Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| de83f97c05 | |||
| fd5337f163 | |||
| 5b1412eab0 | |||
| 363efd6d2d | |||
| 6494f43ddd |
@@ -9,3 +9,4 @@ example.db.bak
|
|||||||
venv/
|
venv/
|
||||||
flet_desktop/
|
flet_desktop/
|
||||||
.flet/
|
.flet/
|
||||||
|
sfgrid.log
|
||||||
|
|||||||
@@ -443,6 +443,33 @@ class RealQmtV:
|
|||||||
"""获取股票名称"""
|
"""获取股票名称"""
|
||||||
return self.cacheStockDetail(stock_code)['InstrumentName']
|
return self.cacheStockDetail(stock_code)['InstrumentName']
|
||||||
|
|
||||||
|
def getInstrumentName_batch(self, stock_codes: list) -> dict:
|
||||||
|
"""批量获取股票名称,返回 {stock_code: name} dict"""
|
||||||
|
result = {}
|
||||||
|
missing = []
|
||||||
|
for code in stock_codes:
|
||||||
|
if code in self.details:
|
||||||
|
result[code] = self.details[code].get('InstrumentName', '')
|
||||||
|
else:
|
||||||
|
missing.append(code)
|
||||||
|
if not missing:
|
||||||
|
return result
|
||||||
|
try:
|
||||||
|
from xtquant import xtdata
|
||||||
|
for code in missing:
|
||||||
|
full_code = self._to_full_code(code)
|
||||||
|
detail = xtdata.get_instrument_detail(full_code)
|
||||||
|
if detail:
|
||||||
|
name = detail.get('instrumentName', detail.get('InstrumentName', ''))
|
||||||
|
self.details[code] = detail
|
||||||
|
result[code] = name
|
||||||
|
else:
|
||||||
|
result[code] = ''
|
||||||
|
except Exception:
|
||||||
|
for code in missing:
|
||||||
|
result[code] = ''
|
||||||
|
return result
|
||||||
|
|
||||||
def dailyUpStop(self, stock_code: str):
|
def dailyUpStop(self, stock_code: str):
|
||||||
"""获取涨停价"""
|
"""获取涨停价"""
|
||||||
detail = self.cacheStockDetail(stock_code)
|
detail = self.cacheStockDetail(stock_code)
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ GRID_HIGH = 11 # 网格上限
|
|||||||
GRID_STEP = 1 # 网格间距(整数格)
|
GRID_STEP = 1 # 网格间距(整数格)
|
||||||
|
|
||||||
# ---- 候选股过滤 ----
|
# ---- 候选股过滤 ----
|
||||||
FILTER_MIN_CLOSE = 8 # 最低收盘价
|
FILTER_MIN_CLOSE = 5 # 最低收盘价(覆盖网格策略持仓股)
|
||||||
FILTER_MAX_CLOSE = 13 # 最高收盘价
|
FILTER_MAX_CLOSE = 13 # 最高收盘价
|
||||||
REQUIRE_DAYS = 120 # 最少交易日数
|
REQUIRE_DAYS = 120 # 最少交易日数
|
||||||
|
|
||||||
@@ -50,5 +50,5 @@ RANK_MODEL = 'rank'
|
|||||||
TOP_MODEL = 'top'
|
TOP_MODEL = 'top'
|
||||||
STACKING_MODEL = 'stacking'
|
STACKING_MODEL = 'stacking'
|
||||||
|
|
||||||
# Stacking 选股阈值
|
# Stacking 选股阈值 (v6.7r3 最优阈值 0.331)
|
||||||
STACKING_THRESHOLD = 0.35
|
STACKING_THRESHOLD = 0.33
|
||||||
@@ -1,9 +1,10 @@
|
|||||||
"""
|
"""
|
||||||
扩展特征 v3.4 (16维)
|
扩展特征 v3.4 + v6.7新增 (20维: 16维 v3.4 + 4维 v6.7新增)
|
||||||
"""
|
"""
|
||||||
import numpy as np
|
import numpy as np
|
||||||
import pandas as pd
|
import pandas as pd
|
||||||
from core.scoring.features.v3_2_features import _ols_slope
|
from core.scoring.features.v3_2_features import _ols_slope
|
||||||
|
from core.scoring.features.v3_3_features import _grid_touch_count
|
||||||
from core.scoring.config import GRID_LOW, GRID_HIGH
|
from core.scoring.config import GRID_LOW, GRID_HIGH
|
||||||
|
|
||||||
|
|
||||||
@@ -104,6 +105,29 @@ def calculate_features_v3_4(ctx) -> pd.DataFrame:
|
|||||||
feat['wick_ratio_20d'] = np.mean(
|
feat['wick_ratio_20d'] = np.mean(
|
||||||
wick_len / np.where(total_len > 0, total_len, 1)) * 100
|
wick_len / np.where(total_len > 0, total_len, 1)) * 100
|
||||||
|
|
||||||
|
# ── v6.7 新增 4 维特征 ──────────────────────────────
|
||||||
|
# 53. vol_decay_5d: 近5日波动率 / 近20日波动率 (波动率用对数收益std)
|
||||||
|
log_ret = np.diff(np.log(np.maximum(closes, 1e-10)))
|
||||||
|
vol_5d = np.std(log_ret[-5:], ddof=1) if len(log_ret) >= 5 else 0
|
||||||
|
vol_20d = np.std(log_ret[-20:], ddof=1) if len(log_ret) >= 20 else vol_5d
|
||||||
|
feat['vol_decay_5d'] = float(vol_5d / vol_20d) if vol_20d > 0 else 0.0
|
||||||
|
|
||||||
|
# 54. grid_touch_relative_10d: 10日振幅比 / 60日振幅比
|
||||||
|
range_10d = float(np.max(highs[-10:]) - np.min(lows[-10:]))
|
||||||
|
range_60d = float(np.max(highs[-60:]) - np.min(lows[-60:])) if len(highs) >= 60 else range_10d
|
||||||
|
close_now = float(closes[-1])
|
||||||
|
close_60d_mean = float(np.mean(closes[-60:])) if len(closes) >= 60 else close_now
|
||||||
|
if close_now > 0 and close_60d_mean > 0 and range_60d > 0:
|
||||||
|
feat['grid_touch_relative_10d'] = (range_10d / close_now) / (range_60d / close_60d_mean)
|
||||||
|
else:
|
||||||
|
feat['grid_touch_relative_10d'] = 0.0
|
||||||
|
|
||||||
|
# 55. vol_decay_x_grid_balance: vol_decay × 网格均衡度
|
||||||
|
feat['vol_decay_x_grid_balance'] = feat['vol_decay_5d'] * feat.get('grid_room_balance', 0.0)
|
||||||
|
|
||||||
|
# 56. vol_decay_x_dist_lower: vol_decay × 下轨距离
|
||||||
|
feat['vol_decay_x_dist_lower'] = feat['vol_decay_5d'] * feat.get('dist_to_grid_lower', 0.0)
|
||||||
|
|
||||||
features[code] = feat
|
features[code] = feat
|
||||||
|
|
||||||
return pd.DataFrame.from_dict(features, orient='index')
|
return pd.DataFrame.from_dict(features, orient='index')
|
||||||
|
|||||||
@@ -128,6 +128,18 @@ def load_candidates(trade_date: date) -> DataContext:
|
|||||||
PrintLog(LogLevel.INFO,
|
PrintLog(LogLevel.INFO,
|
||||||
f'[validator] 候选: {len(candidates)} 通过, {len(excluded)} 排除')
|
f'[validator] 候选: {len(candidates)} 通过, {len(excluded)} 排除')
|
||||||
|
|
||||||
|
# 3.5 补充:强制加入网格持仓股(不受价格过滤限制)
|
||||||
|
from core.sfgrid.model import SFGridTradeTarget
|
||||||
|
pos_rows = list(SFGridTradeTarget
|
||||||
|
.select(SFGridTradeTarget.stock_code)
|
||||||
|
.where(SFGridTradeTarget.enabled == True)
|
||||||
|
.dicts())
|
||||||
|
pos_codes = [r['stock_code'].split('.')[0] for r in pos_rows]
|
||||||
|
forced = [c for c in pos_codes if c not in candidates and c not in excluded]
|
||||||
|
if forced:
|
||||||
|
PrintLog(LogLevel.INFO, f'[validator] 强制加入网格持仓股: {forced}')
|
||||||
|
candidates.extend(forced)
|
||||||
|
|
||||||
# 4. 裁剪K线到只含候选股 (保留最近180日)
|
# 4. 裁剪K线到只含候选股 (保留最近180日)
|
||||||
kline_df = kline_df[kline_df['stock_code'].isin(candidates)].copy()
|
kline_df = kline_df[kline_df['stock_code'].isin(candidates)].copy()
|
||||||
cut_date = trade_date - timedelta(days=365)
|
cut_date = trade_date - timedelta(days=365)
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
"""
|
"""
|
||||||
grid_seeker v6.6 三级模型推理管道
|
grid_seeker v6.7r3 三级模型推理管道
|
||||||
Rank → Top → Stacking → stacking_probability (最终排序)
|
Rank → Top → Stacking → stacking_probability (最终排序)
|
||||||
"""
|
"""
|
||||||
import pickle
|
import pickle
|
||||||
@@ -18,7 +18,7 @@ from core.logger import LogLevel, PrintLog
|
|||||||
|
|
||||||
|
|
||||||
# ============================================================
|
# ============================================================
|
||||||
# Rank 模型输入特征 (52维, v3.4, 直接从模型文件的 selected_features 读取)
|
# Rank 模型输入特征 (56维 v6.7/v3.4, 同时支持 feat_names 和 selected_features)
|
||||||
# ============================================================
|
# ============================================================
|
||||||
def _get_rank_features() -> list:
|
def _get_rank_features() -> list:
|
||||||
import pickle
|
import pickle
|
||||||
@@ -27,17 +27,26 @@ def _get_rank_features() -> list:
|
|||||||
with open(path, 'rb') as f:
|
with open(path, 'rb') as f:
|
||||||
obj = pickle.load(f)
|
obj = pickle.load(f)
|
||||||
if isinstance(obj, dict):
|
if isinstance(obj, dict):
|
||||||
sf = obj.get('selected_features', [])
|
# v6.7r3 使用 feat_names, v6.6 使用 selected_features
|
||||||
|
sf = obj.get('feat_names', []) or obj.get('selected_features', [])
|
||||||
if sf:
|
if sf:
|
||||||
return sf
|
return sf
|
||||||
raise RuntimeError("无法从 rank.pkl 读取 selected_features")
|
raise RuntimeError("无法从 rank.pkl 读取 feat_names 或 selected_features")
|
||||||
|
|
||||||
RANK_FEATURE_COLS = _get_rank_features()
|
RANK_FEATURE_COLS = _get_rank_features()
|
||||||
|
|
||||||
|
# Top/Stacking 模型只用 52 维基础特征(不含 v6.7 新增的4维)
|
||||||
|
# v6.7 新增: vol_decay_5d, grid_touch_relative_10d, vol_decay_x_grid_balance, vol_decay_x_dist_lower
|
||||||
|
_V67_NEW_FEATS = {
|
||||||
|
'vol_decay_5d', 'grid_touch_relative_10d',
|
||||||
|
'vol_decay_x_grid_balance', 'vol_decay_x_dist_lower'
|
||||||
|
}
|
||||||
|
BASE_52_COLS = [f for f in RANK_FEATURE_COLS if f not in _V67_NEW_FEATS]
|
||||||
|
|
||||||
|
|
||||||
class GridSeekerPipeline:
|
class GridSeekerPipeline:
|
||||||
"""
|
"""
|
||||||
grid_seeker v6.6 三级模型评分管道。
|
grid_seeker v6.7r3 三级模型评分管道。
|
||||||
|
|
||||||
Usage:
|
Usage:
|
||||||
engine = GridSeekerPipeline()
|
engine = GridSeekerPipeline()
|
||||||
@@ -120,7 +129,7 @@ class GridSeekerPipeline:
|
|||||||
DataFrame indexed by stock_code, 含 stacking_probability / rank 等列,
|
DataFrame indexed by stock_code, 含 stacking_probability / rank 等列,
|
||||||
按 stacking_probability 降序排列
|
按 stacking_probability 降序排列
|
||||||
"""
|
"""
|
||||||
PrintLog(LogLevel.INFO, f'[scorer] ===== grid_seeker v6.6 评分开始 ({trade_date}) =====')
|
PrintLog(LogLevel.INFO, f'[scorer] ===== grid_seeker v6.7r3 评分开始 ({trade_date}) =====')
|
||||||
|
|
||||||
# 1. 特征工程
|
# 1. 特征工程
|
||||||
pipeline = FeaturePipeline(trade_date)
|
pipeline = FeaturePipeline(trade_date)
|
||||||
@@ -140,16 +149,16 @@ class GridSeekerPipeline:
|
|||||||
self.rank_model, feature_df, RANK_FEATURE_COLS
|
self.rank_model, feature_df, RANK_FEATURE_COLS
|
||||||
)
|
)
|
||||||
|
|
||||||
# 3. Stage 2: Top 模型 → top_elite_prob (53维 = 52 + rank_predicted_rounds)
|
# 3. Stage 2: Top 模型 → top_elite_prob (53维 = 52基础 + rank)
|
||||||
PrintLog(LogLevel.INFO, '[scorer] Stage 2/3: Top 模型...')
|
PrintLog(LogLevel.INFO, '[scorer] Stage 2/3: Top 模型...')
|
||||||
top_cols = RANK_FEATURE_COLS + ['rank_predicted_rounds']
|
top_cols = BASE_52_COLS + ['rank_predicted_rounds']
|
||||||
feature_df['top_elite_prob'] = self._predict_with_model(
|
feature_df['top_elite_prob'] = self._predict_with_model(
|
||||||
self.top_model, feature_df, top_cols
|
self.top_model, feature_df, top_cols
|
||||||
)
|
)
|
||||||
|
|
||||||
# 4. Stage 3: Stacking 模型 → stacking_probability (54维 = 52 + rank + top)
|
# 4. Stage 3: Stacking 模型 → stacking_probability (55维 = 52基础 + rank + top)
|
||||||
PrintLog(LogLevel.INFO, '[scorer] Stage 3/3: Stacking 模型...')
|
PrintLog(LogLevel.INFO, '[scorer] Stage 3/3: Stacking 模型...')
|
||||||
stk_cols = RANK_FEATURE_COLS + ['rank_predicted_rounds', 'top_elite_prob']
|
stk_cols = BASE_52_COLS + ['rank_predicted_rounds', 'top_elite_prob']
|
||||||
feature_df['stacking_probability'] = self._predict_with_model(
|
feature_df['stacking_probability'] = self._predict_with_model(
|
||||||
self.stacking_model, feature_df, stk_cols
|
self.stacking_model, feature_df, stk_cols
|
||||||
)
|
)
|
||||||
|
|||||||
+15
-1
@@ -121,12 +121,26 @@ class ScoringResult(BaseModel):
|
|||||||
primary_key = CompositeKey('stock_code', 'trade_date')
|
primary_key = CompositeKey('stock_code', 'trade_date')
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# 8. PendingPoolAction — 待执行股票池操作(阶段一标记,阶段二执行)
|
||||||
|
# ============================================================
|
||||||
|
class PendingPoolAction(BaseModel):
|
||||||
|
"""pending_pool_actions 表 — T日标记的操作,待 T+1 执行"""
|
||||||
|
action_date = DateField() # T日日期(标记日期)
|
||||||
|
action_type = CharField(max_length=20) # 'eliminate' | 'liquidate'
|
||||||
|
stock_code = CharField(max_length=6) # 股票代码
|
||||||
|
reason = TextField(null=True) # 淘汰原因描述
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
primary_key = CompositeKey('action_date', 'action_type', 'stock_code')
|
||||||
|
|
||||||
|
|
||||||
# ============================================================
|
# ============================================================
|
||||||
# 建表
|
# 建表
|
||||||
# ============================================================
|
# ============================================================
|
||||||
ALL_SCORING_TABLES = [
|
ALL_SCORING_TABLES = [
|
||||||
KlineStock, StockInfo, IndustryMapping, KlineIndex,
|
KlineStock, StockInfo, IndustryMapping, KlineIndex,
|
||||||
MarketRegimeDaily, SectorFeaturesDaily, ScoringResult,
|
MarketRegimeDaily, SectorFeaturesDaily, ScoringResult, PendingPoolAction,
|
||||||
]
|
]
|
||||||
|
|
||||||
db.create_tables(ALL_SCORING_TABLES)
|
db.create_tables(ALL_SCORING_TABLES)
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ K线数据同步 — 个股日K + 指数日K
|
|||||||
线程锁: KlineStockSync / KlineIndexSync 各自内部锁
|
线程锁: KlineStockSync / KlineIndexSync 各自内部锁
|
||||||
"""
|
"""
|
||||||
import pandas as pd
|
import pandas as pd
|
||||||
from datetime import date, timedelta
|
from datetime import date, datetime, timedelta
|
||||||
from core.scoring.sync.base import BaseSync
|
from core.scoring.sync.base import BaseSync
|
||||||
from core.scoring.models import KlineStock, KlineIndex
|
from core.scoring.models import KlineStock, KlineIndex
|
||||||
from core.scoring.config import TRACKED_INDICES
|
from core.scoring.config import TRACKED_INDICES
|
||||||
@@ -58,19 +58,18 @@ class KlineStockSync(BaseSync):
|
|||||||
def _fetch(self, **kwargs):
|
def _fetch(self, **kwargs):
|
||||||
from xtquant import xtdata
|
from xtquant import xtdata
|
||||||
|
|
||||||
# 增量判断
|
# 增量判断: 以数据库最新一条记录为准
|
||||||
latest = _latest_date(KlineStock)
|
latest = _latest_date(KlineStock)
|
||||||
today = date.today()
|
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
|
|
||||||
|
|
||||||
# 增量起点
|
# 增量起点: last_db_date + 1; 截止: 昨天(盘中不能同步当天数据)
|
||||||
|
# 注意: 不能用 latest >= today 跳过,因为 latest 可能是错误的未收盘数据
|
||||||
start_date = (latest + timedelta(days=1)) if latest else None
|
start_date = (latest + timedelta(days=1)) if latest else None
|
||||||
|
end_date = today - timedelta(days=1) # 固定截止到昨天,收盘后同步昨天数据
|
||||||
start_str = start_date.strftime('%Y%m%d') if start_date else ""
|
start_str = start_date.strftime('%Y%m%d') if start_date else ""
|
||||||
|
end_str = end_date.strftime('%Y%m%d')
|
||||||
PrintLog(LogLevel.INFO,
|
PrintLog(LogLevel.INFO,
|
||||||
f'[sync] KlineStock: 增量同步,起点={start_str or "全部"}')
|
f'[sync] KlineStock: 增量同步 {start_str} ~ {end_str}')
|
||||||
|
|
||||||
all_stocks = xtdata.get_stock_list_in_sector("沪深A股")
|
all_stocks = xtdata.get_stock_list_in_sector("沪深A股")
|
||||||
PrintLog(LogLevel.INFO, f'[sync] KlineStock: {len(all_stocks)} 只A股')
|
PrintLog(LogLevel.INFO, f'[sync] KlineStock: {len(all_stocks)} 只A股')
|
||||||
@@ -83,7 +82,7 @@ class KlineStockSync(BaseSync):
|
|||||||
PrintLog(LogLevel.INFO, f'[sync] KlineStock: {i}/{total} ({i*100//total}%)')
|
PrintLog(LogLevel.INFO, f'[sync] KlineStock: {i}/{total} ({i*100//total}%)')
|
||||||
|
|
||||||
try:
|
try:
|
||||||
xtdata.download_history_data(code, period='1d', start_time=start_str)
|
xtdata.download_history_data(code, period='1d', start_time=start_str, end_time=end_str)
|
||||||
except Exception:
|
except Exception:
|
||||||
self.stats['errors'] += 1
|
self.stats['errors'] += 1
|
||||||
continue
|
continue
|
||||||
@@ -91,8 +90,9 @@ class KlineStockSync(BaseSync):
|
|||||||
try:
|
try:
|
||||||
result = xtdata.get_market_data(
|
result = xtdata.get_market_data(
|
||||||
field_list=field_list, stock_list=[code], period='1d',
|
field_list=field_list, stock_list=[code], period='1d',
|
||||||
count=self.count, dividend_type='none', fill_data=False)
|
start_time=start_str, end_time=end_str,
|
||||||
inserted += self._upsert_incremental(code, result, start_date)
|
dividend_type='none', fill_data=False)
|
||||||
|
inserted += self._upsert_incremental(code, result, start_date, end_date)
|
||||||
except Exception:
|
except Exception:
|
||||||
self.stats['errors'] += 1
|
self.stats['errors'] += 1
|
||||||
|
|
||||||
@@ -102,7 +102,7 @@ class KlineStockSync(BaseSync):
|
|||||||
f'跳过={self.stats["skipped"]} 错误={self.stats["errors"]}')
|
f'跳过={self.stats["skipped"]} 错误={self.stats["errors"]}')
|
||||||
return self.stats
|
return self.stats
|
||||||
|
|
||||||
def _upsert_incremental(self, full_code: str, result: dict, start_date) -> int:
|
def _upsert_incremental(self, full_code: str, result: dict, start_date, end_date) -> int:
|
||||||
if not result:
|
if not result:
|
||||||
return 0
|
return 0
|
||||||
close_df = result.get('close')
|
close_df = result.get('close')
|
||||||
@@ -110,9 +110,23 @@ class KlineStockSync(BaseSync):
|
|||||||
return 0
|
return 0
|
||||||
stock_code = full_code.split('.')[0]
|
stock_code = full_code.split('.')[0]
|
||||||
records = []
|
records = []
|
||||||
|
vol_df = result.get('volume')
|
||||||
for td in close_df.columns:
|
for td in close_df.columns:
|
||||||
td_date = td.date() if hasattr(td, 'date') else td
|
# xtdata 返回的列名可能是字符串 'YYYYMMDD' 或 datetime,需统一转成 date
|
||||||
if start_date is not None and td_date <= start_date:
|
if isinstance(td, str):
|
||||||
|
td_date = datetime.strptime(td, '%Y%m%d').date()
|
||||||
|
else:
|
||||||
|
td_date = td.date() if hasattr(td, 'date') else td
|
||||||
|
# 过滤: 不在增量范围内的跳过 (start_date < td <= end_date)
|
||||||
|
if start_date is not None and td_date < start_date:
|
||||||
|
self.stats['skipped'] = self.stats.get('skipped', 0) + 1
|
||||||
|
continue
|
||||||
|
if end_date is not None and td_date > end_date:
|
||||||
|
self.stats['skipped'] = self.stats.get('skipped', 0) + 1
|
||||||
|
continue
|
||||||
|
# 跳过成交量为0的无效数据(盘中未结算数据)
|
||||||
|
vol = vol_df.loc[full_code, td] if vol_df is not None else None
|
||||||
|
if vol is None or (isinstance(vol, float) and pd.isna(vol)) or vol == 0:
|
||||||
self.stats['skipped'] = self.stats.get('skipped', 0) + 1
|
self.stats['skipped'] = self.stats.get('skipped', 0) + 1
|
||||||
continue
|
continue
|
||||||
close_val = close_df.loc[full_code, td]
|
close_val = close_df.loc[full_code, td]
|
||||||
@@ -126,7 +140,7 @@ class KlineStockSync(BaseSync):
|
|||||||
'high': _safe_get(result.get('high'), full_code, td),
|
'high': _safe_get(result.get('high'), full_code, td),
|
||||||
'low': _safe_get(result.get('low'), full_code, td),
|
'low': _safe_get(result.get('low'), full_code, td),
|
||||||
'close': float(close_val),
|
'close': float(close_val),
|
||||||
'volume': _safe_get(result.get('volume'), full_code, td),
|
'volume': float(vol),
|
||||||
})
|
})
|
||||||
if records:
|
if records:
|
||||||
with db.atomic():
|
with db.atomic():
|
||||||
@@ -161,40 +175,54 @@ class KlineIndexSync(BaseSync):
|
|||||||
|
|
||||||
latest = _latest_date(KlineIndex)
|
latest = _latest_date(KlineIndex)
|
||||||
today = date.today()
|
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_date = (latest + timedelta(days=1)) if latest else None
|
||||||
|
end_date = today - timedelta(days=1) # 截止到昨天
|
||||||
start_str = start_date.strftime('%Y%m%d') if start_date else ""
|
start_str = start_date.strftime('%Y%m%d') if start_date else ""
|
||||||
|
end_str = end_date.strftime('%Y%m%d')
|
||||||
PrintLog(LogLevel.INFO,
|
PrintLog(LogLevel.INFO,
|
||||||
f'[sync] KlineIndex: 增量同步,起点={start_str or "全部"}')
|
f'[sync] KlineIndex: 增量同步 {start_str} ~ {end_str}')
|
||||||
|
|
||||||
for code in index_codes:
|
for code in index_codes:
|
||||||
try:
|
try:
|
||||||
xtdata.download_history_data(code, period='1d', start_time=start_str)
|
xtdata.download_history_data(code, period='1d', start_time=start_str, end_time=end_str)
|
||||||
except Exception:
|
except Exception:
|
||||||
self.stats['errors'] += 1
|
self.stats['errors'] += 1
|
||||||
|
|
||||||
field_list = ['open', 'high', 'low', 'close', 'volume']
|
field_list = ['open', 'high', 'low', 'close', 'volume']
|
||||||
result = xtdata.get_market_data(
|
result = xtdata.get_market_data(
|
||||||
field_list=field_list, stock_list=index_codes, period='1d',
|
field_list=field_list, stock_list=index_codes, period='1d',
|
||||||
count=self.count, dividend_type='none', fill_data=False)
|
start_time=start_str, end_time=end_str,
|
||||||
return result or {}
|
dividend_type='none', fill_data=False)
|
||||||
|
|
||||||
def _upsert(self, data):
|
def _upsert(self, data):
|
||||||
if not data:
|
if not data:
|
||||||
return
|
return
|
||||||
latest = _latest_date(KlineIndex)
|
latest = _latest_date(KlineIndex)
|
||||||
|
today = date.today()
|
||||||
|
start_date = (latest + timedelta(days=1)) if latest else None
|
||||||
|
end_date = today - timedelta(days=1)
|
||||||
records = []
|
records = []
|
||||||
close_df = data.get('close')
|
close_df = data.get('close')
|
||||||
|
vol_df = data.get('volume')
|
||||||
if close_df is None or close_df.empty:
|
if close_df is None or close_df.empty:
|
||||||
return
|
return
|
||||||
for full_code in close_df.index:
|
for full_code in close_df.index:
|
||||||
index_code = full_code.split('.')[0]
|
index_code = full_code.split('.')[0]
|
||||||
for td in close_df.columns:
|
for td in close_df.columns:
|
||||||
td_date = td.date() if hasattr(td, 'date') else td
|
if isinstance(td, str):
|
||||||
if latest is not None and td_date <= latest:
|
td_date = datetime.strptime(td, '%Y%m%d').date()
|
||||||
|
else:
|
||||||
|
td_date = td.date() if hasattr(td, 'date') else td
|
||||||
|
# 增量范围过滤 (start_date < td <= end_date)
|
||||||
|
if start_date is not None and td_date < start_date:
|
||||||
|
self.stats['skipped'] = self.stats.get('skipped', 0) + 1
|
||||||
|
continue
|
||||||
|
if end_date is not None and td_date > end_date:
|
||||||
|
self.stats['skipped'] = self.stats.get('skipped', 0) + 1
|
||||||
|
continue
|
||||||
|
# 过滤成交量为0的无效数据
|
||||||
|
vol = vol_df.loc[full_code, td] if vol_df is not None else None
|
||||||
|
if vol is None or (isinstance(vol, float) and pd.isna(vol)) or vol == 0:
|
||||||
self.stats['skipped'] = self.stats.get('skipped', 0) + 1
|
self.stats['skipped'] = self.stats.get('skipped', 0) + 1
|
||||||
continue
|
continue
|
||||||
close_val = close_df.loc[full_code, td]
|
close_val = close_df.loc[full_code, td]
|
||||||
@@ -208,7 +236,7 @@ class KlineIndexSync(BaseSync):
|
|||||||
'high': _safe_get(data.get('high'), full_code, td),
|
'high': _safe_get(data.get('high'), full_code, td),
|
||||||
'low': _safe_get(data.get('low'), full_code, td),
|
'low': _safe_get(data.get('low'), full_code, td),
|
||||||
'close': float(close_val),
|
'close': float(close_val),
|
||||||
'volume': _safe_get(data.get('volume'), full_code, td),
|
'volume': float(vol),
|
||||||
})
|
})
|
||||||
if records:
|
if records:
|
||||||
with db.atomic():
|
with db.atomic():
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ class SectorFeaturesSync(BaseSync):
|
|||||||
"""行业聚合指数同步 — kline_stock + industry → sector_features_daily"""
|
"""行业聚合指数同步 — kline_stock + industry → sector_features_daily"""
|
||||||
|
|
||||||
def _fetch(self, **kwargs):
|
def _fetch(self, **kwargs):
|
||||||
"""从数据库加载原始数据, 计算行业指数特征"""
|
"""从数据库加载原始数据, 计算行业指数特征(分块处理避免内存溢出)"""
|
||||||
PrintLog(LogLevel.INFO, '[sync] SectorFeatures: 加载原始数据...')
|
PrintLog(LogLevel.INFO, '[sync] SectorFeatures: 加载原始数据...')
|
||||||
|
|
||||||
# 1. 加载行业映射: code → industry_name
|
# 1. 加载行业映射: code → industry_name
|
||||||
@@ -24,49 +24,81 @@ class SectorFeaturesSync(BaseSync):
|
|||||||
code_to_industry = {row['code']: row['industry_name'] for row in industries}
|
code_to_industry = {row['code']: row['industry_name'] for row in industries}
|
||||||
PrintLog(LogLevel.INFO, f'[sync] SectorFeatures: {len(code_to_industry)} 条行业映射')
|
PrintLog(LogLevel.INFO, f'[sync] SectorFeatures: {len(code_to_industry)} 条行业映射')
|
||||||
|
|
||||||
# 2. 加载 K 线数据
|
# 2. 分块加载 K 线数据,避免内存溢出
|
||||||
kline_rows = (KlineStock
|
# 聚合结果: {(trade_date, sector_name): [sum_pct_chg, sum_amp, count]}
|
||||||
.select(
|
sector_daily_agg = {} # key: (date, sector) -> {'ret_sum': float, 'amp_sum': float, 'count': int}
|
||||||
KlineStock.stock_code,
|
CHUNK_SIZE = 50000
|
||||||
KlineStock.trade_date,
|
last_date_per_stock = {} # stock_code -> prev_close
|
||||||
KlineStock.open,
|
|
||||||
KlineStock.high,
|
|
||||||
KlineStock.low,
|
|
||||||
KlineStock.close,
|
|
||||||
)
|
|
||||||
.order_by(KlineStock.stock_code, KlineStock.trade_date)
|
|
||||||
.dicts())
|
|
||||||
|
|
||||||
if not kline_rows:
|
PrintLog(LogLevel.INFO, '[sync] SectorFeatures: 分块处理K线数据...')
|
||||||
PrintLog(LogLevel.WARNING, '[sync] SectorFeatures: KlineStock 表为空')
|
chunk_num = 0
|
||||||
|
while True:
|
||||||
|
chunk_num += 1
|
||||||
|
rows = list(KlineStock
|
||||||
|
.select(
|
||||||
|
KlineStock.stock_code,
|
||||||
|
KlineStock.trade_date,
|
||||||
|
KlineStock.open,
|
||||||
|
KlineStock.high,
|
||||||
|
KlineStock.low,
|
||||||
|
KlineStock.close,
|
||||||
|
)
|
||||||
|
.order_by(KlineStock.stock_code, KlineStock.trade_date)
|
||||||
|
.offset((chunk_num - 1) * CHUNK_SIZE)
|
||||||
|
.limit(CHUNK_SIZE)
|
||||||
|
.dicts())
|
||||||
|
if not rows:
|
||||||
|
break
|
||||||
|
|
||||||
|
PrintLog(LogLevel.INFO, f'[sync] SectorFeatures: 处理块 {chunk_num} ({len(rows)} 行)...')
|
||||||
|
|
||||||
|
for row in rows:
|
||||||
|
code = str(row['stock_code'])
|
||||||
|
td = row['trade_date']
|
||||||
|
open_p = float(row['open'])
|
||||||
|
high = float(row['high'])
|
||||||
|
low = float(row['low'])
|
||||||
|
close = float(row['close'])
|
||||||
|
|
||||||
|
sector = code_to_industry.get(code)
|
||||||
|
if sector is None:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# 计算日收益率和振幅
|
||||||
|
prev_close = last_date_per_stock.get(code)
|
||||||
|
if prev_close is not None and prev_close > 0 and open_p > 0 and close > 0:
|
||||||
|
pct_chg = (close - prev_close) / prev_close * 100
|
||||||
|
amp = (high - low) / open_p * 100
|
||||||
|
key = (td, sector)
|
||||||
|
if key not in sector_daily_agg:
|
||||||
|
sector_daily_agg[key] = {'ret_sum': 0.0, 'amp_sum': 0.0, 'count': 0}
|
||||||
|
sector_daily_agg[key]['ret_sum'] += pct_chg
|
||||||
|
sector_daily_agg[key]['amp_sum'] += amp
|
||||||
|
sector_daily_agg[key]['count'] += 1
|
||||||
|
|
||||||
|
last_date_per_stock[code] = close
|
||||||
|
|
||||||
|
if not sector_daily_agg:
|
||||||
|
PrintLog(LogLevel.WARNING, '[sync] SectorFeatures: 无有效K线数据')
|
||||||
return None
|
return None
|
||||||
|
|
||||||
df = pd.DataFrame(kline_rows)
|
PrintLog(LogLevel.INFO, f'[sync] SectorFeatures: 聚合完成, {len(sector_daily_agg)} 个行业-日组合')
|
||||||
df['trade_date'] = pd.to_datetime(df['trade_date'])
|
|
||||||
PrintLog(LogLevel.INFO, f'[sync] SectorFeatures: {len(df)} 条K线数据')
|
|
||||||
|
|
||||||
# 3. 映射行业
|
# 3. 构建聚合 DataFrame
|
||||||
df['sector_name'] = df['stock_code'].map(code_to_industry)
|
agg_data = []
|
||||||
df = df.dropna(subset=['sector_name'])
|
for (td, sector), vals in sector_daily_agg.items():
|
||||||
|
agg_data.append({
|
||||||
|
'trade_date': td,
|
||||||
|
'sector_name': sector,
|
||||||
|
'sector_ret': vals['ret_sum'] / vals['count'],
|
||||||
|
'sector_amplitude': vals['amp_sum'] / vals['count'],
|
||||||
|
})
|
||||||
|
agg = pd.DataFrame(agg_data)
|
||||||
|
agg = agg.sort_values(['sector_name', 'trade_date'])
|
||||||
|
agg['trade_date'] = pd.to_datetime(agg['trade_date'])
|
||||||
|
PrintLog(LogLevel.INFO, f'[sync] SectorFeatures: {len(agg)} 行, {agg["sector_name"].nunique()} 个行业')
|
||||||
|
|
||||||
# 4. 逐股计算日收益率和振幅
|
# 4. 构建行业指数 (基值=100)
|
||||||
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 = agg.sort_values(['sector_name', 'trade_date'])
|
||||||
agg['sector_index'] = agg.groupby('sector_name')['sector_ret'].transform(
|
agg['sector_index'] = agg.groupby('sector_name')['sector_ret'].transform(
|
||||||
lambda x: (1 + x / 100).cumprod() * 100
|
lambda x: (1 + x / 100).cumprod() * 100
|
||||||
@@ -77,7 +109,7 @@ class SectorFeaturesSync(BaseSync):
|
|||||||
first_val = group['sector_index'].iloc[0]
|
first_val = group['sector_index'].iloc[0]
|
||||||
agg.loc[idx, 'sector_index'] = group['sector_index'] / first_val * 100
|
agg.loc[idx, 'sector_index'] = group['sector_index'] / first_val * 100
|
||||||
|
|
||||||
# 7. 计算 EMA 均线
|
# 5. 计算 EMA 均线
|
||||||
agg['ema10'] = (agg.groupby('sector_name')['sector_index']
|
agg['ema10'] = (agg.groupby('sector_name')['sector_index']
|
||||||
.transform(lambda x: x.ewm(span=10, min_periods=1).mean()))
|
.transform(lambda x: x.ewm(span=10, min_periods=1).mean()))
|
||||||
agg['ema20'] = (agg.groupby('sector_name')['sector_index']
|
agg['ema20'] = (agg.groupby('sector_name')['sector_index']
|
||||||
@@ -85,7 +117,7 @@ class SectorFeaturesSync(BaseSync):
|
|||||||
agg['ema200'] = (agg.groupby('sector_name')['sector_index']
|
agg['ema200'] = (agg.groupby('sector_name')['sector_index']
|
||||||
.transform(lambda x: x.ewm(span=200, min_periods=1).mean()))
|
.transform(lambda x: x.ewm(span=200, min_periods=1).mean()))
|
||||||
|
|
||||||
# 8. 趋势评分: close>ema200 得1分 + ema10>ema20 得1分
|
# 6. 趋势评分: close>ema200 得1分 + ema10>ema20 得1分
|
||||||
agg['score'] = (
|
agg['score'] = (
|
||||||
(agg['sector_index'] > agg['ema200']).astype(int) +
|
(agg['sector_index'] > agg['ema200']).astype(int) +
|
||||||
(agg['ema10'] > agg['ema20']).astype(int)
|
(agg['ema10'] > agg['ema20']).astype(int)
|
||||||
|
|||||||
@@ -5,3 +5,11 @@ EventTradeTargetDeleted = "trade_target_deleted"
|
|||||||
# 评分系统事件
|
# 评分系统事件
|
||||||
EventScoringCompleted = "scoring_completed" # 评分完成, data: {'date', 'count'}
|
EventScoringCompleted = "scoring_completed" # 评分完成, data: {'date', 'count'}
|
||||||
EventSyncProgress = "sync_progress" # 同步进度, data: {'source', 'status', 'stats'}
|
EventSyncProgress = "sync_progress" # 同步进度, data: {'source', 'status', 'stats'}
|
||||||
|
|
||||||
|
# 股票池管理器事件(阶段一)
|
||||||
|
# T日收盘标记完成, data: {action: 'eliminate'|'liquidate', stock_codes: list[str], count: int}
|
||||||
|
EventPoolMark = "pool_mark"
|
||||||
|
|
||||||
|
# T+1日执行完成(阶段二用)
|
||||||
|
# data: {action: 'eliminate'|'liquidate'|'refill', stock_codes: list[str], count: int}
|
||||||
|
EventPoolActionExecute = "pool_action_execute"
|
||||||
|
|||||||
@@ -0,0 +1,486 @@
|
|||||||
|
"""
|
||||||
|
pool_manager.py — 网格自动交易股票池管理器(阶段一:T日标记)
|
||||||
|
===================================================================
|
||||||
|
后台 daemon 线程运行,每日定时:
|
||||||
|
09:25 K线数据同步
|
||||||
|
09:30 执行评分
|
||||||
|
15:30 沉寂检测标记
|
||||||
|
周五15:30 额外执行周度淘汰标记
|
||||||
|
|
||||||
|
所有操作只记录到数据库,不执行真实交易(阶段二实现)。
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from collections import defaultdict
|
||||||
|
from datetime import date, datetime, timedelta
|
||||||
|
import numpy as np
|
||||||
|
import pandas as pd
|
||||||
|
|
||||||
|
from core.logger import LogLevel, PrintLog
|
||||||
|
from core.scoring.models import PendingPoolAction, ScoringResult
|
||||||
|
from core.sfgrid.model import SFGridTradeTarget
|
||||||
|
from core.sfgrid.bus_events import EventPoolMark
|
||||||
|
from core.eventbus import event_bus
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# 配置
|
||||||
|
# ============================================================
|
||||||
|
TOP_N = 10 # 最大持仓数
|
||||||
|
TOP_MODEL_N = 50 # 评分池 Top N
|
||||||
|
ELIM_WINDOW = 2 # 连续 N 周不在 Top50 则淘汰
|
||||||
|
SLUMBER_DAYS = 10 # 沉寂触发天数(连续)
|
||||||
|
SLUMBER_TRIGGERS = 3 # 沉寂触发特征数(5个中触发几个)
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# 沉寂检测 — 直接复用 ref_backtest_strategy.py 的纯函数
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
def is_slumbering(df: pd.DataFrame,
|
||||||
|
lookback_60: int = 60,
|
||||||
|
lookback_20: int = 20,
|
||||||
|
min_triggers: int = SLUMBER_TRIGGERS) -> bool:
|
||||||
|
"""
|
||||||
|
检测一只股是否陷入'沉寂'(资金离场后长期低位震荡)。
|
||||||
|
5 特征,>= min_triggers 触发则返回 True。
|
||||||
|
|
||||||
|
df 要求:包含 close/high/low/volume 列,index 为日期升序,
|
||||||
|
至少 60 条记录。
|
||||||
|
"""
|
||||||
|
if df is None or len(df) < lookback_60:
|
||||||
|
return False
|
||||||
|
|
||||||
|
# 过滤停牌日期(volume=0 的行会导致 log_ret = NaN)
|
||||||
|
active = df[df["volume"] > 0]
|
||||||
|
if len(active) < lookback_60:
|
||||||
|
return False
|
||||||
|
|
||||||
|
sub = active.tail(lookback_60)
|
||||||
|
close = sub["close"].values
|
||||||
|
high = sub["high"].values
|
||||||
|
low = sub["low"].values
|
||||||
|
vol = sub["volume"].values
|
||||||
|
|
||||||
|
# 1. 波动率塌陷
|
||||||
|
log_ret = np.log(close[1:] / close[:-1])
|
||||||
|
if len(log_ret) < lookback_20:
|
||||||
|
return False
|
||||||
|
vol_20d = float(np.std(log_ret[-lookback_20:], ddof=1))
|
||||||
|
vol_60d = float(np.std(log_ret, ddof=1))
|
||||||
|
vol_collapse = (vol_60d > 0) and (vol_20d / vol_60d < 0.6)
|
||||||
|
|
||||||
|
# 2. 振幅萎缩
|
||||||
|
amp_20d = float(np.mean((high[-lookback_20:] - low[-lookback_20:]) / close[-lookback_20:]) * 100)
|
||||||
|
amp_shrink = amp_20d < 2.5
|
||||||
|
|
||||||
|
# 3. 成交量枯竭
|
||||||
|
avg_vol_20 = float(np.mean(vol[-lookback_20:]))
|
||||||
|
avg_vol_60 = float(np.mean(vol))
|
||||||
|
vol_dry = (avg_vol_60 > 0) and (avg_vol_20 / avg_vol_60 < 0.5)
|
||||||
|
|
||||||
|
# 4. 价格弱势
|
||||||
|
price_max_60 = float(np.max(close))
|
||||||
|
price_weak = price_max_60 > 0 and (close[-1] / price_max_60) < 0.85
|
||||||
|
|
||||||
|
# 5. 反弹失败
|
||||||
|
recent_high_30 = float(np.max(high[-30:]))
|
||||||
|
past_high_60 = float(np.max(high))
|
||||||
|
rebound_fail = past_high_60 > 0 and (recent_high_30 / past_high_60) < 0.95
|
||||||
|
|
||||||
|
triggers = [vol_collapse, amp_shrink, vol_dry, price_weak, rebound_fail]
|
||||||
|
return sum(triggers) >= min_triggers
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# 工具函数
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
def is_trading_day(td: date) -> bool:
|
||||||
|
"""简单判断是否为交易日(周一~周五)"""
|
||||||
|
return td.weekday() < 5 # 0=周一, 4=周五
|
||||||
|
|
||||||
|
|
||||||
|
def get_week_id(td: date) -> int:
|
||||||
|
"""返回年内周序号(周一为起始)"""
|
||||||
|
return td.isocalendar()[1]
|
||||||
|
|
||||||
|
|
||||||
|
def seconds_to_target(target_hour: int, target_minute: int) -> float:
|
||||||
|
"""计算从现在到目标时间(当天 target_hour:target_minute)的秒数。"""
|
||||||
|
now = datetime.now()
|
||||||
|
today_target = datetime(now.year, now.month, now.day, target_hour, target_minute, 0)
|
||||||
|
if now >= today_target:
|
||||||
|
# 今天已过,推到明天
|
||||||
|
today_target += timedelta(days=1)
|
||||||
|
return (today_target - now).total_seconds()
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# 股票池管理器
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
class PoolManager:
|
||||||
|
"""
|
||||||
|
网格股票池自动管理器(阶段一:T日标记)
|
||||||
|
|
||||||
|
使用 threading.Timer 递归调度,实现每日 09:25 / 09:30 / 15:30 定时任务。
|
||||||
|
所有操作只写入数据库,不执行真实交易。
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self._thread: threading.Thread | None = None
|
||||||
|
self._stop_event = threading.Event()
|
||||||
|
|
||||||
|
# 周度淘汰历史:key=股票代码,value=[(week_id, rank), ...]
|
||||||
|
# rank=0 表示在 Top50,rank=-1 表示不在 Top50
|
||||||
|
self._top50_history: dict[str, list[tuple[int, int]]] = defaultdict(list)
|
||||||
|
|
||||||
|
# 调试:手动触发时传入自定义日期(仅供测试用)
|
||||||
|
self._override_date: date | None = None
|
||||||
|
|
||||||
|
# 加载历史排名数据(从 ScoringResult 重建)
|
||||||
|
self._rebuild_top50_history()
|
||||||
|
|
||||||
|
# ---- 对外控制接口 ----
|
||||||
|
|
||||||
|
def start(self):
|
||||||
|
"""启动后台管理线程(幂等)"""
|
||||||
|
if self._thread is not None and self._thread.is_alive():
|
||||||
|
PrintLog(LogLevel.WARNING, '[PoolManager] 已启动,忽略重复调用')
|
||||||
|
return
|
||||||
|
self._stop_event.clear()
|
||||||
|
self._thread = threading.Thread(target=self._run_loop, daemon=True, name='PoolManager')
|
||||||
|
self._thread.start()
|
||||||
|
PrintLog(LogLevel.INFO, '[PoolManager] 已启动')
|
||||||
|
|
||||||
|
def stop(self):
|
||||||
|
"""停止后台管理线程"""
|
||||||
|
self._stop_event.set()
|
||||||
|
if self._thread is not None:
|
||||||
|
self._thread.join(timeout=5)
|
||||||
|
self._thread = None
|
||||||
|
PrintLog(LogLevel.INFO, '[PoolManager] 已停止')
|
||||||
|
|
||||||
|
# ---- 每日定时任务 ----
|
||||||
|
|
||||||
|
def _run_loop(self):
|
||||||
|
"""后台线程主循环:计算出距下次任务的时间,注册下一个 Timer"""
|
||||||
|
while not self._stop_event.is_set():
|
||||||
|
now = datetime.now()
|
||||||
|
td = self._override_date or now.date()
|
||||||
|
|
||||||
|
# 确定当天要执行的任务及距其的秒数
|
||||||
|
delay, task_name = self._compute_next_delay(now, td)
|
||||||
|
|
||||||
|
# 保证 Timer 不会太久(最多 24 小时),处理节假日顺延
|
||||||
|
if delay <= 0:
|
||||||
|
delay = 60 # 异常时等 1 分钟重算
|
||||||
|
|
||||||
|
PrintLog(LogLevel.INFO,
|
||||||
|
f'[PoolManager] 计划任务 "{task_name}",{delay:.0f} 秒后执行')
|
||||||
|
|
||||||
|
timer = threading.Timer(delay, self._execute_task, args=(task_name, td))
|
||||||
|
timer.name = f'PoolManager-{task_name}'
|
||||||
|
timer.start()
|
||||||
|
|
||||||
|
# 等待定时器完成或停止信号
|
||||||
|
timer.join()
|
||||||
|
|
||||||
|
if self._stop_event.is_set():
|
||||||
|
break
|
||||||
|
|
||||||
|
def _compute_next_delay(self, now: datetime, td: date) -> tuple[float, str]:
|
||||||
|
"""
|
||||||
|
计算距下一个任务的时间和任务名称。
|
||||||
|
任务顺序:09:25 → 09:30 → 15:30 → (下一天 09:25)
|
||||||
|
"""
|
||||||
|
h, m = now.hour, now.minute
|
||||||
|
|
||||||
|
if h < 9 or (h == 9 and m < 25):
|
||||||
|
# 现在在 09:25 之前 → 先执行 09:25
|
||||||
|
return seconds_to_target(9, 25), '_sync_data'
|
||||||
|
elif h == 9 and 25 <= m < 30:
|
||||||
|
# 09:25~09:30 之间 → 立即执行 09:30
|
||||||
|
return 0.0, '_run_scoring'
|
||||||
|
elif (h == 9 and m >= 30) or h < 15:
|
||||||
|
# 09:30 之后、15:30 之前 → 执行 15:30
|
||||||
|
return seconds_to_target(15, 30), '_mark_slumber'
|
||||||
|
elif h >= 15:
|
||||||
|
# 15:30 之后 → 推到下一天 09:25
|
||||||
|
delay = seconds_to_target(9, 25) + (td.weekday() < 4 and 1 or 3) * 86400 # 工作日+1,周末+3
|
||||||
|
return delay, '_sync_data'
|
||||||
|
|
||||||
|
# 默认兜底
|
||||||
|
return seconds_to_target(9, 25), '_sync_data'
|
||||||
|
|
||||||
|
def _execute_task(self, task_name: str, td: date):
|
||||||
|
"""根据任务名执行对应任务"""
|
||||||
|
try:
|
||||||
|
if task_name == '_sync_data':
|
||||||
|
self._sync_data()
|
||||||
|
# 同步完成后自动调度评分
|
||||||
|
self._run_scoring()
|
||||||
|
# 调度 15:30
|
||||||
|
self._schedule_next(target_hour=15, target_minute=30,
|
||||||
|
task_name='_mark_slumber', td=td)
|
||||||
|
|
||||||
|
elif task_name == '_run_scoring':
|
||||||
|
self._run_scoring()
|
||||||
|
# 评分完成后调度沉寂检测
|
||||||
|
self._schedule_next(target_hour=15, target_minute=30,
|
||||||
|
task_name='_mark_slumber', td=td)
|
||||||
|
|
||||||
|
elif task_name == '_mark_slumber':
|
||||||
|
self._mark_slumber(td)
|
||||||
|
# 如果是周五,额外调度淘汰标记
|
||||||
|
if td.weekday() == 4: # 周五
|
||||||
|
self._mark_weekly_elim(td)
|
||||||
|
# 调度下一天 09:25
|
||||||
|
self._schedule_next(target_hour=9, target_minute=25,
|
||||||
|
task_name='_sync_data', td=td)
|
||||||
|
except Exception as e:
|
||||||
|
PrintLog(LogLevel.ERROR, f'[PoolManager] 任务 {task_name} 执行异常: {e}')
|
||||||
|
|
||||||
|
def _schedule_next(self, target_hour: int, target_minute: int,
|
||||||
|
task_name: str, td: date):
|
||||||
|
"""注册一个 Timer,在指定时间执行 task_name"""
|
||||||
|
# 计算 delay
|
||||||
|
delay = seconds_to_target(target_hour, target_minute)
|
||||||
|
# 如果 target 在过去(如周末顺延),加 1 天
|
||||||
|
if delay <= 0:
|
||||||
|
delay += 86400
|
||||||
|
|
||||||
|
def wrapper():
|
||||||
|
now = datetime.now()
|
||||||
|
# 重新计算真实日期
|
||||||
|
exec_td = self._override_date or now.date()
|
||||||
|
self._execute_task(task_name, exec_td)
|
||||||
|
|
||||||
|
timer = threading.Timer(delay, wrapper, name=f'PoolManager-sched-{task_name}')
|
||||||
|
timer.start()
|
||||||
|
|
||||||
|
# ---- 任务实现 ----
|
||||||
|
|
||||||
|
def _sync_data(self):
|
||||||
|
"""09:25 — 同步 K 线数据"""
|
||||||
|
PrintLog(LogLevel.INFO, '[PoolManager] 开始同步K线数据...')
|
||||||
|
try:
|
||||||
|
from core.scoring.sync.kline_sync import KlineStockSync
|
||||||
|
syncer = KlineStockSync()
|
||||||
|
syncer.run()
|
||||||
|
PrintLog(LogLevel.INFO, '[PoolManager] K线数据同步完成')
|
||||||
|
except Exception as e:
|
||||||
|
PrintLog(LogLevel.ERROR, f'[PoolManager] K线同步失败: {e}')
|
||||||
|
|
||||||
|
def _run_scoring(self):
|
||||||
|
"""09:30 — 执行评分"""
|
||||||
|
PrintLog(LogLevel.INFO, '[PoolManager] 开始执行评分...')
|
||||||
|
try:
|
||||||
|
from core.scoring.inference.scorer import GridSeekerPipeline
|
||||||
|
pipeline = GridSeekerPipeline()
|
||||||
|
td = self._override_date or date.today()
|
||||||
|
result = pipeline.run(trade_date=td)
|
||||||
|
if not result.empty:
|
||||||
|
pipeline.persist(result, trade_date=td)
|
||||||
|
PrintLog(LogLevel.INFO, f'[PoolManager] 评分完成,写入 {len(result)} 条结果')
|
||||||
|
# 触发 UI 刷新
|
||||||
|
event_bus.publish(EventPoolMark, {
|
||||||
|
'action': 'scoring',
|
||||||
|
'stock_codes': [],
|
||||||
|
'count': len(result),
|
||||||
|
})
|
||||||
|
else:
|
||||||
|
PrintLog(LogLevel.WARNING, '[PoolManager] 评分无结果')
|
||||||
|
except Exception as e:
|
||||||
|
PrintLog(LogLevel.ERROR, f'[PoolManager] 评分失败: {e}')
|
||||||
|
|
||||||
|
def _mark_slumber(self, td: date):
|
||||||
|
"""15:30 — 沉寂检测,标记待卖出股"""
|
||||||
|
if not is_trading_day(td):
|
||||||
|
PrintLog(LogLevel.INFO, '[PoolManager] 非交易日,跳过沉寂检测')
|
||||||
|
return
|
||||||
|
|
||||||
|
PrintLog(LogLevel.INFO, '[PoolManager] 开始沉寂检测...')
|
||||||
|
|
||||||
|
# 获取所有 enabled=True 的持仓
|
||||||
|
targets = SFGridTradeTarget.select().where(SFGridTradeTarget.enabled == True)
|
||||||
|
if not targets:
|
||||||
|
PrintLog(LogLevel.INFO, '[PoolManager] 无持仓,跳过沉寂检测')
|
||||||
|
return
|
||||||
|
|
||||||
|
marked: list[str] = []
|
||||||
|
today_str = td.strftime('%Y-%m-%d')
|
||||||
|
|
||||||
|
for tgt in targets:
|
||||||
|
try:
|
||||||
|
# 读取该股 K 线数据(从本地 SQLite KlineStock 表)
|
||||||
|
from core.scoring.models import KlineStock
|
||||||
|
klines = list(KlineStock
|
||||||
|
.select()
|
||||||
|
.where(KlineStock.stock_code == tgt.stock_code)
|
||||||
|
.order_by(KlineStock.trade_date)
|
||||||
|
.dicts())
|
||||||
|
|
||||||
|
if not klines or len(klines) < 60:
|
||||||
|
continue
|
||||||
|
|
||||||
|
df = pd.DataFrame(klines)
|
||||||
|
if 'trade_date' in df.columns and not pd.api.types.is_datetime64_any_dtype(df['trade_date']):
|
||||||
|
df['trade_date'] = pd.to_datetime(df['trade_date'])
|
||||||
|
df = df.set_index('trade_date').sort_index()
|
||||||
|
|
||||||
|
if is_slumbering(df):
|
||||||
|
self._write_pending(td, 'liquidate', tgt.stock_code,
|
||||||
|
reason=f'沉寂检测触发({today_str})')
|
||||||
|
marked.append(tgt.stock_code)
|
||||||
|
PrintLog(LogLevel.INFO,
|
||||||
|
f'[PoolManager] 沉寂标记: {tgt.stock_code}')
|
||||||
|
except Exception as e:
|
||||||
|
PrintLog(LogLevel.WARNING,
|
||||||
|
f'[PoolManager] 沉寂检测异常 {tgt.stock_code}: {e}')
|
||||||
|
|
||||||
|
if marked:
|
||||||
|
event_bus.publish(EventPoolMark, {
|
||||||
|
'action': 'liquidate',
|
||||||
|
'stock_codes': marked,
|
||||||
|
'count': len(marked),
|
||||||
|
})
|
||||||
|
PrintLog(LogLevel.INFO,
|
||||||
|
f'[PoolManager] 沉寂检测完成,标记 {len(marked)} 只股')
|
||||||
|
else:
|
||||||
|
PrintLog(LogLevel.INFO, '[PoolManager] 沉寂检测完成,无股触发')
|
||||||
|
|
||||||
|
def _mark_weekly_elim(self, td: date):
|
||||||
|
"""周五 15:30 — 周度评分淘汰标记(连续2周不在Top50则卖出)"""
|
||||||
|
if not is_trading_day(td):
|
||||||
|
return
|
||||||
|
|
||||||
|
PrintLog(LogLevel.INFO, '[PoolManager] 开始周度淘汰检测...')
|
||||||
|
|
||||||
|
# 获取最近 N 周的评分排名
|
||||||
|
week_id = get_week_id(td)
|
||||||
|
today_str = td.strftime('%Y-%m-%d')
|
||||||
|
|
||||||
|
# 查今日 Top50
|
||||||
|
top50_codes: Set[str] = set()
|
||||||
|
rows = (ScoringResult
|
||||||
|
.select(ScoringResult.stock_code)
|
||||||
|
.where(ScoringResult.trade_date == td)
|
||||||
|
.where(ScoringResult.score_rank <= TOP_MODEL_N)
|
||||||
|
.dicts())
|
||||||
|
for r in rows:
|
||||||
|
top50_codes.add(r['stock_code'])
|
||||||
|
|
||||||
|
if not top50_codes:
|
||||||
|
PrintLog(LogLevel.WARNING, '[PoolManager] 今日无评分数据,无法进行淘汰检测')
|
||||||
|
return
|
||||||
|
|
||||||
|
# 更新历史排名
|
||||||
|
targets = SFGridTradeTarget.select().where(SFGridTradeTarget.enabled == True)
|
||||||
|
for tgt in targets:
|
||||||
|
code = tgt.stock_code
|
||||||
|
rank_in_top50 = 0 if code in top50_codes else -1
|
||||||
|
self._top50_history[code].append((week_id, rank_in_top50))
|
||||||
|
# 只保留近 8 周记录,防止内存膨胀
|
||||||
|
if len(self._top50_history[code]) > 8:
|
||||||
|
self._top50_history[code] = self._top50_history[code][-8:]
|
||||||
|
|
||||||
|
# 检测连续 N 周不在 Top50 的股
|
||||||
|
marked: list[str] = []
|
||||||
|
for tgt in targets:
|
||||||
|
code = tgt.stock_code
|
||||||
|
hist = self._top50_history.get(code, [])
|
||||||
|
if len(hist) < ELIM_WINDOW:
|
||||||
|
continue
|
||||||
|
recent = hist[-ELIM_WINDOW:]
|
||||||
|
if all(rank == -1 for _, rank in recent):
|
||||||
|
self._write_pending(td, 'eliminate', code,
|
||||||
|
reason=f'连续{ELIM_WINDOW}周不在Top50({today_str})')
|
||||||
|
marked.append(code)
|
||||||
|
PrintLog(LogLevel.INFO,
|
||||||
|
f'[PoolManager] 淘汰标记: {code},历史: {recent}')
|
||||||
|
|
||||||
|
if marked:
|
||||||
|
event_bus.publish(EventPoolMark, {
|
||||||
|
'action': 'eliminate',
|
||||||
|
'stock_codes': marked,
|
||||||
|
'count': len(marked),
|
||||||
|
})
|
||||||
|
PrintLog(LogLevel.INFO,
|
||||||
|
f'[PoolManager] 周度淘汰检测完成,标记 {len(marked)} 只股')
|
||||||
|
else:
|
||||||
|
PrintLog(LogLevel.INFO, '[PoolManager] 周度淘汰检测完成,无股触发')
|
||||||
|
|
||||||
|
def _write_pending(self, td: date, action_type: str, stock_code: str, reason: str = ''):
|
||||||
|
"""写入 pending_pool_actions 表(幂等)"""
|
||||||
|
try:
|
||||||
|
PendingPoolAction.insert(
|
||||||
|
action_date=td,
|
||||||
|
action_type=action_type,
|
||||||
|
stock_code=stock_code,
|
||||||
|
reason=reason,
|
||||||
|
).on_conflict_replace().execute()
|
||||||
|
except Exception as e:
|
||||||
|
PrintLog(LogLevel.WARNING,
|
||||||
|
f'[PoolManager] 写入待处理操作失败: {e}')
|
||||||
|
|
||||||
|
def _rebuild_top50_history(self):
|
||||||
|
"""启动时从 ScoringResult 表重建 _top50_history(用于淘汰判断)"""
|
||||||
|
try:
|
||||||
|
rows = (ScoringResult
|
||||||
|
.select(ScoringResult.stock_code, ScoringResult.trade_date,
|
||||||
|
ScoringResult.score_rank)
|
||||||
|
.where(ScoringResult.score_rank <= TOP_MODEL_N)
|
||||||
|
.order_by(ScoringResult.trade_date)
|
||||||
|
.dicts())
|
||||||
|
|
||||||
|
week_groups: dict = defaultdict(list)
|
||||||
|
for r in rows:
|
||||||
|
td = r['trade_date']
|
||||||
|
if isinstance(td, str):
|
||||||
|
td = datetime.strptime(td, '%Y-%m-%d').date()
|
||||||
|
week_id = get_week_id(td)
|
||||||
|
week_groups[(r['stock_code'], week_id)].append(r['score_rank'])
|
||||||
|
|
||||||
|
# 取每周最新一条(排名最靠前的)
|
||||||
|
for (code, week_id), ranks in week_groups.items():
|
||||||
|
best_rank = min(ranks)
|
||||||
|
rank_val = 0 if best_rank <= TOP_MODEL_N else -1
|
||||||
|
self._top50_history[code].append((week_id, rank_val))
|
||||||
|
|
||||||
|
# 去重,每 week_id 只留一条
|
||||||
|
for code in self._top50_history:
|
||||||
|
seen = set()
|
||||||
|
cleaned = []
|
||||||
|
for w, r in self._top50_history[code]:
|
||||||
|
if w not in seen:
|
||||||
|
seen.add(w)
|
||||||
|
cleaned.append((w, r))
|
||||||
|
self._top50_history[code] = cleaned
|
||||||
|
|
||||||
|
PrintLog(LogLevel.INFO,
|
||||||
|
f'[PoolManager] 历史排名已重建,{len(self._top50_history)} 只股有历史数据')
|
||||||
|
except Exception as e:
|
||||||
|
PrintLog(LogLevel.ERROR, f'[PoolManager] 重建历史排名失败: {e}')
|
||||||
|
|
||||||
|
# ---- 手动触发(供 UI 调试按钮调用)----
|
||||||
|
|
||||||
|
def trigger_sync(self):
|
||||||
|
"""手动触发数据同步"""
|
||||||
|
threading.Thread(target=self._sync_data, daemon=True).start()
|
||||||
|
|
||||||
|
def trigger_scoring(self):
|
||||||
|
"""手动触发评分"""
|
||||||
|
threading.Thread(target=self._run_scoring, daemon=True).start()
|
||||||
|
|
||||||
|
def trigger_slumber(self, td: date | None = None):
|
||||||
|
"""手动触发沉寂检测"""
|
||||||
|
td = td or (self._override_date or date.today())
|
||||||
|
threading.Thread(target=self._mark_slumber, args=(td,), daemon=True).start()
|
||||||
|
|
||||||
|
def trigger_weekly_elim(self, td: date | None = None):
|
||||||
|
"""手动触发周度淘汰检测"""
|
||||||
|
td = td or (self._override_date or date.today())
|
||||||
|
threading.Thread(target=self._mark_weekly_elim, args=(td,), daemon=True).start()
|
||||||
@@ -54,10 +54,17 @@ class SFGridStrategy:
|
|||||||
"""
|
"""
|
||||||
self.tradeTarget: model.SFGridTradeTarget = tradeTarget
|
self.tradeTarget: model.SFGridTradeTarget = tradeTarget
|
||||||
|
|
||||||
|
# orderGrid 必须在所有可能触发回调的操作之前初始化
|
||||||
|
# orderGrid: 网格索引 → 订单编号(seq 或 order_id)的映射
|
||||||
|
# seq 是 xtquant 返回的下单序号(下单瞬间),order_id 是交易所返回的正式订单号(异步回调后更新)
|
||||||
|
self.orderGrid = {} # {grid_index: order_seq | order_id}
|
||||||
|
|
||||||
# 数据更新锁:保护 orderGrid 和 tradeTarget 的并发访问
|
# 数据更新锁:保护 orderGrid 和 tradeTarget 的并发访问
|
||||||
# QMT 回调在独立线程中触发,必须在可能触发回调的操作之前创建
|
# QMT 回调在独立线程中触发,必须在可能触发回调的操作之前创建
|
||||||
# 注意:这个锁必须在订阅事件之前创建,防止事件在初始化期间触发
|
# 注意:这个锁必须在订阅事件之前创建,防止事件在初始化期间触发
|
||||||
self.dataUpdateLock = threading.Lock()
|
# 注意:必须使用 RLock 而非 Lock,因为 refreshGridOrder 在持有此锁时也会被调用
|
||||||
|
#(如 onOrderTrade 回调中),Lock 会导致同一线程重复获取时永久阻塞(死锁)
|
||||||
|
self.dataUpdateLock = threading.RLock()
|
||||||
|
|
||||||
# 订阅事件总线:监听订单创建、成交、失败三种事件
|
# 订阅事件总线:监听订单创建、成交、失败三种事件
|
||||||
event_bus.subscribe(eBus.MarketOrderCreated, self.onOrderCreateAsync)
|
event_bus.subscribe(eBus.MarketOrderCreated, self.onOrderCreateAsync)
|
||||||
@@ -73,10 +80,6 @@ class SFGridStrategy:
|
|||||||
f'|- [DEBUG] 标的{tradeTarget.targetName()} 构造开始: '
|
f'|- [DEBUG] 标的{tradeTarget.targetName()} 构造开始: '
|
||||||
f'网格={tradeTarget.grid_index}, 启用={tradeTarget.enabled}')
|
f'网格={tradeTarget.grid_index}, 启用={tradeTarget.enabled}')
|
||||||
|
|
||||||
# orderGrid: 网格索引 → 订单编号(seq 或 order_id)的映射
|
|
||||||
# seq 是 xtquant 返回的下单序号(下单瞬间),order_id 是交易所返回的正式订单号(异步回调后更新)
|
|
||||||
self.orderGrid = {} # {grid_index: order_seq | order_id}
|
|
||||||
|
|
||||||
# 加载券商侧已存在的未成交订单,恢复到 orderGrid 中
|
# 加载券商侧已存在的未成交订单,恢复到 orderGrid 中
|
||||||
self.loadExistOrders()
|
self.loadExistOrders()
|
||||||
|
|
||||||
@@ -171,8 +174,18 @@ class SFGridStrategy:
|
|||||||
sell_remark = self._make_remark(OrderTypeSell, sellIdx)
|
sell_remark = self._make_remark(OrderTypeSell, sellIdx)
|
||||||
|
|
||||||
# 检查是否已存在同 remark 的卖单(避免重复挂单)
|
# 检查是否已存在同 remark 的卖单(避免重复挂单)
|
||||||
if not any(o.order_remark == sell_remark for o in orders):
|
# 注意:必须同时查 QMT 订单簿和本地 orderGrid
|
||||||
|
# - QMT 订单簿:已确认的订单(onOrderCreateAsync 之后)
|
||||||
|
# - orderGrid:本地下单后、回调前的新单(orderAsync 返回后直接写入)
|
||||||
|
# 两者并集才能完整覆盖所有已存在订单,防止 onOrderCreateAsync 回调
|
||||||
|
# 之前再次触发 refreshGridOrder 导致重复下单
|
||||||
|
qmt_has_order = any(o.order_remark == sell_remark for o in orders)
|
||||||
|
local_has_order = sellIdx in self.orderGrid
|
||||||
|
if not qmt_has_order and not local_has_order:
|
||||||
# 卖单价格超过涨停价 → 今日无法成交,跳过下单
|
# 卖单价格超过涨停价 → 今日无法成交,跳过下单
|
||||||
|
# 防御性检查:若属性未初始化(初始化顺序导致),先获取
|
||||||
|
if not hasattr(self, 'todayUpStopPrice') or self.todayUpStopPrice is None:
|
||||||
|
self.todayUpStopPrice = qmtv.dailyUpStop(self.tradeTarget.stock_code) # type: ignore
|
||||||
if sellPrice > self.todayUpStopPrice:
|
if sellPrice > self.todayUpStopPrice:
|
||||||
PrintLog(LogLevel.INFO,
|
PrintLog(LogLevel.INFO,
|
||||||
f'|- 标的[{self.tradeTarget.targetName()}] '
|
f'|- 标的[{self.tradeTarget.targetName()}] '
|
||||||
@@ -205,8 +218,14 @@ class SFGridStrategy:
|
|||||||
buy_remark = self._make_remark(OrderTypeBuy, buyIdx)
|
buy_remark = self._make_remark(OrderTypeBuy, buyIdx)
|
||||||
|
|
||||||
# 检查是否已存在同 remark 的买单(避免重复挂单)
|
# 检查是否已存在同 remark 的买单(避免重复挂单)
|
||||||
if not any(o.order_remark == buy_remark for o in orders):
|
# 必须同时查 QMT 订单簿和本地 orderGrid(见上方卖单注释)
|
||||||
|
qmt_has_order = any(o.order_remark == buy_remark for o in orders)
|
||||||
|
local_has_order = buyIdx in self.orderGrid
|
||||||
|
if not qmt_has_order and not local_has_order:
|
||||||
# 买单价格低于跌停价 → 今日无法成交,跳过下单
|
# 买单价格低于跌停价 → 今日无法成交,跳过下单
|
||||||
|
# 防御性检查:若属性未初始化(初始化顺序导致),先获取
|
||||||
|
if not hasattr(self, 'todayDownStopPrice') or self.todayDownStopPrice is None:
|
||||||
|
self.todayDownStopPrice = qmtv.dailyDownStop(self.tradeTarget.stock_code) # type: ignore
|
||||||
if buyPrice < self.todayDownStopPrice:
|
if buyPrice < self.todayDownStopPrice:
|
||||||
PrintLog(LogLevel.INFO,
|
PrintLog(LogLevel.INFO,
|
||||||
f'|- 标的[{self.tradeTarget.targetName()}] '
|
f'|- 标的[{self.tradeTarget.targetName()}] '
|
||||||
@@ -428,6 +447,17 @@ class SFGridStrategy:
|
|||||||
if self.tradeTarget.grid_index == 0:
|
if self.tradeTarget.grid_index == 0:
|
||||||
self.tradeTarget.init_price = trade.traded_price # type: ignore
|
self.tradeTarget.init_price = trade.traded_price # type: ignore
|
||||||
|
|
||||||
|
# ── 同步更新持仓量 ──
|
||||||
|
# 注意:xtquant 的成交推送不包含最新持仓,此处根据成交方向估算变动
|
||||||
|
# 买入成交(建仓/补仓)→ 持仓增加
|
||||||
|
# 卖出成交(减仓/清仓)→ 持仓减少
|
||||||
|
if gridIdx > self.tradeTarget.grid_index:
|
||||||
|
# 买入方向:持仓增加
|
||||||
|
self.tradeTarget.current_position += int(trade.traded_volume) # type: ignore
|
||||||
|
elif gridIdx < self.tradeTarget.grid_index:
|
||||||
|
# 卖出方向:持仓减少
|
||||||
|
self.tradeTarget.current_position -= int(trade.traded_volume) # type: ignore
|
||||||
|
|
||||||
# ── 网格方向判断 ──
|
# ── 网格方向判断 ──
|
||||||
# 比较成交单的网格索引 vs 当前网格索引,判断价格移动方向
|
# 比较成交单的网格索引 vs 当前网格索引,判断价格移动方向
|
||||||
oriIdx = self.tradeTarget.grid_index # 成交前的网格位置
|
oriIdx = self.tradeTarget.grid_index # 成交前的网格位置
|
||||||
|
|||||||
@@ -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)
|
|
||||||
+392
-38
@@ -17,19 +17,21 @@ from core.logger import LogLevel, PrintLog
|
|||||||
from core.sfgrid.model import SFGridTradeTarget, STRATEGY_TYPE_GRID
|
from core.sfgrid.model import SFGridTradeTarget, STRATEGY_TYPE_GRID
|
||||||
from core.sfgrid.sfgrid_strategy import SFGridStrategy
|
from core.sfgrid.sfgrid_strategy import SFGridStrategy
|
||||||
from core.eventbus import event_bus, MarketDataUpdate, EventMarketActiveSwitch
|
from core.eventbus import event_bus, MarketDataUpdate, EventMarketActiveSwitch
|
||||||
from core.sfgrid.bus_events import EventTradeTargetUpdate
|
from core.sfgrid.bus_events import EventTradeTargetUpdate, EventPoolMark
|
||||||
|
|
||||||
# ── 工具函数 ──
|
# ── 工具函数 ──
|
||||||
_ORDER_STATUS = {48: '未报', 49: '待报', 50: '已报', 51: '已报待撤', 52: '部成待撤',
|
_ORDER_STATUS = {48: '未报', 49: '待报', 50: '已报', 51: '已报待撤', 52: '部成待撤',
|
||||||
53: '部撤', 54: '已撤', 55: '部成', 56: '已成', 57: '废单'}
|
53: '部撤', 54: '已撤', 55: '部成', 56: '已成', 57: '废单'}
|
||||||
|
|
||||||
|
from datetime import date as _date
|
||||||
|
|
||||||
def _fmt_time(t) -> str:
|
def _fmt_time(t) -> str:
|
||||||
if not t: return ''
|
if not t: return ''
|
||||||
import datetime
|
from datetime import datetime as _dt
|
||||||
try:
|
try:
|
||||||
ts = int(t)
|
ts = int(t)
|
||||||
if ts > 1e12: ts //= 1000
|
if ts > 1e12: ts //= 1000
|
||||||
return datetime.datetime.fromtimestamp(ts).strftime('%H:%M:%S')
|
return _dt.fromtimestamp(ts).strftime('%H:%M:%S')
|
||||||
except (ValueError, OSError):
|
except (ValueError, OSError):
|
||||||
return str(t)
|
return str(t)
|
||||||
|
|
||||||
@@ -78,6 +80,7 @@ class _DataStore:
|
|||||||
self.monitorPrice: float = 10.0
|
self.monitorPrice: float = 10.0
|
||||||
self.marketLog: dict[str, dict] = {} # stock_code → {name, price, time}
|
self.marketLog: dict[str, dict] = {} # stock_code → {name, price, time}
|
||||||
self.listeningStocks: list = []
|
self.listeningStocks: list = []
|
||||||
|
self.poolLog: list[str] = [] # 股票池管理器日志(list,方便 append)
|
||||||
|
|
||||||
# ── 初始化 ──
|
# ── 初始化 ──
|
||||||
|
|
||||||
@@ -301,6 +304,22 @@ class _DataStore:
|
|||||||
elif ot == 24 and '空' not in tags: tags.append('空')
|
elif ot == 24 and '空' not in tags: tags.append('空')
|
||||||
return tags
|
return tags
|
||||||
|
|
||||||
|
def pool_action_tags(self, stock_code: str) -> list[str]:
|
||||||
|
"""某标的的股票池待执行标记(eliminate/liquidate)"""
|
||||||
|
plain = _plain(stock_code)
|
||||||
|
from core.scoring.models import PendingPoolAction
|
||||||
|
rows = list(PendingPoolAction
|
||||||
|
.select(PendingPoolAction.action_type)
|
||||||
|
.where(PendingPoolAction.stock_code == plain)
|
||||||
|
.dicts())
|
||||||
|
tags = []
|
||||||
|
for r in rows:
|
||||||
|
if r['action_type'] == 'eliminate' and '淘汰' not in tags:
|
||||||
|
tags.append('淘汰')
|
||||||
|
elif r['action_type'] == 'liquidate' and '沉寂' not in tags:
|
||||||
|
tags.append('沉寂')
|
||||||
|
return tags
|
||||||
|
|
||||||
|
|
||||||
# ══════════════════════════════════════════════════════════════
|
# ══════════════════════════════════════════════════════════════
|
||||||
# GridPanel — 网格策略持仓表格
|
# GridPanel — 网格策略持仓表格
|
||||||
@@ -324,9 +343,9 @@ class _GridPanel:
|
|||||||
return self._col
|
return self._col
|
||||||
|
|
||||||
def _rebuild(self):
|
def _rebuild(self):
|
||||||
# (width, expand): 0=固定宽, >0=弹性比重
|
# (width, expand): 0=固定宽, >0=弹性比重; 新增排名列(50px)
|
||||||
_C = [(35, 0), (0, 2), (70, 1), (0, 2), (50, 1), (55, 1), (60, 1), (0, 1)]
|
_C = [(35, 0), (0, 2), (70, 1), (80, 1), (50, 1), (55, 1), (50, 0), (60, 1), (0, 1)]
|
||||||
H = ["ID", "股票", "市场价", "网格基准", "持仓", "成本", "状态", "操作"]
|
H = ["ID", "股票", "市场价", "网格基准", "持仓", "成本", "排名", "状态", "操作"]
|
||||||
header = []
|
header = []
|
||||||
for h, (w, e) in zip(H, _C):
|
for h, (w, e) in zip(H, _C):
|
||||||
if e > 0:
|
if e > 0:
|
||||||
@@ -335,6 +354,22 @@ class _GridPanel:
|
|||||||
header.append(ft.Container(_text(h, bold=True), width=w, padding=4))
|
header.append(ft.Container(_text(h, bold=True), width=w, padding=4))
|
||||||
rows = [ft.Row(header, spacing=0), ft.Divider(height=1, color='#e0e0e0')]
|
rows = [ft.Row(header, spacing=0), ft.Divider(height=1, color='#e0e0e0')]
|
||||||
|
|
||||||
|
# 获取最新评分排名
|
||||||
|
rank_map = {} # stock_code -> score_rank
|
||||||
|
try:
|
||||||
|
from core.scoring.models import ScoringResult
|
||||||
|
from peewee import fn
|
||||||
|
latest_date = ScoringResult.select(fn.MAX(ScoringResult.trade_date)).scalar()
|
||||||
|
if latest_date:
|
||||||
|
scored = (ScoringResult
|
||||||
|
.select(ScoringResult.stock_code, ScoringResult.score_rank)
|
||||||
|
.where(ScoringResult.trade_date == latest_date)
|
||||||
|
.dicts())
|
||||||
|
for r in scored:
|
||||||
|
rank_map[r['stock_code']] = r['score_rank']
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
for tid, t in self._data.tradeTargets.items():
|
for tid, t in self._data.tradeTargets.items():
|
||||||
if t.strategy_type != STRATEGY_TYPE_GRID: continue
|
if t.strategy_type != STRATEGY_TYPE_GRID: continue
|
||||||
pg = t.getPriceGrid()
|
pg = t.getPriceGrid()
|
||||||
@@ -358,6 +393,22 @@ class _GridPanel:
|
|||||||
))
|
))
|
||||||
gcell = ft.Row(gcells, spacing=3) if len(gcells) > 1 else gcells[0]
|
gcell = ft.Row(gcells, spacing=3) if len(gcells) > 1 else gcells[0]
|
||||||
|
|
||||||
|
# 排名列
|
||||||
|
plain = t.stock_code.split('.')[0] if '.' in t.stock_code else t.stock_code
|
||||||
|
rank = rank_map.get(plain, None)
|
||||||
|
if rank is not None and rank <= 50:
|
||||||
|
rank_str = f'#{rank}'
|
||||||
|
rank_color = '#4CAF50'
|
||||||
|
elif rank is not None and rank <= 100:
|
||||||
|
rank_str = f'#{rank}'
|
||||||
|
rank_color = '#2196F3'
|
||||||
|
elif rank is not None:
|
||||||
|
rank_str = f'#{rank}'
|
||||||
|
rank_color = '#888888'
|
||||||
|
else:
|
||||||
|
rank_str = '—'
|
||||||
|
rank_color = '#888888'
|
||||||
|
|
||||||
# 操作按钮
|
# 操作按钮
|
||||||
_ICON = 22 # 图标大小,比默认 18 好按
|
_ICON = 22 # 图标大小,比默认 18 好按
|
||||||
if t.enabled:
|
if t.enabled:
|
||||||
@@ -381,10 +432,30 @@ class _GridPanel:
|
|||||||
on_click=lambda e, tt=t: self._dialogs.confirm_remove(tt)),
|
on_click=lambda e, tt=t: self._dialogs.confirm_remove(tt)),
|
||||||
]
|
]
|
||||||
|
|
||||||
cells_text = [str(tid), f'{t.stock_code} {t.stock_name}',
|
# 股票列:名称 + 标记标签
|
||||||
|
pool_tags = self._data.pool_action_tags(t.stock_code)
|
||||||
|
tag_chips = []
|
||||||
|
if pool_tags:
|
||||||
|
# 有淘汰/沉寂时,显示操作标签,不显示默认标签
|
||||||
|
for tag in pool_tags:
|
||||||
|
color = '#E67E22' if tag == '淘汰' else '#E74C3C'
|
||||||
|
tag_chips.append(ft.Container(
|
||||||
|
_text(tag, color='white', bold=True, size=10),
|
||||||
|
bgcolor=color, border_radius=4, padding=ft.Padding(2, 1, 2, 1),
|
||||||
|
))
|
||||||
|
else:
|
||||||
|
# 无操作标签时,显示默认"网格"标签
|
||||||
|
tag_chips.append(ft.Container(
|
||||||
|
_text('网格', color='white', bold=True, size=10),
|
||||||
|
bgcolor='#2E7D32', border_radius=4, padding=ft.Padding(2, 1, 2, 1),
|
||||||
|
))
|
||||||
|
name_cell = ft.Row([_text(f'{t.stock_code} {t.stock_name}')] + tag_chips, spacing=4)
|
||||||
|
|
||||||
|
cells_text = [str(tid), name_cell,
|
||||||
f'{mp:.3f}', gcell,
|
f'{mp:.3f}', gcell,
|
||||||
str(t.current_position),
|
str(t.current_position),
|
||||||
f'{self._data.avgPrices.get(tid, 0):.3f}',
|
f'{self._data.avgPrices.get(tid, 0):.3f}',
|
||||||
|
rank_str,
|
||||||
'▶运行中' if t.enabled else '⏸已暂停',
|
'▶运行中' if t.enabled else '⏸已暂停',
|
||||||
btns]
|
btns]
|
||||||
row_cells = []
|
row_cells = []
|
||||||
@@ -392,6 +463,8 @@ class _GridPanel:
|
|||||||
content = cells_text[i]
|
content = cells_text[i]
|
||||||
if i == 2: # 市场价用颜色
|
if i == 2: # 市场价用颜色
|
||||||
content = _text(cells_text[i], color=pcolor, bold=True)
|
content = _text(cells_text[i], color=pcolor, bold=True)
|
||||||
|
elif i == 6: # 排名用颜色
|
||||||
|
content = _text(cells_text[i], color=rank_color, bold=True)
|
||||||
elif isinstance(content, str):
|
elif isinstance(content, str):
|
||||||
content = _text(content)
|
content = _text(content)
|
||||||
elif isinstance(content, list):
|
elif isinstance(content, list):
|
||||||
@@ -439,6 +512,7 @@ class _DrawerPanel:
|
|||||||
self._dataset_table = ft.ListView(expand=True)
|
self._dataset_table = ft.ListView(expand=True)
|
||||||
self._dataset_col = ft.Column(scroll=ft.ScrollMode.AUTO, expand=True)
|
self._dataset_col = ft.Column(scroll=ft.ScrollMode.AUTO, expand=True)
|
||||||
self._scoring_table = ft.ListView(expand=True)
|
self._scoring_table = ft.ListView(expand=True)
|
||||||
|
self._backtest_col = ft.Column(scroll=ft.ScrollMode.AUTO, expand=True)
|
||||||
|
|
||||||
bar = ft.TabBar(tabs=[
|
bar = ft.TabBar(tabs=[
|
||||||
ft.Tab(label="实时价格监控"),
|
ft.Tab(label="实时价格监控"),
|
||||||
@@ -491,12 +565,28 @@ class _DrawerPanel:
|
|||||||
self._refresh_trades()
|
self._refresh_trades()
|
||||||
self._refresh_market()
|
self._refresh_market()
|
||||||
self._refresh_dataset()
|
self._refresh_dataset()
|
||||||
self._refresh_scoring()
|
|
||||||
|
|
||||||
def _refresh_grid(self):
|
def _refresh_grid(self):
|
||||||
# (width, expand): 0=固定宽, >0=弹性比重; 股票列 expand 自动填充剩余空间
|
# (width, expand): 0=固定宽, >0=弹性比重; 股票列 expand 自动填充剩余空间
|
||||||
_C = [(35, 0), (0, 1), (80, 0), (60, 0), (70, 0), (65, 0)]
|
# 新增: 排名列 (50px)
|
||||||
H = ["ID", "股票", "市场价", "持仓", "成本", "操作"]
|
_C = [(35, 0), (0, 1), (80, 0), (60, 0), (70, 0), (50, 0), (65, 0)]
|
||||||
|
H = ["ID", "股票", "市场价", "持仓", "成本", "排名", "操作"]
|
||||||
|
|
||||||
|
# 获取最新评分排名
|
||||||
|
rank_map = {} # stock_code -> score_rank
|
||||||
|
try:
|
||||||
|
from core.scoring.models import ScoringResult
|
||||||
|
from peewee import fn
|
||||||
|
latest_date = ScoringResult.select(fn.MAX(ScoringResult.trade_date)).scalar()
|
||||||
|
if latest_date:
|
||||||
|
rows = (ScoringResult
|
||||||
|
.select(ScoringResult.stock_code, ScoringResult.score_rank)
|
||||||
|
.where(ScoringResult.trade_date == latest_date)
|
||||||
|
.dicts())
|
||||||
|
for r in rows:
|
||||||
|
rank_map[r['stock_code']] = r['score_rank']
|
||||||
|
except Exception:
|
||||||
|
pass # 数据库未就绪时忽略
|
||||||
|
|
||||||
def _cell(text, w, e, color=None):
|
def _cell(text, w, e, color=None):
|
||||||
if e > 0:
|
if e > 0:
|
||||||
@@ -509,15 +599,26 @@ class _DrawerPanel:
|
|||||||
for tid, t in self._data.tradeTargets.items():
|
for tid, t in self._data.tradeTargets.items():
|
||||||
if t.strategy_type == STRATEGY_TYPE_GRID: continue
|
if t.strategy_type == STRATEGY_TYPE_GRID: continue
|
||||||
mp = self._data.marketPrices.get(tid, 0) or 0
|
mp = self._data.marketPrices.get(tid, 0) or 0
|
||||||
|
plain = t.stock_code.split('.')[0] if '.' in t.stock_code else t.stock_code
|
||||||
|
rank = rank_map.get(plain, None)
|
||||||
|
rank_str = f"#{rank}" if rank is not None else "—"
|
||||||
|
# 排名颜色: top50 绿色, top100 蓝色, 其他灰色
|
||||||
|
if rank is not None and rank <= 50:
|
||||||
|
rank_color = '#4CAF50'
|
||||||
|
elif rank is not None and rank <= 100:
|
||||||
|
rank_color = '#2196F3'
|
||||||
|
else:
|
||||||
|
rank_color = '#888888'
|
||||||
cells = [
|
cells = [
|
||||||
_cell(str(tid), *_C[0]),
|
_cell(str(tid), *_C[0]),
|
||||||
_cell(f'{t.stock_code} {t.stock_name}', *_C[1]),
|
_cell(f'{t.stock_code} {t.stock_name}', *_C[1]),
|
||||||
_cell(f'{mp:.3f}', *_C[2]),
|
_cell(f'{mp:.3f}', *_C[2]),
|
||||||
_cell(str(t.current_position), *_C[3]),
|
_cell(str(t.current_position), *_C[3]),
|
||||||
_cell(f'{self._data.avgPrices.get(tid, 0):.3f}', *_C[4]),
|
_cell(f'{self._data.avgPrices.get(tid, 0):.3f}', *_C[4]),
|
||||||
|
_cell(rank_str, *_C[5], color=rank_color),
|
||||||
ft.Container(ft.IconButton(ft.Icons.SETTINGS, icon_size=20, tooltip="网格配置",
|
ft.Container(ft.IconButton(ft.Icons.SETTINGS, icon_size=20, tooltip="网格配置",
|
||||||
on_click=lambda e, tt=t: self._dialogs.open_config(tt)),
|
on_click=lambda e, tt=t: self._dialogs.open_config(tt)),
|
||||||
width=_C[5][0], padding=0),
|
width=_C[6][0], padding=0),
|
||||||
]
|
]
|
||||||
row = ft.Row(cells, spacing=0)
|
row = ft.Row(cells, spacing=0)
|
||||||
rows.append(ft.Container(row, padding=ft.Padding(0, 2, 0, 2)))
|
rows.append(ft.Container(row, padding=ft.Padding(0, 2, 0, 2)))
|
||||||
@@ -603,10 +704,15 @@ class _DrawerPanel:
|
|||||||
|
|
||||||
def _build_dataset_tab(self) -> ft.Control:
|
def _build_dataset_tab(self) -> ft.Control:
|
||||||
self._dataset_status = ft.Text("就绪", size=12, color='#888888')
|
self._dataset_status = ft.Text("就绪", size=12, color='#888888')
|
||||||
|
self._backtest_label = ft.Text("v6.7r3 回测曲线", size=11, color='#aaaaaa')
|
||||||
|
self._backtest_img = ft.Container(visible=False) # 回测图表占位,运行时替换为 Image
|
||||||
return ft.Column([
|
return ft.Column([
|
||||||
self._dataset_status,
|
self._dataset_status,
|
||||||
self._dataset_col,
|
self._dataset_col,
|
||||||
], expand=True, spacing=6)
|
ft.Divider(height=2, color='#444444'),
|
||||||
|
self._backtest_label,
|
||||||
|
self._backtest_img,
|
||||||
|
], expand=True, spacing=6, scroll=ft.ScrollMode.AUTO)
|
||||||
|
|
||||||
def _run_sync(self, targets: list):
|
def _run_sync(self, targets: list):
|
||||||
# 线程锁
|
# 线程锁
|
||||||
@@ -649,6 +755,92 @@ class _DrawerPanel:
|
|||||||
|
|
||||||
threading.Thread(target=_do, daemon=True).start()
|
threading.Thread(target=_do, daemon=True).start()
|
||||||
|
|
||||||
|
def _refresh_backtest(self):
|
||||||
|
"""加载 v6.7r3 回测图表,嵌入数据集 Tab"""
|
||||||
|
import pandas as pd
|
||||||
|
import io, base64, json
|
||||||
|
import matplotlib
|
||||||
|
matplotlib.use('Agg')
|
||||||
|
import matplotlib.pyplot as plt
|
||||||
|
import matplotlib.dates as mdates
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
model_dir = Path(__file__).parent.parent.parent.parent / 'models'
|
||||||
|
daily_csv = model_dir / 'backtest_v67r3_daily.csv'
|
||||||
|
summary_json = model_dir / 'backtest_v67r3_summary.json'
|
||||||
|
|
||||||
|
if not daily_csv.exists():
|
||||||
|
self._backtest_label.value = "回测数据不存在"
|
||||||
|
self._backtest_label.update()
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
df_daily = pd.read_csv(daily_csv, parse_dates=['date'])
|
||||||
|
summary = json.loads(summary_json.read_text()) if summary_json.exists() else {}
|
||||||
|
|
||||||
|
# 更新标题行
|
||||||
|
start_val = summary.get('initial_cash', 60000)
|
||||||
|
final_val = summary.get('final_total_value', 0)
|
||||||
|
self._backtest_label.value = (f"v6.7r3 回测 初始:{start_val:,.0f} → 终值:{final_val:,.0f} "
|
||||||
|
f"收益:{summary.get('total_return_pct',0):.1f}% "
|
||||||
|
f"夏普:{summary.get('annual_sharpe',0):.2f} "
|
||||||
|
f"回撤:{summary.get('max_drawdown_pct',0):.1f}%")
|
||||||
|
self._backtest_label.update()
|
||||||
|
|
||||||
|
# 渲染总资产曲线
|
||||||
|
import matplotlib.font_manager as mfont
|
||||||
|
# 查找中文字体
|
||||||
|
cjk_names = ['Microsoft YaHei', 'SimHei', 'Noto Sans SC', 'WenQuanYi']
|
||||||
|
cjk_font = next((f.fname for f in mfont.fontManager.ttflist
|
||||||
|
if any(n in f.name for n in cjk_names)), None)
|
||||||
|
if cjk_font:
|
||||||
|
prop = mfont.FontProperties(fname=cjk_font)
|
||||||
|
plt.rcParams['font.family'] = prop.get_name()
|
||||||
|
plt.rcParams['axes.unicode_minus'] = False
|
||||||
|
fig, ax = plt.subplots(figsize=(7, 3.5), dpi=100)
|
||||||
|
fig.patch.set_facecolor('#1e1e1e')
|
||||||
|
ax.set_facecolor('#2d2d2d')
|
||||||
|
ax.plot(df_daily['date'], df_daily['total_asset'], color='#4CAF50', linewidth=1.5)
|
||||||
|
ax.fill_between(df_daily['date'], df_daily['total_asset'], alpha=0.1, color='#4CAF50')
|
||||||
|
ax.set_title('总资产曲线 (v6.7r3)', color='#ffffff', fontsize=10)
|
||||||
|
ax.set_ylabel('元', color='#cccccc', fontsize=9)
|
||||||
|
ax.tick_params(colors='#cccccc', labelsize=8)
|
||||||
|
ax.xaxis.set_major_formatter(mdates.DateFormatter('%Y-%m'))
|
||||||
|
ax.xaxis.set_major_locator(mdates.MonthLocator(interval=3))
|
||||||
|
plt.setp(ax.xaxis.get_majorticklabels(), rotation=30, ha='right')
|
||||||
|
for spine in ax.spines.values():
|
||||||
|
spine.set_color('#555555')
|
||||||
|
ax.grid(True, alpha=0.15, color='#888888')
|
||||||
|
# 标注起点终点
|
||||||
|
ax.annotate(f'{start_val:,.0f}', xy=(df_daily['date'].iloc[0], start_val),
|
||||||
|
xytext=(3, 5), textcoords='offset points', color='#aaaaaa', fontsize=8)
|
||||||
|
ax.annotate(f'{final_val:,.0f}', xy=(df_daily['date'].iloc[-1], final_val),
|
||||||
|
xytext=(3, 5), textcoords='offset points', color='#4CAF50', fontsize=8, fontweight='bold')
|
||||||
|
plt.tight_layout()
|
||||||
|
buf = io.BytesIO()
|
||||||
|
fig.savefig(buf, format='png', bbox_inches='tight', facecolor=fig.get_facecolor())
|
||||||
|
buf.seek(0)
|
||||||
|
img_b64 = base64.b64encode(buf.read()).decode()
|
||||||
|
plt.close(fig)
|
||||||
|
|
||||||
|
# 用实际 Image 替换占位的 Container
|
||||||
|
new_img = ft.Image(
|
||||||
|
src=f'data:image/png;base64,{img_b64}',
|
||||||
|
fit=ft.BoxFit.CONTAIN,
|
||||||
|
height=240,
|
||||||
|
visible=True,
|
||||||
|
)
|
||||||
|
# 找到 placeholder Container 在 Column 中的位置,替换
|
||||||
|
idx = self._backtest_label.parent.controls.index(self._backtest_img)
|
||||||
|
self._backtest_label.parent.controls[idx] = new_img
|
||||||
|
self._backtest_img = new_img
|
||||||
|
self._backtest_label.parent.update()
|
||||||
|
|
||||||
|
except Exception as ex:
|
||||||
|
self._backtest_label.value = f"图表加载失败: {ex}"
|
||||||
|
self._backtest_label.color = '#F44336'
|
||||||
|
self._backtest_label.update()
|
||||||
|
|
||||||
def _set_sync_btns_disabled(self, disabled: bool):
|
def _set_sync_btns_disabled(self, disabled: bool):
|
||||||
for btn in self._sync_btns:
|
for btn in self._sync_btns:
|
||||||
btn.disabled = disabled
|
btn.disabled = disabled
|
||||||
@@ -713,40 +905,101 @@ class _DrawerPanel:
|
|||||||
rows.append(ft.Divider(height=1, color='#f0f0f0'))
|
rows.append(ft.Divider(height=1, color='#f0f0f0'))
|
||||||
|
|
||||||
self._dataset_col.controls = rows
|
self._dataset_col.controls = rows
|
||||||
|
self._refresh_backtest()
|
||||||
|
|
||||||
# ── Tab 6: 每日评分 ──
|
# ── Tab 6: 每日评分 ──
|
||||||
def _build_scoring_tab(self) -> ft.Control:
|
def _build_scoring_tab(self) -> ft.Control:
|
||||||
from datetime import date, timedelta
|
from datetime import timedelta
|
||||||
self._score_cur_date = date.today()
|
from peewee import fn
|
||||||
|
from core.scoring.models import ScoringResult
|
||||||
|
|
||||||
|
# 初始日期:优先最近一个有评分的过去日期,今天盘中不能评分
|
||||||
|
today = _date.today()
|
||||||
|
latest_scored = (ScoringResult
|
||||||
|
.select(fn.MAX(ScoringResult.trade_date))
|
||||||
|
.scalar())
|
||||||
|
# 只取过去的评分日期,今天(未收盘)不算有效评分
|
||||||
|
if latest_scored and latest_scored < today:
|
||||||
|
self._score_cur_date = latest_scored
|
||||||
|
elif latest_scored:
|
||||||
|
self._score_cur_date = latest_scored # 今天有评分(可能是收盘后补跑的),也显示
|
||||||
|
else:
|
||||||
|
self._score_cur_date = today
|
||||||
|
|
||||||
self._score_date_label = ft.Text(str(self._score_cur_date), size=14, weight=ft.FontWeight.BOLD)
|
self._score_date_label = ft.Text(str(self._score_cur_date), size=14, weight=ft.FontWeight.BOLD)
|
||||||
self._score_status = ft.Text("无评分数据", size=12, color='#888888')
|
self._score_status = ft.Text("无评分数据", size=12, color='#888888')
|
||||||
|
self._score_refreshing = False
|
||||||
|
self._score_lock = threading.Lock()
|
||||||
|
|
||||||
|
self._score_max_date = today # 导航上限:不允许看未来日期
|
||||||
|
|
||||||
def _prev_day(_):
|
def _prev_day(_):
|
||||||
|
if not self._score_lock.acquire(blocking=False):
|
||||||
|
return
|
||||||
self._score_cur_date -= timedelta(days=1)
|
self._score_cur_date -= timedelta(days=1)
|
||||||
self._score_date_label.value = str(self._score_cur_date)
|
self._score_date_label.value = str(self._score_cur_date)
|
||||||
self._score_date_label.update()
|
self._score_date_label.update()
|
||||||
|
self._score_refreshing = True
|
||||||
|
for btn in self._score_nav_btns:
|
||||||
|
btn.disabled = True
|
||||||
|
for btn in self._score_nav_btns:
|
||||||
|
btn.update()
|
||||||
self._refresh_scoring()
|
self._refresh_scoring()
|
||||||
|
|
||||||
def _next_day(_):
|
def _next_day(_):
|
||||||
|
if not self._score_lock.acquire(blocking=False):
|
||||||
|
return
|
||||||
|
if self._score_cur_date >= self._score_max_date:
|
||||||
|
self._score_lock.release()
|
||||||
|
return
|
||||||
self._score_cur_date += timedelta(days=1)
|
self._score_cur_date += timedelta(days=1)
|
||||||
self._score_date_label.value = str(self._score_cur_date)
|
self._score_date_label.value = str(self._score_cur_date)
|
||||||
self._score_date_label.update()
|
self._score_date_label.update()
|
||||||
|
self._score_refreshing = True
|
||||||
|
for btn in self._score_nav_btns:
|
||||||
|
btn.disabled = True
|
||||||
|
for btn in self._score_nav_btns:
|
||||||
|
btn.update()
|
||||||
self._refresh_scoring()
|
self._refresh_scoring()
|
||||||
|
|
||||||
def _today(_):
|
def _today(_):
|
||||||
self._score_cur_date = date.today()
|
if not self._score_lock.acquire(blocking=False):
|
||||||
|
return
|
||||||
|
if self._score_cur_date >= self._score_max_date:
|
||||||
|
self._score_lock.release()
|
||||||
|
return
|
||||||
|
self._score_cur_date = self._score_max_date
|
||||||
self._score_date_label.value = str(self._score_cur_date)
|
self._score_date_label.value = str(self._score_cur_date)
|
||||||
self._score_date_label.update()
|
self._score_date_label.update()
|
||||||
|
self._score_refreshing = True
|
||||||
|
for btn in self._score_nav_btns:
|
||||||
|
btn.disabled = True
|
||||||
|
for btn in self._score_nav_btns:
|
||||||
|
btn.update()
|
||||||
self._refresh_scoring()
|
self._refresh_scoring()
|
||||||
|
|
||||||
|
self._score_nav_btns = [] # 占位,后面重新赋值
|
||||||
|
prev_btn = ft.IconButton(ft.Icons.CHEVRON_LEFT, icon_size=22, tooltip="前一天", on_click=_prev_day)
|
||||||
|
next_btn = ft.IconButton(ft.Icons.CHEVRON_RIGHT, icon_size=22, tooltip="后一天", on_click=_next_day)
|
||||||
|
today_btn = ft.IconButton(ft.Icons.TODAY, icon_size=20, tooltip="回到今天", on_click=_today)
|
||||||
|
self._score_nav_btns = [prev_btn, next_btn, today_btn]
|
||||||
|
|
||||||
|
# 初始时若已在上限日期,禁用"明天"和"今天"按钮
|
||||||
|
if self._score_cur_date >= self._score_max_date:
|
||||||
|
next_btn.disabled = True
|
||||||
|
today_btn.disabled = True
|
||||||
|
|
||||||
date_row = ft.Row([
|
date_row = ft.Row([
|
||||||
ft.IconButton(ft.Icons.CHEVRON_LEFT, icon_size=22, tooltip="前一天", on_click=_prev_day),
|
prev_btn,
|
||||||
self._score_date_label,
|
self._score_date_label,
|
||||||
ft.IconButton(ft.Icons.CHEVRON_RIGHT, icon_size=22, tooltip="后一天", on_click=_next_day),
|
next_btn,
|
||||||
ft.IconButton(ft.Icons.TODAY, icon_size=20, tooltip="回到今天", on_click=_today),
|
today_btn,
|
||||||
ft.Container(ft.Divider(height=20), width=2),
|
ft.Container(ft.Divider(height=20), width=2),
|
||||||
ft.ElevatedButton("同步数据", on_click=lambda e: self._run_sync(['kline', 'stocks', 'industry', 'market', 'sector']), height=32),
|
ft.ElevatedButton("同步数据", on_click=lambda e: self._run_sync(['kline', 'stocks', 'industry', 'market', 'sector']), height=32),
|
||||||
ft.ElevatedButton("执行评分", on_click=lambda e: self._run_scoring(), height=32),
|
ft.ElevatedButton("执行评分", on_click=lambda e: self._run_scoring(), height=32),
|
||||||
|
ft.Container(ft.Divider(height=20), width=2),
|
||||||
|
ft.ElevatedButton("沉寂检测", on_click=lambda e: self._data._pool_manager.trigger_slumber(), height=32, tooltip="手动触发沉寂检测(阶段一标记)"),
|
||||||
|
ft.ElevatedButton("淘汰检测", on_click=lambda e: self._data._pool_manager.trigger_weekly_elim(), height=32, tooltip="手动触发周度淘汰检测(阶段一标记)"),
|
||||||
self._score_status,
|
self._score_status,
|
||||||
], spacing=6, vertical_alignment=ft.CrossAxisAlignment.CENTER)
|
], spacing=6, vertical_alignment=ft.CrossAxisAlignment.CENTER)
|
||||||
return ft.Column([
|
return ft.Column([
|
||||||
@@ -763,34 +1016,68 @@ class _DrawerPanel:
|
|||||||
from core.scoring.inference.scorer import GridSeekerPipeline
|
from core.scoring.inference.scorer import GridSeekerPipeline
|
||||||
trade_date = self._score_cur_date
|
trade_date = self._score_cur_date
|
||||||
|
|
||||||
# 检查当日 K线数据是否已同步
|
# 检查 K线数据同步到哪一天
|
||||||
from peewee import fn
|
from peewee import fn
|
||||||
from core.scoring.models import KlineStock
|
from core.scoring.models import KlineStock
|
||||||
latest_kline = KlineStock.select(fn.MAX(KlineStock.trade_date)).scalar()
|
latest_kline = KlineStock.select(fn.MAX(KlineStock.trade_date)).scalar()
|
||||||
if latest_kline is None or latest_kline < trade_date:
|
|
||||||
self._score_status.value = f"K线未同步至 {trade_date},请先盘后同步"
|
if latest_kline is None:
|
||||||
|
self._score_status.value = "K线数据未同步,请先同步数据"
|
||||||
self._score_status.color = '#F44336'
|
self._score_status.color = '#F44336'
|
||||||
self._score_status.update()
|
self._score_status.update()
|
||||||
return
|
return
|
||||||
|
|
||||||
|
# 评分日期以实际可用数据日期为准
|
||||||
|
# 规则: 只能评过去已收盘的日期,今天(06-26)盘中不能评分
|
||||||
|
# - trade_date < today: 过去日期,若有数据则用,若无数据则fallback到latest_kline
|
||||||
|
# - trade_date == today: 今天盘中,拒绝评分
|
||||||
|
today = _date.today()
|
||||||
|
if trade_date >= today:
|
||||||
|
self._score_status.value = f"今天({today})未收盘,无法评分,请切换到过去日期"
|
||||||
|
self._score_status.color = '#F44336'
|
||||||
|
self._score_status.update()
|
||||||
|
self._revert_nav_btns()
|
||||||
|
return
|
||||||
|
|
||||||
|
from core.scoring.models import KlineStock
|
||||||
|
has_trade_date = (KlineStock
|
||||||
|
.select(fn.COUNT(KlineStock.stock_code))
|
||||||
|
.where(KlineStock.trade_date == trade_date)
|
||||||
|
.scalar() or 0) > 0
|
||||||
|
|
||||||
|
persist_date = trade_date if has_trade_date else latest_kline
|
||||||
|
|
||||||
|
if not has_trade_date:
|
||||||
|
self._score_status.value = f"{trade_date} K线未同步,实际用 {persist_date} 数据评分"
|
||||||
|
self._score_status.color = '#FF9800'
|
||||||
|
self._score_status.update()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
engine = GridSeekerPipeline()
|
engine = GridSeekerPipeline()
|
||||||
rankings = engine.run(trade_date)
|
rankings = engine.run(persist_date)
|
||||||
if not rankings.empty:
|
if not rankings.empty:
|
||||||
engine.persist(rankings, trade_date)
|
engine.persist(rankings, persist_date)
|
||||||
self._score_status.value = f"{trade_date} 评分完成"
|
self._score_status.value = f"{persist_date} 评分完成"
|
||||||
|
self._score_status.color = '#4CAF50'
|
||||||
|
# 刷新后跳到实际评分日期的那一页
|
||||||
|
self._score_cur_date = persist_date
|
||||||
|
self._score_date_label.value = str(persist_date)
|
||||||
except FileNotFoundError:
|
except FileNotFoundError:
|
||||||
self._score_status.value = "模型文件缺失"
|
self._score_status.value = "模型文件缺失"
|
||||||
except Exception as ex:
|
except Exception as ex:
|
||||||
self._score_status.value = f"失败: {ex}"
|
self._score_status.value = f"失败: {ex}"
|
||||||
|
self._score_status.color = '#F44336'
|
||||||
self._score_status.update()
|
self._score_status.update()
|
||||||
self._refresh_scoring()
|
self._refresh_scoring()
|
||||||
|
|
||||||
threading.Thread(target=_do, daemon=True).start()
|
threading.Thread(target=_do, daemon=True).start()
|
||||||
|
|
||||||
def _refresh_scoring(self):
|
def _refresh_scoring(self):
|
||||||
|
import time as _time
|
||||||
from core.scoring.models import ScoringResult
|
from core.scoring.models import ScoringResult
|
||||||
from core.qmt import qmtv
|
from core.qmt import qmtv
|
||||||
|
t0 = _time.time()
|
||||||
|
PrintLog(LogLevel.DEBUG, '[_refresh_scoring] START date=%s' % self._score_cur_date)
|
||||||
|
|
||||||
# (width, expand): 排名60 + 名称弹性 + 概率65 + 轮数58 + 堆叠概率65 + 操作32
|
# (width, expand): 排名60 + 名称弹性 + 概率65 + 轮数58 + 堆叠概率65 + 操作32
|
||||||
_C = [(60, 0), (0, 1), (65, 1), (58, 1), (65, 1), (32, 1)]
|
_C = [(60, 0), (0, 1), (65, 1), (58, 1), (65, 1), (32, 1)]
|
||||||
@@ -821,28 +1108,40 @@ class _DrawerPanel:
|
|||||||
if not scored_rows:
|
if not scored_rows:
|
||||||
self._score_status.value = f"{trade_date} 无评分数据"
|
self._score_status.value = f"{trade_date} 无评分数据"
|
||||||
self._score_status.color = '#F44336'
|
self._score_status.color = '#F44336'
|
||||||
|
# 空状态时在表格内也显示日期占位,让切换日期有明确的视觉反馈
|
||||||
|
rows.append(ft.Container(
|
||||||
|
ft.Text(f'← {trade_date} 暂无评分数据 →',
|
||||||
|
size=14, color='#bbbbbb', text_align=ft.TextAlign.CENTER),
|
||||||
|
padding=ft.Padding(0, 20, 0, 20),
|
||||||
|
))
|
||||||
self._scoring_table.controls = rows
|
self._scoring_table.controls = rows
|
||||||
|
# 一次性批量更新:标签 + 状态 + 导航按钮
|
||||||
|
self._score_date_label.update()
|
||||||
self._score_status.update()
|
self._score_status.update()
|
||||||
|
self._revert_nav_btns()
|
||||||
return
|
return
|
||||||
|
|
||||||
shown = 0
|
# 批量预加载: 1次DB查询 (替代原来每行1+1次,最快方案)
|
||||||
|
plain_codes = []
|
||||||
|
full_codes = []
|
||||||
for r in scored_rows:
|
for r in scored_rows:
|
||||||
code = r['stock_code']
|
code = r['stock_code']
|
||||||
plain = code.split('.')[0] if '.' in code else code
|
plain = code.split('.')[0] if '.' in code else code
|
||||||
# 过滤 ST
|
plain_codes.append(plain)
|
||||||
from core.scoring.models import StockInfo
|
full_codes.append(f'{plain}.SH' if plain.startswith(('6', '5', '9')) else f'{plain}.SZ')
|
||||||
if plain.startswith(('6', '5', '9')):
|
|
||||||
full_code = f'{plain}.SH'
|
from core.scoring.models import StockInfo
|
||||||
else:
|
st_map = {}
|
||||||
full_code = f'{plain}.SZ'
|
name_map = {}
|
||||||
st = StockInfo.get_or_none(StockInfo.code == full_code)
|
for row in StockInfo.select(StockInfo.code, StockInfo.listing_status, StockInfo.name).where(StockInfo.code.in_(full_codes)).dicts():
|
||||||
if st and st.listing_status == 'ST':
|
st_map[row['code']] = row['listing_status']
|
||||||
|
name_map[row['code'].split('.')[0]] = row['name']
|
||||||
|
|
||||||
|
shown = 0
|
||||||
|
for r, plain, full_code in zip(scored_rows, plain_codes, full_codes):
|
||||||
|
if st_map.get(full_code) == 'ST':
|
||||||
continue
|
continue
|
||||||
name = ''
|
name = name_map.get(plain, '')
|
||||||
try:
|
|
||||||
name = qmtv.getInstrumentName(plain)
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
already_in = plain in self._data.stockCodeIdMap
|
already_in = plain in self._data.stockCodeIdMap
|
||||||
shown += 1
|
shown += 1
|
||||||
|
|
||||||
@@ -873,7 +1172,26 @@ class _DrawerPanel:
|
|||||||
self._scoring_table.controls = rows
|
self._scoring_table.controls = rows
|
||||||
self._score_status.value = f"{trade_date} 候选 {shown} 只"
|
self._score_status.value = f"{trade_date} 候选 {shown} 只"
|
||||||
self._score_status.color = '#4CAF50'
|
self._score_status.color = '#4CAF50'
|
||||||
|
# 一次性批量更新:标签 + 状态 + 导航按钮
|
||||||
|
self._score_date_label.update()
|
||||||
self._score_status.update()
|
self._score_status.update()
|
||||||
|
self._revert_nav_btns()
|
||||||
|
PrintLog(LogLevel.DEBUG, '[_refresh_scoring] END t=%.2fs rows=%s' % (_time.time()-t0, shown))
|
||||||
|
|
||||||
|
|
||||||
|
def _revert_nav_btns(self):
|
||||||
|
"""重新启用导航按钮并解除刷新锁;已达上限日期时禁用'明天'和'今天'按钮"""
|
||||||
|
self._score_refreshing = False
|
||||||
|
at_max = self._score_cur_date >= self._score_max_date
|
||||||
|
for btn in self._score_nav_btns:
|
||||||
|
btn.disabled = False
|
||||||
|
if at_max:
|
||||||
|
self._score_nav_btns[1].disabled = True # next
|
||||||
|
self._score_nav_btns[2].disabled = True # today
|
||||||
|
for btn in self._score_nav_btns:
|
||||||
|
btn.update()
|
||||||
|
if self._score_refreshing:
|
||||||
|
self._score_lock.release()
|
||||||
|
|
||||||
def _on_add_from_scoring(self, stock_code: str, stock_name: str):
|
def _on_add_from_scoring(self, stock_code: str, stock_name: str):
|
||||||
"""+ 按钮:从评分列表添加标的并打开网格配置"""
|
"""+ 按钮:从评分列表添加标的并打开网格配置"""
|
||||||
@@ -1173,8 +1491,14 @@ class QmtApp:
|
|||||||
event_bus.subscribe(MarketDataUpdate, self._on_market_data)
|
event_bus.subscribe(MarketDataUpdate, self._on_market_data)
|
||||||
event_bus.subscribe(EventMarketActiveSwitch, self._on_market_active)
|
event_bus.subscribe(EventMarketActiveSwitch, self._on_market_active)
|
||||||
event_bus.subscribe(EventTradeTargetUpdate, lambda t: self._refresh_ui())
|
event_bus.subscribe(EventTradeTargetUpdate, lambda t: self._refresh_ui())
|
||||||
|
event_bus.subscribe(EventPoolMark, self._on_pool_mark)
|
||||||
threading.Thread(target=self._refresh_loop, daemon=True).start()
|
threading.Thread(target=self._refresh_loop, daemon=True).start()
|
||||||
|
|
||||||
|
# 启动股票池管理器(阶段一:T日标记)
|
||||||
|
from core.sfgrid.pool_manager import PoolManager
|
||||||
|
self._data._pool_manager = PoolManager()
|
||||||
|
self._data._pool_manager.start()
|
||||||
|
|
||||||
def _show_error(self, msg: str):
|
def _show_error(self, msg: str):
|
||||||
self.page.clean()
|
self.page.clean()
|
||||||
self.page.add(ft.Container(
|
self.page.add(ft.Container(
|
||||||
@@ -1278,6 +1602,36 @@ class QmtApp:
|
|||||||
self._prices_loaded = True
|
self._prices_loaded = True
|
||||||
self._refresh_ui()
|
self._refresh_ui()
|
||||||
|
|
||||||
|
def _on_pool_mark(self, data: dict):
|
||||||
|
"""响应股票池标记事件,在日志区显示"""
|
||||||
|
action = data.get('action', '')
|
||||||
|
count = data.get('count', 0)
|
||||||
|
codes = data.get('stock_codes', [])
|
||||||
|
if action == 'scoring':
|
||||||
|
msg = f'[池] 评分完成,共 {count} 只候选'
|
||||||
|
elif action == 'liquidate':
|
||||||
|
msg = f'[池] 沉寂检测完成,标记 {count} 只股: {", ".join(codes)}'
|
||||||
|
elif action == 'eliminate':
|
||||||
|
msg = f'[池] 周度淘汰检测完成,标记 {count} 只股: {", ".join(codes)}'
|
||||||
|
else:
|
||||||
|
msg = f'[池] 未知事件: {data}'
|
||||||
|
self._data.poolLog.append(msg)
|
||||||
|
# 追加到 UI 日志
|
||||||
|
if hasattr(self, '_pool_log_text') and self._pool_log_text:
|
||||||
|
log = self._pool_log_text
|
||||||
|
log.value = '\n'.join(self._data.poolLog[-200:]) if self._data.poolLog else ''
|
||||||
|
log.update()
|
||||||
|
from core.logger import PrintLog, LogLevel
|
||||||
|
PrintLog(LogLevel.INFO, msg)
|
||||||
|
|
||||||
|
def trigger_pool_slumber(self):
|
||||||
|
"""供 UI 按钮手动触发沉寂检测"""
|
||||||
|
self._data._pool_manager.trigger_slumber()
|
||||||
|
|
||||||
|
def trigger_pool_elim(self):
|
||||||
|
"""供 UI 按钮手动触淘汰检测"""
|
||||||
|
self._data._pool_manager.trigger_weekly_elim()
|
||||||
|
|
||||||
def _on_market_active(self, is_active: bool):
|
def _on_market_active(self, is_active: bool):
|
||||||
from core.logger import PrintLog, LogLevel
|
from core.logger import PrintLog, LogLevel
|
||||||
PrintLog(LogLevel.INFO, f'[UI] 收到市场状态切换事件 is_active={is_active}')
|
PrintLog(LogLevel.INFO, f'[UI] 收到市场状态切换事件 is_active={is_active}')
|
||||||
|
|||||||
@@ -1,142 +0,0 @@
|
|||||||
import tkinter as tk
|
|
||||||
from tkinter import ttk
|
|
||||||
from core.logger import LogLevel, LogData, PrintLog
|
|
||||||
from core.ui.tkinter.sfgrid_view import TradeTargetUI
|
|
||||||
|
|
||||||
# 检测运行环境,决定使用真实或模拟 QMT
|
|
||||||
def get_qmt_module():
|
|
||||||
try:
|
|
||||||
# 尝试导入真实 QMT,如果失败则使用模拟
|
|
||||||
from core.qmt import qmtv
|
|
||||||
return qmtv
|
|
||||||
except ImportError:
|
|
||||||
from core.qmt_dummy import qmtv
|
|
||||||
return qmtv
|
|
||||||
|
|
||||||
qmtv = get_qmt_module()
|
|
||||||
|
|
||||||
from core.eventbus import EventPrintLog
|
|
||||||
from core.eventbus import event_bus as eBus
|
|
||||||
|
|
||||||
|
|
||||||
class MainWindow:
|
|
||||||
def __init__(self, configLogLevel:str, progress=None):
|
|
||||||
self.root = tk.Tk()
|
|
||||||
self.root.title("神之一手 - 交易系统")
|
|
||||||
self.root.geometry("1400x700")
|
|
||||||
|
|
||||||
self.logLevel = LogLevel[configLogLevel]
|
|
||||||
PrintLog(LogLevel.DEBUG, f"系统启动成功 {self.logLevel.name}")
|
|
||||||
# 存储各个Frame的引用
|
|
||||||
self.strategy_frames = {}
|
|
||||||
# 日志面板可见性标志
|
|
||||||
self.log_visible = False
|
|
||||||
self.create_ui(progress)
|
|
||||||
|
|
||||||
eBus.subscribe(EventPrintLog, self.on_log_event)
|
|
||||||
|
|
||||||
|
|
||||||
def create_ui(self, progress=None):
|
|
||||||
"""创建UI界面"""
|
|
||||||
# 主容器
|
|
||||||
main_container = ttk.Frame(self.root)
|
|
||||||
main_container.pack(fill=tk.BOTH, expand=True, padx=10, pady=10)
|
|
||||||
|
|
||||||
# 中间主体区域
|
|
||||||
content_area = ttk.Frame(main_container)
|
|
||||||
content_area.pack(fill=tk.BOTH, expand=True)
|
|
||||||
|
|
||||||
# 右侧内容区域容器
|
|
||||||
self.content_container = ttk.Frame(content_area)
|
|
||||||
self.content_container.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)
|
|
||||||
|
|
||||||
# 创建策略Frame
|
|
||||||
strategy_names = ["网格"]
|
|
||||||
self.create_strategy_frames(strategy_names, progress)
|
|
||||||
|
|
||||||
# 创建全局日志面板(默认隐藏)
|
|
||||||
self.create_global_log_panel(main_container)
|
|
||||||
|
|
||||||
# 默认显示第一个策略
|
|
||||||
self.show_strategy_frame(0)
|
|
||||||
|
|
||||||
def create_global_log_panel(self, parent):
|
|
||||||
"""创建全局日志面板"""
|
|
||||||
# 日志区域(默认隐藏)
|
|
||||||
self.log_frame = ttk.LabelFrame(parent, text="操作日志", padding=10)
|
|
||||||
# 默认不显示,通过工具栏按钮控制
|
|
||||||
|
|
||||||
# 创建日志表格
|
|
||||||
columns = ("timestamp", "level", "message")
|
|
||||||
|
|
||||||
self.log_table = ttk.Treeview(self.log_frame, columns=columns, show='headings', height=8)
|
|
||||||
|
|
||||||
log_column_configs = {
|
|
||||||
"timestamp": ("时间", 100),
|
|
||||||
"level": ("级别", 50),
|
|
||||||
"message": ("消息", 1150) # 调整宽度适应全局布局
|
|
||||||
}
|
|
||||||
|
|
||||||
for col in columns:
|
|
||||||
title, width = log_column_configs[col]
|
|
||||||
self.log_table.heading(col, text=title)
|
|
||||||
self.log_table.column(col, width=width, anchor=tk.W)
|
|
||||||
|
|
||||||
# 添加初始日志
|
|
||||||
from datetime import datetime
|
|
||||||
timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
|
||||||
self.log_table.insert('', tk.END, values=(timestamp, "INFO", "系统启动成功"))
|
|
||||||
|
|
||||||
# 滚动条
|
|
||||||
scrollbar = ttk.Scrollbar(self.log_frame, orient=tk.VERTICAL, command=self.log_table.yview)
|
|
||||||
self.log_table.configure(yscrollcommand=scrollbar.set)
|
|
||||||
|
|
||||||
self.log_table.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)
|
|
||||||
scrollbar.pack(side=tk.RIGHT, fill=tk.Y)
|
|
||||||
|
|
||||||
def on_log_event(self, event:LogData):
|
|
||||||
if self.logLevel.value <= event.level.value:
|
|
||||||
self.add_log(event.level, event.message)
|
|
||||||
|
|
||||||
|
|
||||||
def add_log(self, level:LogLevel, message):
|
|
||||||
"""添加日志记录 - 全局方法"""
|
|
||||||
from datetime import datetime
|
|
||||||
timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
|
||||||
self.log_table.insert('', 0, values=(timestamp, level.name, message))
|
|
||||||
|
|
||||||
def clear_logs(self):
|
|
||||||
"""清空日志记录"""
|
|
||||||
# 删除所有日志项
|
|
||||||
for item in self.log_table.get_children():
|
|
||||||
self.log_table.delete(item)
|
|
||||||
|
|
||||||
def create_strategy_frames(self, strategy_names, progress=None):
|
|
||||||
"""创建各个策略的Frame"""
|
|
||||||
frame = TradeTargetUI(self.content_container, progress=progress)
|
|
||||||
self.strategy_frames[0] = frame
|
|
||||||
|
|
||||||
def show_strategy_frame(self, index):
|
|
||||||
"""显示策略Frame"""
|
|
||||||
if index in self.strategy_frames:
|
|
||||||
self.strategy_frames[index].pack(fill=tk.BOTH, expand=True)
|
|
||||||
|
|
||||||
def toggle_log_panel(self):
|
|
||||||
"""切换日志面板的显示/隐藏"""
|
|
||||||
if self.log_visible:
|
|
||||||
self.log_frame.pack_forget()
|
|
||||||
self.log_visible = False
|
|
||||||
else:
|
|
||||||
self.log_frame.pack(side=tk.BOTTOM, fill=tk.X, pady=(5, 0))
|
|
||||||
self.log_visible = True
|
|
||||||
|
|
||||||
def on_exit(self):
|
|
||||||
"""退出程序"""
|
|
||||||
from tkinter import messagebox
|
|
||||||
result = messagebox.askyesno("确认退出", "确定要退出系统吗?")
|
|
||||||
if result:
|
|
||||||
self.root.destroy()
|
|
||||||
|
|
||||||
def run(self):
|
|
||||||
"""运行程序"""
|
|
||||||
self.root.mainloop()
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,112 +0,0 @@
|
|||||||
"""
|
|
||||||
启动进度窗口 — 无边框小窗口,负责整个初始化流程。
|
|
||||||
"""
|
|
||||||
import time
|
|
||||||
import tkinter as tk
|
|
||||||
from tkinter import ttk, messagebox
|
|
||||||
|
|
||||||
|
|
||||||
class SplashWindow:
|
|
||||||
"""初始化进度窗口,所有者启动逻辑"""
|
|
||||||
|
|
||||||
def __init__(self):
|
|
||||||
self.root = tk.Tk()
|
|
||||||
self.root.title("神之一手")
|
|
||||||
self.root.geometry("380x120")
|
|
||||||
self.root.resizable(False, False)
|
|
||||||
self.root.overrideredirect(True)
|
|
||||||
|
|
||||||
self.root.update_idletasks()
|
|
||||||
sw = self.root.winfo_screenwidth()
|
|
||||||
sh = self.root.winfo_screenheight()
|
|
||||||
w, h = 380, 120
|
|
||||||
self.root.geometry(f"{w}x{h}+{(sw - w) // 2}+{(sh - h) // 2}")
|
|
||||||
|
|
||||||
frame = ttk.Frame(self.root, padding=20)
|
|
||||||
frame.pack(fill=tk.BOTH, expand=True)
|
|
||||||
|
|
||||||
ttk.Label(frame, text="神之一手", font=('Microsoft YaHei', 14, 'bold')).pack(pady=(0, 5))
|
|
||||||
self._status = ttk.Label(frame, text="正在初始化...", font=('Microsoft YaHei', 9))
|
|
||||||
self._status.pack(pady=(0, 10))
|
|
||||||
|
|
||||||
self._bar = ttk.Progressbar(frame, mode='determinate', length=340)
|
|
||||||
self._bar.pack()
|
|
||||||
self.root.update()
|
|
||||||
|
|
||||||
def progress(self, text: str, pct: float):
|
|
||||||
self._status.configure(text=text)
|
|
||||||
self._bar.configure(value=pct)
|
|
||||||
self.root.update()
|
|
||||||
|
|
||||||
def _destroy(self):
|
|
||||||
self.root.destroy()
|
|
||||||
|
|
||||||
def run(self):
|
|
||||||
"""执行完整启动流程,成功返回主窗口,失败返回 None"""
|
|
||||||
from core.qmt_real import RealQmtV, qmtv as selected_qmtv
|
|
||||||
|
|
||||||
while True:
|
|
||||||
_t_total = time.time()
|
|
||||||
|
|
||||||
# 步骤1: 探测 QMT 环境
|
|
||||||
self.progress("正在检查 QMT 环境...", 10)
|
|
||||||
_t = time.time()
|
|
||||||
try:
|
|
||||||
discovered = RealQmtV._discover_qmt_port()
|
|
||||||
except Exception:
|
|
||||||
discovered = 0
|
|
||||||
print(f'[计时] 步骤1-探测QMT环境: {time.time() - _t:.2f}s')
|
|
||||||
|
|
||||||
if not discovered:
|
|
||||||
self._destroy()
|
|
||||||
messagebox.showerror(
|
|
||||||
"启动失败",
|
|
||||||
"未能自动探测到 QMT 环境。\n\n"
|
|
||||||
"请确认:\n"
|
|
||||||
"1. 极简QMT(GJQMT)已启动并登录\n"
|
|
||||||
"2. XtMiniQmt.exe 和 miniquote.exe 进程在运行"
|
|
||||||
)
|
|
||||||
return None
|
|
||||||
|
|
||||||
# 步骤2: 初始化交易器
|
|
||||||
self.progress("正在初始化交易器...", 35)
|
|
||||||
_t = time.time()
|
|
||||||
selected_qmtv.init_qmtv()
|
|
||||||
print(f'[计时] 步骤2-初始化交易器: {time.time() - _t:.2f}s')
|
|
||||||
|
|
||||||
# 步骤3: 连接 QMT
|
|
||||||
self.progress("正在连接 QMT...", 55)
|
|
||||||
_t = time.time()
|
|
||||||
connected = selected_qmtv.connect()
|
|
||||||
print(f'[计时] 步骤3-连接QMT: {time.time() - _t:.2f}s')
|
|
||||||
|
|
||||||
if not connected:
|
|
||||||
self._destroy()
|
|
||||||
option = messagebox.askokcancel(
|
|
||||||
"连接失败",
|
|
||||||
"QMT 连接失败。\n\n"
|
|
||||||
"请确认极简QMT 已启动并登录交易账号。\n"
|
|
||||||
"点击「确定」重试,或「取消」退出。"
|
|
||||||
)
|
|
||||||
if not option:
|
|
||||||
return None
|
|
||||||
# 重试:重新创建进度窗口
|
|
||||||
self.__init__()
|
|
||||||
continue
|
|
||||||
|
|
||||||
# 步骤4: 加载主界面
|
|
||||||
self.progress("正在加载持仓与策略...", 75)
|
|
||||||
_t = time.time()
|
|
||||||
from core.ui.tkinter.main_window import MainWindow
|
|
||||||
window = MainWindow('INFO', progress=lambda t, p: self.progress(t, 75 + p * 0.2))
|
|
||||||
print(f'[计时] 步骤4-主界面加载: {time.time() - _t:.2f}s')
|
|
||||||
|
|
||||||
window.root.update()
|
|
||||||
|
|
||||||
# 步骤5: 完成
|
|
||||||
self.progress("启动完成", 100)
|
|
||||||
self.root.update()
|
|
||||||
self.root.after(300, self._destroy)
|
|
||||||
print(f'[计时] 总启动耗时: {time.time() - _t_total:.2f}s')
|
|
||||||
|
|
||||||
return window
|
|
||||||
-259
@@ -1,259 +0,0 @@
|
|||||||
from kuanke.wizard import *
|
|
||||||
from jqdata import *
|
|
||||||
import pandas as pd
|
|
||||||
import numpy as np
|
|
||||||
|
|
||||||
# ==================== 初始化 ====================
|
|
||||||
def initialize(context):
|
|
||||||
set_params(context)
|
|
||||||
# 开启防未来函数
|
|
||||||
set_option('avoid_future_data', True)
|
|
||||||
# 用真实价格交易
|
|
||||||
set_option('use_real_price', True)
|
|
||||||
# 过滤order中低于error级别的日志
|
|
||||||
log.set_level('order', 'error')
|
|
||||||
log.set_level('system', 'error')
|
|
||||||
log.set_level('strategy', 'debug')
|
|
||||||
|
|
||||||
set_benchmark('000001.XSHG')
|
|
||||||
set_order_cost(OrderCost(open_tax=0, close_tax=0.001, open_commission=0.0002, close_commission=0.0002, min_commission=5), type='stock')
|
|
||||||
set_slippage(FixedSlippage(0.01))
|
|
||||||
|
|
||||||
run_daily(before_trading, '9:30')
|
|
||||||
|
|
||||||
# -------------------- 参数设置 --------------------
|
|
||||||
def set_params(context):
|
|
||||||
context.max_price = 6
|
|
||||||
context.min_price = 5.01
|
|
||||||
context.grid_base_min = 1 # 最小价格
|
|
||||||
context.grid_base_max = 5 # 建仓价格
|
|
||||||
context.grid_interval = 0.5 # 下跌n元加仓
|
|
||||||
context.profit_target = 0.5 # 上涨n元清仓
|
|
||||||
|
|
||||||
context.min_stocks = 10
|
|
||||||
context.max_stocks = 25
|
|
||||||
context.base_max_stocks = 25
|
|
||||||
context.max_layers = 7
|
|
||||||
|
|
||||||
context.base_position_pct = 0.15
|
|
||||||
context.max_position_pct = 0.15
|
|
||||||
context.target_usage = 0.98
|
|
||||||
context.reserve_ratio = 0.02
|
|
||||||
|
|
||||||
context.first_round_max = 10
|
|
||||||
context.add_batch_size = 3
|
|
||||||
context.add_cash_threshold = 0.4
|
|
||||||
|
|
||||||
g.stock_pool = []
|
|
||||||
g.grid_info = {}
|
|
||||||
g.monitoring_stocks = set()
|
|
||||||
g.first_round_done = False
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
# ==================== 盘前 ====================
|
|
||||||
def before_trading(context):
|
|
||||||
january_clear(context)
|
|
||||||
if context.current_dt.month == 1:
|
|
||||||
return
|
|
||||||
stock_pool = get_stock_pool(context)
|
|
||||||
g.stock_pool = stock_pool
|
|
||||||
g.monitoring_stocks.update([s for s in stock_pool if s not in g.grid_info])
|
|
||||||
g.first_round_done = len(g.grid_info) >= context.first_round_max
|
|
||||||
|
|
||||||
# -------------------- 股票池 --------------------
|
|
||||||
def get_stock_pool(context):
|
|
||||||
# 1. 全部 A 股(不含退市)
|
|
||||||
df_sec = get_all_securities(types=['stock'], date=context.previous_date)
|
|
||||||
codes = list(df_sec.index)
|
|
||||||
|
|
||||||
# 2. 过滤 ST、科创板、北交所
|
|
||||||
def is_valid(code):
|
|
||||||
name = df_sec.loc[code, 'display_name']
|
|
||||||
if 'ST' in name or '退' in name or 'st' in name:
|
|
||||||
return False
|
|
||||||
if code.startswith('688'): # 科创板
|
|
||||||
return False
|
|
||||||
if code.startswith('83') or code.startswith('87') or code.startswith('9'): # 北交所
|
|
||||||
return False
|
|
||||||
return True
|
|
||||||
|
|
||||||
codes = [c for c in codes if is_valid(c)]
|
|
||||||
|
|
||||||
if not codes:
|
|
||||||
return []
|
|
||||||
|
|
||||||
# 3. 过滤停牌 & 价格区间
|
|
||||||
try:
|
|
||||||
price_df = get_price(codes,
|
|
||||||
end_date=context.current_dt,
|
|
||||||
count=1,
|
|
||||||
fields=['pre_close'],
|
|
||||||
panel=False)
|
|
||||||
|
|
||||||
if price_df is None or price_df.empty:
|
|
||||||
return []
|
|
||||||
|
|
||||||
# 过滤价格区间
|
|
||||||
price_df = price_df[
|
|
||||||
(price_df['pre_close'].notna()) &
|
|
||||||
(price_df['pre_close'] >= context.min_price) &
|
|
||||||
(price_df['pre_close'] <= context.max_price)
|
|
||||||
]
|
|
||||||
|
|
||||||
valid_codes = price_df['code'].tolist()
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
log.error(f"获取价格数据失败: {e}")
|
|
||||||
return []
|
|
||||||
|
|
||||||
if not valid_codes:
|
|
||||||
return []
|
|
||||||
|
|
||||||
# 4. 过滤停牌(开盘价缺失)
|
|
||||||
try:
|
|
||||||
open_df = get_price(valid_codes,
|
|
||||||
end_date=context.current_dt,
|
|
||||||
count=1,
|
|
||||||
fields=['open'],
|
|
||||||
panel=False)
|
|
||||||
|
|
||||||
if open_df is None or open_df.empty:
|
|
||||||
return []
|
|
||||||
|
|
||||||
# 过滤掉开盘价为空的股票
|
|
||||||
open_df = open_df[open_df['open'].notna()]
|
|
||||||
final_codes = open_df['code'].tolist()
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
log.error(f"获取开盘价数据失败: {e}")
|
|
||||||
return []
|
|
||||||
|
|
||||||
return final_codes
|
|
||||||
|
|
||||||
# -------------------- 一月清仓 --------------------
|
|
||||||
def january_clear(context):
|
|
||||||
if context.current_dt.month == 1:
|
|
||||||
log.info("进入1月,执行年度清仓...")
|
|
||||||
for stock in list(context.portfolio.positions.keys()):
|
|
||||||
order_target(stock, 0)
|
|
||||||
if stock in g.grid_info:
|
|
||||||
del g.grid_info[stock]
|
|
||||||
g.monitoring_stocks.add(stock)
|
|
||||||
|
|
||||||
# ==================== 盘中 ====================
|
|
||||||
def handle_data(context, data):
|
|
||||||
if context.current_dt.month == 1:
|
|
||||||
return
|
|
||||||
manage_positions(context, data)
|
|
||||||
usage = (context.portfolio.total_value - context.portfolio.available_cash) / context.portfolio.total_value
|
|
||||||
dynamic_max = get_dynamic_max_stocks(context)
|
|
||||||
if len(g.grid_info) < dynamic_max and usage < context.target_usage:
|
|
||||||
try_build_new(context, data)
|
|
||||||
|
|
||||||
# -------------------- 动态上限 --------------------
|
|
||||||
def get_dynamic_max_stocks(context):
|
|
||||||
return context.max_stocks if g.first_round_done else context.first_round_max
|
|
||||||
|
|
||||||
# -------------------- 建仓 --------------------
|
|
||||||
def try_build_new(context, data):
|
|
||||||
position_pct = context.max_position_pct
|
|
||||||
dynamic_max = get_dynamic_max_stocks(context)
|
|
||||||
count = 0
|
|
||||||
for stock in list(g.monitoring_stocks):
|
|
||||||
if len(g.grid_info) >= dynamic_max or count >= 3:
|
|
||||||
break
|
|
||||||
|
|
||||||
price = data[stock].close
|
|
||||||
if context.grid_base_min <= price <= context.grid_base_max:
|
|
||||||
total_value = context.portfolio.total_value
|
|
||||||
stock_amount = total_value * position_pct
|
|
||||||
grid = GridInfo(price, stock_amount, context.max_layers, context.grid_interval, context.profit_target)
|
|
||||||
layer_amount = grid.get_layer_amount(0)
|
|
||||||
buy_amount = int(layer_amount / price / 100) * 100
|
|
||||||
if buy_amount > 0:
|
|
||||||
order(stock, buy_amount)
|
|
||||||
grid.add_position(price, buy_amount, 0)
|
|
||||||
g.grid_info[stock] = grid
|
|
||||||
g.monitoring_stocks.discard(stock)
|
|
||||||
count += 1
|
|
||||||
log.info(f"[建仓] {stock} 价格{price:.2f} 数量{buy_amount}")
|
|
||||||
|
|
||||||
# -------------------- 管理持仓 --------------------
|
|
||||||
def manage_positions(context, data):
|
|
||||||
for stock, grid in list(g.grid_info.items()):
|
|
||||||
|
|
||||||
price = data[stock].close
|
|
||||||
|
|
||||||
# 止盈
|
|
||||||
sellable = grid.get_sellable_positions(price)
|
|
||||||
if sellable:
|
|
||||||
for idx, pos in reversed(sellable):
|
|
||||||
order(stock, -pos['amount'])
|
|
||||||
grid.remove_position(idx)
|
|
||||||
profit = (price - pos['price']) * pos['amount']
|
|
||||||
log.info(f"[止盈] {stock} 盈利{profit:.2f}")
|
|
||||||
|
|
||||||
# 加仓
|
|
||||||
layer = grid.should_add_layer(price)
|
|
||||||
if layer is not None:
|
|
||||||
layer_amount = grid.get_layer_amount(layer)
|
|
||||||
buy_amount = int(layer_amount / price / 100) * 100
|
|
||||||
if buy_amount > 0:
|
|
||||||
order(stock, buy_amount)
|
|
||||||
grid.add_position(price, buy_amount, layer)
|
|
||||||
log.info(f"[加仓] {stock} 层级{layer} 数量{buy_amount}")
|
|
||||||
else:
|
|
||||||
log.info(f"[加仓失败] {stock} 层级{layer} 金额不足")
|
|
||||||
|
|
||||||
# 清仓
|
|
||||||
if len(grid.positions) == 0:
|
|
||||||
del g.grid_info[stock]
|
|
||||||
g.monitoring_stocks.add(stock)
|
|
||||||
log.info(f"[清仓] {stock}")
|
|
||||||
|
|
||||||
# ==================== 盘后 ====================
|
|
||||||
def after_trading_end(context):
|
|
||||||
log.info(f"持仓数:{len(g.grid_info)},监控数:{len(g.monitoring_stocks)}")
|
|
||||||
|
|
||||||
# ==================== 网格类 ====================
|
|
||||||
class GridInfo:
|
|
||||||
def __init__(self, base_price, total_amount, max_layers, interval, profit_target):
|
|
||||||
self.base_price = float(base_price)
|
|
||||||
self.total_amount = float(total_amount)
|
|
||||||
self.max_layers = int(max_layers)
|
|
||||||
self.interval = float(interval)
|
|
||||||
self.profit_target = float(profit_target)
|
|
||||||
self.layer_prices = {i: base_price - i * interval for i in range(self.max_layers)}
|
|
||||||
self.layer_weights = self._calc_weights()
|
|
||||||
self.positions = []
|
|
||||||
|
|
||||||
def _calc_weights(self):
|
|
||||||
weights = {i: 1.0 + 0.05 * i for i in range(self.max_layers)}
|
|
||||||
total = sum(list(weights.values()))
|
|
||||||
return {k: v / total for k, v in weights.items()}
|
|
||||||
|
|
||||||
def get_layer_amount(self, layer):
|
|
||||||
return self.total_amount * self.layer_weights[layer]
|
|
||||||
|
|
||||||
def add_position(self, price, amount, layer):
|
|
||||||
self.positions.append({'price': price, 'amount': amount, 'layer': layer})
|
|
||||||
|
|
||||||
def get_sellable_positions(self, current_price):
|
|
||||||
return [(i, p) for i, p in enumerate(self.positions) if current_price >= p['price'] + self.profit_target]
|
|
||||||
|
|
||||||
def remove_position(self, index):
|
|
||||||
return self.positions.pop(index)
|
|
||||||
|
|
||||||
def should_add_layer(self, current_price):
|
|
||||||
for layer in range(self.max_layers):
|
|
||||||
target = self.layer_prices[layer]
|
|
||||||
diff = abs(current_price - target)
|
|
||||||
|
|
||||||
# 获取该层级的所有持仓
|
|
||||||
layer_positions = [p for p in self.positions if p['layer'] == layer]
|
|
||||||
has_position = len(layer_positions) > 0
|
|
||||||
|
|
||||||
if diff <= 0.1 and not has_position:
|
|
||||||
return layer
|
|
||||||
return None
|
|
||||||
@@ -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元区间股票
|
|
||||||
@@ -0,0 +1,727 @@
|
|||||||
|
date,cash,holding_market_value,total_asset
|
||||||
|
2023-05-04,28289.86,33283.08,61572.94
|
||||||
|
2023-05-05,29353.86,32311.96,61665.82
|
||||||
|
2023-05-08,29553.86,32215.16,61769.020000000004
|
||||||
|
2023-05-09,27953.86,32879.84,60833.7
|
||||||
|
2023-05-10,26153.86,35374.479999999996,61528.34
|
||||||
|
2023-05-11,29753.86,33225.380000000005,62979.240000000005
|
||||||
|
2023-05-12,24353.86,37200.04,61553.9
|
||||||
|
2023-05-15,25667.86,35345.6,61013.46
|
||||||
|
2023-05-16,20867.86,38643.44,59511.3
|
||||||
|
2023-05-17,17667.86,42281.4,59949.26
|
||||||
|
2023-05-18,17667.86,43420.1,61087.96
|
||||||
|
2023-05-19,19999.86,40128.84,60128.7
|
||||||
|
2023-05-22,19136.34,40407.96,59544.3
|
||||||
|
2023-05-23,19136.34,39661.66,58798.0
|
||||||
|
2023-05-24,19136.34,39738.92,58875.259999999995
|
||||||
|
2023-05-25,17736.34,40531.479999999996,58267.81999999999
|
||||||
|
2023-05-26,17736.34,41098.4,58834.740000000005
|
||||||
|
2023-05-29,14136.34,44736.28,58872.619999999995
|
||||||
|
2023-05-30,14336.34,45939.42,60275.759999999995
|
||||||
|
2023-05-31,14336.34,45642.6,59978.94
|
||||||
|
2023-06-01,20433.92,41484.86,61918.78
|
||||||
|
2023-06-02,22233.92,40253.44,62487.36
|
||||||
|
2023-06-05,20683.899999999998,42477.36,63161.259999999995
|
||||||
|
2023-06-06,24283.899999999998,37810.38,62094.28
|
||||||
|
2023-06-07,22683.899999999998,40119.560000000005,62803.46000000001
|
||||||
|
2023-06-08,21083.899999999998,40887.58,61971.479999999996
|
||||||
|
2023-06-09,23083.899999999998,39432.4,62516.3
|
||||||
|
2023-06-12,23083.899999999998,39468.780000000006,62552.68000000001
|
||||||
|
2023-06-13,23083.899999999998,40270.58,63354.479999999996
|
||||||
|
2023-06-14,24883.899999999998,38792.08,63675.979999999996
|
||||||
|
2023-06-15,26883.899999999998,36531.32000000001,63415.22
|
||||||
|
2023-06-16,27171.899999999998,36662.04,63833.94
|
||||||
|
2023-06-19,30577.359999999997,33518.72,64096.08
|
||||||
|
2023-06-20,28977.359999999997,35500.700000000004,64478.06
|
||||||
|
2023-06-21,27177.359999999997,35306.82,62484.17999999999
|
||||||
|
2023-06-26,25315.46,35218.7,60534.159999999996
|
||||||
|
2023-06-27,20515.46,40464.58,60980.04
|
||||||
|
2023-06-28,20515.46,39961.2,60476.659999999996
|
||||||
|
2023-06-29,20515.46,40167.38,60682.84
|
||||||
|
2023-06-30,20515.46,40323.66,60839.12
|
||||||
|
2023-07-03,28555.76,32259.06,60814.82
|
||||||
|
2023-07-04,28555.76,32140.9,60696.66
|
||||||
|
2023-07-05,28555.76,31760.359999999997,60316.119999999995
|
||||||
|
2023-07-06,28555.76,32029.4,60585.16
|
||||||
|
2023-07-07,27155.76,33151.0,60306.759999999995
|
||||||
|
2023-07-10,30335.76,30607.5,60943.259999999995
|
||||||
|
2023-07-11,30335.76,29987.239999999998,60323.0
|
||||||
|
2023-07-12,27135.76,32715.800000000003,59851.56
|
||||||
|
2023-07-13,28735.76,32062.980000000003,60798.740000000005
|
||||||
|
2023-07-14,29091.76,31705.800000000003,60797.56
|
||||||
|
2023-07-17,27259.08,33179.8,60438.880000000005
|
||||||
|
2023-07-18,27259.08,33110.299999999996,60369.38
|
||||||
|
2023-07-19,27259.08,33071.82,60330.9
|
||||||
|
2023-07-20,27259.08,32716.16,59975.240000000005
|
||||||
|
2023-07-21,27608.82,32509.1,60117.92
|
||||||
|
2023-07-24,29909.840000000004,30081.24,59991.08
|
||||||
|
2023-07-25,29909.840000000004,30276.039999999997,60185.880000000005
|
||||||
|
2023-07-26,31797.840000000004,27988.0,59785.840000000004
|
||||||
|
2023-07-27,31797.840000000004,27916.859999999997,59714.7
|
||||||
|
2023-07-28,30397.840000000004,29960.780000000002,60358.62000000001
|
||||||
|
2023-07-31,31034.120000000003,29691.679999999997,60725.8
|
||||||
|
2023-08-01,31326.78,29226.62,60553.399999999994
|
||||||
|
2023-08-02,33126.78,27590.860000000004,60717.64
|
||||||
|
2023-08-03,31326.78,29124.66,60451.44
|
||||||
|
2023-08-04,31326.78,29340.219999999998,60667.0
|
||||||
|
2023-08-07,28402.46,32404.219999999998,60806.67999999999
|
||||||
|
2023-08-08,28402.46,32277.72,60680.18
|
||||||
|
2023-08-09,28602.46,31887.659999999996,60490.119999999995
|
||||||
|
2023-08-10,28602.46,32160.539999999997,60763.0
|
||||||
|
2023-08-11,28602.46,31713.8,60316.259999999995
|
||||||
|
2023-08-14,30216.02,30586.119999999995,60802.14
|
||||||
|
2023-08-15,30216.02,30597.48,60813.5
|
||||||
|
2023-08-16,32216.02,28753.199999999997,60969.22
|
||||||
|
2023-08-17,30416.02,31062.44,61478.46
|
||||||
|
2023-08-18,33136.700000000004,28257.34,61394.04000000001
|
||||||
|
2023-08-21,31336.700000000004,30055.36,61392.060000000005
|
||||||
|
2023-08-22,29536.700000000004,32326.899999999998,61863.600000000006
|
||||||
|
2023-08-23,29536.700000000004,31548.5,61085.200000000004
|
||||||
|
2023-08-24,29536.700000000004,31743.98,61280.68000000001
|
||||||
|
2023-08-25,26136.700000000004,34300.62,60437.32000000001
|
||||||
|
2023-08-28,29708.780000000006,31279.519999999997,60988.3
|
||||||
|
2023-08-29,30196.780000000006,32665.699999999997,62862.48
|
||||||
|
2023-08-30,32196.780000000006,31247.879999999997,63444.66
|
||||||
|
2023-08-31,34196.780000000006,29057.66,63254.44
|
||||||
|
2023-09-01,33946.780000000006,29311.820000000003,63258.600000000006
|
||||||
|
2023-09-04,34135.520000000004,29560.14,63695.66
|
||||||
|
2023-09-05,34509.780000000006,29185.22,63695.00000000001
|
||||||
|
2023-09-06,32709.780000000006,30561.239999999998,63271.020000000004
|
||||||
|
2023-09-07,33082.920000000006,30092.64,63175.560000000005
|
||||||
|
2023-09-08,29682.920000000006,33243.98,62926.90000000001
|
||||||
|
2023-09-11,28129.100000000006,35377.44,63506.54000000001
|
||||||
|
2023-09-12,30129.100000000006,33934.32,64063.420000000006
|
||||||
|
2023-09-13,31929.100000000006,32089.72,64018.82000000001
|
||||||
|
2023-09-14,31929.100000000006,31721.36,63650.46000000001
|
||||||
|
2023-09-15,33929.100000000006,29926.239999999998,63855.340000000004
|
||||||
|
2023-09-18,35984.3,28165.4,64149.700000000004
|
||||||
|
2023-09-19,36370.3,27593.84,63964.14
|
||||||
|
2023-09-20,36917.340000000004,27446.239999999998,64363.58
|
||||||
|
2023-09-21,35117.340000000004,29112.16,64229.5
|
||||||
|
2023-09-22,35117.340000000004,29932.44,65049.78
|
||||||
|
2023-09-25,35673.340000000004,30188.22,65861.56
|
||||||
|
2023-09-26,33873.340000000004,31843.26,65716.6
|
||||||
|
2023-09-27,33873.340000000004,32595.0,66468.34
|
||||||
|
2023-09-28,39873.340000000004,27709.4,67582.74
|
||||||
|
2023-10-09,41247.340000000004,26008.479999999996,67255.82
|
||||||
|
2023-10-10,43605.08,24212.319999999996,67817.4
|
||||||
|
2023-10-11,43805.08,23993.4,67798.48000000001
|
||||||
|
2023-10-12,44083.08,23915.480000000003,67998.56
|
||||||
|
2023-10-13,44083.08,23683.52,67766.6
|
||||||
|
2023-10-16,43689.08,23793.599999999995,67482.68
|
||||||
|
2023-10-17,42089.08,25216.94,67306.02
|
||||||
|
2023-10-18,38489.08,28344.4,66833.48000000001
|
||||||
|
2023-10-19,36689.08,29789.940000000002,66479.02
|
||||||
|
2023-10-20,33289.08,32655.699999999997,65944.78
|
||||||
|
2023-10-23,31442.520000000004,33523.66,64966.18000000001
|
||||||
|
2023-10-24,29642.520000000004,36040.58,65683.1
|
||||||
|
2023-10-25,33642.520000000004,33017.58,66660.1
|
||||||
|
2023-10-26,34288.520000000004,33149.32,67437.84
|
||||||
|
2023-10-27,36088.520000000004,31940.920000000002,68029.44
|
||||||
|
2023-10-30,37285.62,32011.34,69296.96
|
||||||
|
2023-10-31,39938.9,29558.3,69497.2
|
||||||
|
2023-11-01,38429.700000000004,30972.719999999994,69402.42
|
||||||
|
2023-11-02,40429.700000000004,28891.5,69321.20000000001
|
||||||
|
2023-11-03,40429.700000000004,29377.8,69807.5
|
||||||
|
2023-11-06,41176.26,29848.48,71024.74
|
||||||
|
2023-11-07,45432.26,26553.34,71985.6
|
||||||
|
2023-11-08,45782.26,26698.7,72480.96
|
||||||
|
2023-11-09,48079.740000000005,24910.94,72990.68000000001
|
||||||
|
2023-11-10,46479.740000000005,25900.26,72380.0
|
||||||
|
2023-11-13,48501.3,24292.92,72794.22
|
||||||
|
2023-11-14,46701.3,26683.64,73384.94
|
||||||
|
2023-11-15,46957.340000000004,26097.679999999997,73055.02
|
||||||
|
2023-11-16,47157.340000000004,26279.88,73437.22
|
||||||
|
2023-11-17,49341.340000000004,24523.559999999998,73864.9
|
||||||
|
2023-11-20,51584.740000000005,23124.5,74709.24
|
||||||
|
2023-11-21,51940.740000000005,22778.399999999998,74719.14
|
||||||
|
2023-11-22,49054.740000000005,26431.32,75486.06
|
||||||
|
2023-11-23,50182.740000000005,25830.2,76012.94
|
||||||
|
2023-11-24,49700.740000000005,27047.760000000002,76748.5
|
||||||
|
2023-11-27,52958.340000000004,24999.68,77958.02
|
||||||
|
2023-11-28,46244.340000000004,30826.699999999997,77071.04000000001
|
||||||
|
2023-11-29,41844.340000000004,33468.94,75313.28
|
||||||
|
2023-11-30,37244.340000000004,37343.26,74587.6
|
||||||
|
2023-12-01,33892.340000000004,39456.38,73348.72
|
||||||
|
2023-12-04,29451.340000000004,43211.36,72662.70000000001
|
||||||
|
2023-12-05,36811.340000000004,40589.1,77400.44
|
||||||
|
2023-12-06,43811.340000000004,34448.659999999996,78260.0
|
||||||
|
2023-12-07,49721.340000000004,30807.46,80528.8
|
||||||
|
2023-12-08,50831.340000000004,30194.3,81025.64
|
||||||
|
2023-12-11,41721.94,37976.44,79698.38
|
||||||
|
2023-12-12,41967.94,38844.28,80812.22
|
||||||
|
2023-12-13,41967.94,40205.56,82173.5
|
||||||
|
2023-12-14,48057.94,35801.66,83859.6
|
||||||
|
2023-12-15,52387.94,31884.0,84271.94
|
||||||
|
2023-12-18,54779.94,31022.0,85801.94
|
||||||
|
2023-12-19,56379.94,28964.0,85343.94
|
||||||
|
2023-12-20,58379.94,28260.0,86639.94
|
||||||
|
2023-12-21,60909.94,24672.0,85581.94
|
||||||
|
2023-12-22,52909.94,32946.0,85855.94
|
||||||
|
2023-12-25,54909.94,32782.0,87691.94
|
||||||
|
2023-12-26,54909.94,33254.0,88163.94
|
||||||
|
2023-12-27,57197.94,31222.0,88419.94
|
||||||
|
2023-12-28,55761.94,33084.0,88845.94
|
||||||
|
2023-12-29,56387.94,33956.0,90343.94
|
||||||
|
2024-01-02,62813.94,29938.0,92751.94
|
||||||
|
2024-01-03,65205.94,27608.0,92813.94
|
||||||
|
2024-01-04,63405.94,29272.0,92677.94
|
||||||
|
2024-01-05,56405.94,34602.0,91007.94
|
||||||
|
2024-01-08,58405.94,31830.0,90235.94
|
||||||
|
2024-01-09,48405.94,40204.0,88609.94
|
||||||
|
2024-01-10,43605.94,45832.0,89437.94
|
||||||
|
2024-01-11,45405.94,44096.0,89501.94
|
||||||
|
2024-01-12,42405.94,42438.0,84843.94
|
||||||
|
2024-01-15,35605.94,46922.0,82527.94
|
||||||
|
2024-01-16,34005.94,52684.0,86689.94
|
||||||
|
2024-01-17,37205.94,49538.0,86743.94
|
||||||
|
2024-01-18,39005.94,49904.0,88909.94
|
||||||
|
2024-01-19,44405.94,46458.0,90863.94
|
||||||
|
2024-01-22,45355.94,42936.0,88291.94
|
||||||
|
2024-01-23,45355.94,44114.0,89469.94
|
||||||
|
2024-01-24,47895.94,42334.0,90229.94
|
||||||
|
2024-01-25,46695.94,42518.0,89213.94
|
||||||
|
2024-01-26,43543.94,44782.0,88325.94
|
||||||
|
2024-01-29,39113.94,44472.0,83585.94
|
||||||
|
2024-01-30,33513.94,50754.0,84267.94
|
||||||
|
2024-01-31,40113.94,46114.0,86227.94
|
||||||
|
2024-02-01,38705.94,44274.0,82979.94
|
||||||
|
2024-02-02,27305.940000000002,52690.0,79995.94
|
||||||
|
2024-02-05,22445.940000000002,55230.0,77675.94
|
||||||
|
2024-02-06,27245.940000000002,56198.0,83443.94
|
||||||
|
2024-02-07,36845.94,46666.0,83511.94
|
||||||
|
2024-02-08,31419.940000000002,51926.0,83345.94
|
||||||
|
2024-02-19,36311.94,48834.0,85145.94
|
||||||
|
2024-02-20,39861.28,47910.66,87771.94
|
||||||
|
2024-02-21,39861.28,48018.96,87880.23999999999
|
||||||
|
2024-02-22,41861.28,45818.24,87679.51999999999
|
||||||
|
2024-02-23,41861.28,44551.96,86413.23999999999
|
||||||
|
2024-02-26,45030.24,41690.74,86720.98
|
||||||
|
2024-02-27,48777.32,41789.439999999995,90566.76
|
||||||
|
2024-02-28,49161.3,39334.85999999999,88496.16
|
||||||
|
2024-02-29,45761.3,44599.96,90361.26000000001
|
||||||
|
2024-03-01,49575.3,40919.28,90494.58
|
||||||
|
2024-03-04,52677.94,37579.03999999999,90256.98
|
||||||
|
2024-03-05,51277.94,38087.04,89364.98000000001
|
||||||
|
2024-03-06,53277.94,36919.08,90197.02
|
||||||
|
2024-03-07,53677.94,35949.98,89627.92000000001
|
||||||
|
2024-03-08,53677.94,35737.8,89415.74
|
||||||
|
2024-03-11,57519.94,32557.06,90077.0
|
||||||
|
2024-03-12,59847.920000000006,30947.68,90795.6
|
||||||
|
2024-03-13,59847.920000000006,30429.56,90277.48000000001
|
||||||
|
2024-03-14,60047.920000000006,30268.980000000003,90316.90000000001
|
||||||
|
2024-03-15,60047.920000000006,31026.780000000002,91074.70000000001
|
||||||
|
2024-03-18,62559.920000000006,29269.68,91829.6
|
||||||
|
2024-03-19,62905.560000000005,29223.520000000004,92129.08000000002
|
||||||
|
2024-03-20,65165.560000000005,27477.68,92643.24
|
||||||
|
2024-03-21,65873.84,26703.800000000003,92577.64
|
||||||
|
2024-03-22,65873.84,26076.62,91950.45999999999
|
||||||
|
2024-03-25,61065.36,29863.06,90928.42
|
||||||
|
2024-03-26,59265.36,31568.499999999996,90833.86
|
||||||
|
2024-03-27,55865.36,34504.659999999996,90370.01999999999
|
||||||
|
2024-03-28,60401.36,30693.719999999998,91095.08
|
||||||
|
2024-03-29,60401.36,30781.6,91182.95999999999
|
||||||
|
2024-04-01,66053.02,26153.920000000002,92206.94
|
||||||
|
2024-04-02,66617.02,25795.5,92412.52
|
||||||
|
2024-04-03,65179.58,26641.44,91821.02
|
||||||
|
2024-04-08,63035.58,27350.300000000003,90385.88
|
||||||
|
2024-04-09,61435.58,29304.18,90739.76000000001
|
||||||
|
2024-04-10,56635.58,33710.5,90346.08
|
||||||
|
2024-04-11,56903.58,32962.08,89865.66
|
||||||
|
2024-04-12,56903.58,32507.18,89410.76000000001
|
||||||
|
2024-04-15,45899.58,40168.12,86067.70000000001
|
||||||
|
2024-04-16,36699.58,44627.16,81326.74
|
||||||
|
2024-04-17,33499.58,52978.979999999996,86478.56
|
||||||
|
2024-04-18,33499.58,53139.6,86639.18
|
||||||
|
2024-04-19,33499.58,52126.22,85625.8
|
||||||
|
2024-04-22,35961.58,49286.22,85247.8
|
||||||
|
2024-04-23,39761.58,47887.979999999996,87649.56
|
||||||
|
2024-04-24,41361.58,47785.3,89146.88
|
||||||
|
2024-04-25,41161.58,47469.36,88630.94
|
||||||
|
2024-04-26,42761.58,46693.66,89455.24
|
||||||
|
2024-04-29,46142.72,44846.68,90989.4
|
||||||
|
2024-04-30,51342.72,39697.380000000005,91040.1
|
||||||
|
2024-05-06,59221.060000000005,32499.46,91720.52
|
||||||
|
2024-05-07,59421.060000000005,32774.7,92195.76000000001
|
||||||
|
2024-05-08,59805.060000000005,31778.78,91583.84
|
||||||
|
2024-05-09,58005.060000000005,33495.740000000005,91500.80000000002
|
||||||
|
2024-05-10,56205.060000000005,34632.66,90837.72
|
||||||
|
2024-05-13,54405.060000000005,35785.520000000004,90190.58000000002
|
||||||
|
2024-05-14,56405.060000000005,34677.5,91082.56
|
||||||
|
2024-05-15,56791.060000000005,34668.46,91459.52
|
||||||
|
2024-05-16,58591.060000000005,33183.68,91774.74
|
||||||
|
2024-05-17,60869.060000000005,31647.16,92516.22
|
||||||
|
2024-05-20,60891.060000000005,31465.64,92356.70000000001
|
||||||
|
2024-05-21,61091.060000000005,31509.34,92600.40000000001
|
||||||
|
2024-05-22,61405.060000000005,31812.32,93217.38
|
||||||
|
2024-05-23,61405.060000000005,32031.88,93436.94
|
||||||
|
2024-05-24,59605.060000000005,32880.44,92485.5
|
||||||
|
2024-05-27,59387.50000000001,33578.0,92965.5
|
||||||
|
2024-05-28,63387.50000000001,29672.0,93059.5
|
||||||
|
2024-05-29,61787.50000000001,31334.0,93121.5
|
||||||
|
2024-05-30,64141.5,28916.0,93057.5
|
||||||
|
2024-05-31,64141.5,29130.0,93271.5
|
||||||
|
2024-06-03,59560.399999999994,32518.96,92079.35999999999
|
||||||
|
2024-06-04,57760.399999999994,33912.6,91673.0
|
||||||
|
2024-06-05,56160.399999999994,34984.96,91145.35999999999
|
||||||
|
2024-06-06,47960.399999999994,40566.12,88526.51999999999
|
||||||
|
2024-06-07,47960.399999999994,42130.56,90090.95999999999
|
||||||
|
2024-06-11,50552.399999999994,40529.64,91082.04
|
||||||
|
2024-06-12,54152.399999999994,37975.64,92128.04
|
||||||
|
2024-06-13,56152.399999999994,36528.12,92680.51999999999
|
||||||
|
2024-06-14,56152.399999999994,36035.36,92187.76
|
||||||
|
2024-06-17,58145.759999999995,33718.0,91863.76
|
||||||
|
2024-06-18,60345.759999999995,32524.0,92869.76
|
||||||
|
2024-06-19,64277.759999999995,29616.0,93893.76
|
||||||
|
2024-06-20,59921.759999999995,33686.0,93607.76
|
||||||
|
2024-06-21,62239.759999999995,31984.0,94223.76
|
||||||
|
2024-06-24,56927.759999999995,35350.0,92277.76
|
||||||
|
2024-06-25,53927.759999999995,37340.0,91267.76
|
||||||
|
2024-06-26,52527.759999999995,40366.0,92893.76
|
||||||
|
2024-06-27,52527.759999999995,39326.0,91853.76
|
||||||
|
2024-06-28,52527.759999999995,40070.0,92597.76
|
||||||
|
2024-07-01,57551.759999999995,35500.0,93051.76
|
||||||
|
2024-07-02,57551.759999999995,36244.0,93795.76
|
||||||
|
2024-07-03,57551.759999999995,36146.0,93697.76
|
||||||
|
2024-07-04,55751.759999999995,36268.0,92019.76
|
||||||
|
2024-07-05,52351.759999999995,40466.0,92817.76
|
||||||
|
2024-07-08,53531.759999999995,38242.0,91773.76
|
||||||
|
2024-07-09,52891.759999999995,40390.0,93281.76
|
||||||
|
2024-07-10,51451.719999999994,41648.04,93099.76
|
||||||
|
2024-07-11,51451.719999999994,42945.86,94397.57999999999
|
||||||
|
2024-07-12,51451.719999999994,42662.64,94114.35999999999
|
||||||
|
2024-07-15,52778.87999999999,40392.92,93171.79999999999
|
||||||
|
2024-07-16,52778.87999999999,41218.92,93997.79999999999
|
||||||
|
2024-07-17,57090.87999999999,36641.42,93732.29999999999
|
||||||
|
2024-07-18,55290.87999999999,38284.96,93575.84
|
||||||
|
2024-07-19,55290.87999999999,38665.44,93956.31999999999
|
||||||
|
2024-07-22,60996.87999999999,34089.5,95086.37999999999
|
||||||
|
2024-07-23,60996.87999999999,33558.479999999996,94555.35999999999
|
||||||
|
2024-07-24,62796.87999999999,31740.760000000002,94537.63999999998
|
||||||
|
2024-07-25,61256.87999999999,33841.24,95098.12
|
||||||
|
2024-07-26,61456.87999999999,33980.7,95437.57999999999
|
||||||
|
2024-07-29,59777.73999999999,35923.119999999995,95700.85999999999
|
||||||
|
2024-07-30,61777.73999999999,34529.72,96307.45999999999
|
||||||
|
2024-07-31,64101.73999999999,33334.0,97435.73999999999
|
||||||
|
2024-08-01,65125.73999999999,32018.0,97143.73999999999
|
||||||
|
2024-08-02,61925.73999999999,34468.0,96393.73999999999
|
||||||
|
2024-08-05,62605.73999999999,32892.0,95497.73999999999
|
||||||
|
2024-08-06,62605.73999999999,34194.0,96799.73999999999
|
||||||
|
2024-08-07,64405.73999999999,32874.0,97279.73999999999
|
||||||
|
2024-08-08,64605.73999999999,31938.0,96543.73999999999
|
||||||
|
2024-08-09,63005.73999999999,33154.0,96159.73999999999
|
||||||
|
2024-08-12,61315.73999999999,34852.0,96167.73999999999
|
||||||
|
2024-08-13,59515.73999999999,36410.0,95925.73999999999
|
||||||
|
2024-08-14,59515.73999999999,35968.0,95483.73999999999
|
||||||
|
2024-08-15,58515.73999999999,37140.0,95655.73999999999
|
||||||
|
2024-08-16,56715.73999999999,39058.0,95773.73999999999
|
||||||
|
2024-08-19,56764.19999999999,38072.64,94836.84
|
||||||
|
2024-08-20,56764.19999999999,38040.52,94804.71999999999
|
||||||
|
2024-08-21,55164.19999999999,38808.92,93973.12
|
||||||
|
2024-08-22,53564.19999999999,39192.04,92756.23999999999
|
||||||
|
2024-08-23,55364.19999999999,37672.44,93036.63999999998
|
||||||
|
2024-08-26,56940.25999999999,36384.899999999994,93325.15999999997
|
||||||
|
2024-08-27,57540.25999999999,35327.28,92867.53999999998
|
||||||
|
2024-08-28,52940.25999999999,39837.560000000005,92777.81999999999
|
||||||
|
2024-08-29,51892.25999999999,41458.62,93350.87999999999
|
||||||
|
2024-08-30,52183.51999999999,42771.06,94954.57999999999
|
||||||
|
2024-09-02,56710.33999999999,37626.06,94336.4
|
||||||
|
2024-09-03,58710.33999999999,36281.9,94992.23999999999
|
||||||
|
2024-09-04,58710.33999999999,35629.16,94339.5
|
||||||
|
2024-09-05,58710.33999999999,36421.92,95132.25999999998
|
||||||
|
2024-09-06,58710.33999999999,35878.58,94588.91999999998
|
||||||
|
2024-09-09,58906.33999999999,35322.54000000001,94228.88
|
||||||
|
2024-09-10,57306.33999999999,37236.6,94542.93999999999
|
||||||
|
2024-09-11,57306.33999999999,36978.36,94284.69999999998
|
||||||
|
2024-09-12,62642.33999999999,31378.359999999997,94020.69999999998
|
||||||
|
2024-09-13,62642.33999999999,30741.5,93383.84
|
||||||
|
2024-09-18,58008.74,34825.06,92833.79999999999
|
||||||
|
2024-09-19,58359.32,35364.84,93724.16
|
||||||
|
2024-09-20,58359.32,34882.94,93242.26000000001
|
||||||
|
2024-09-23,59825.32,33529.54,93354.86
|
||||||
|
2024-09-24,59825.32,35009.0,94834.32
|
||||||
|
2024-09-25,62217.32,33031.08,95248.4
|
||||||
|
2024-09-26,62723.32000000001,33992.1,96715.42000000001
|
||||||
|
2024-09-27,69235.32,29121.08,98356.40000000001
|
||||||
|
2024-09-30,80349.32,21369.96,101719.28
|
||||||
|
2024-10-08,85077.28,19476.0,104553.28
|
||||||
|
2024-10-09,77483.28,25692.0,103175.28
|
||||||
|
2024-10-10,65483.28,35852.0,101335.28
|
||||||
|
2024-10-11,56083.28,43112.0,99195.28
|
||||||
|
2024-10-14,54845.28,47550.0,102395.28
|
||||||
|
2024-10-15,62245.28,40032.0,102277.28
|
||||||
|
2024-10-16,64429.28,38264.0,102693.28
|
||||||
|
2024-10-17,71101.28,33832.0,104933.28
|
||||||
|
2024-10-18,71101.28,35670.0,106771.28
|
||||||
|
2024-10-21,81899.28,27310.0,109209.28
|
||||||
|
2024-10-22,78499.28,29890.0,108389.28
|
||||||
|
2024-10-23,79145.28,31210.0,110355.28
|
||||||
|
2024-10-24,83457.28,27304.0,110761.28
|
||||||
|
2024-10-25,87055.28,25324.0,112379.28
|
||||||
|
2024-10-28,82369.28,32322.0,114691.28
|
||||||
|
2024-10-29,94723.28,21330.0,116053.28
|
||||||
|
2024-10-30,93926.66,22730.62,116657.28
|
||||||
|
2024-10-31,92874.66,23630.56,116505.22
|
||||||
|
2024-11-01,90986.66,26929.52,117916.18000000001
|
||||||
|
2024-11-04,88286.66,31429.6,119716.26000000001
|
||||||
|
2024-11-05,99332.66,22827.28,122159.94
|
||||||
|
2024-11-06,100274.82,22557.2,122832.02
|
||||||
|
2024-11-07,97836.82,26869.02,124705.84000000001
|
||||||
|
2024-11-08,100636.82,23667.66,124304.48000000001
|
||||||
|
2024-11-11,97436.82,27560.78,124997.6
|
||||||
|
2024-11-12,97436.82,27386.06,124822.88
|
||||||
|
2024-11-13,95636.82,29058.3,124695.12000000001
|
||||||
|
2024-11-14,97636.82,26820.22,124457.04000000001
|
||||||
|
2024-11-15,96557.20000000001,28499.719999999998,125056.92000000001
|
||||||
|
2024-11-18,99403.46,26804.239999999998,126207.70000000001
|
||||||
|
2024-11-19,87403.46,38321.020000000004,125724.48000000001
|
||||||
|
2024-11-20,91665.46,36476.68,128142.14000000001
|
||||||
|
2024-11-21,97525.46,31670.04,129195.5
|
||||||
|
2024-11-22,102164.48000000001,26932.02,129096.50000000001
|
||||||
|
2024-11-25,100326.48000000001,29415.52,129742.00000000001
|
||||||
|
2024-11-26,98726.48000000001,30748.68,129475.16
|
||||||
|
2024-11-27,97126.48000000001,32619.879999999997,129746.36000000002
|
||||||
|
2024-11-28,101950.1,29081.36,131031.46
|
||||||
|
2024-11-29,101950.1,29600.280000000002,131550.38
|
||||||
|
2024-12-02,108792.78,23990.58,132783.36
|
||||||
|
2024-12-03,105192.78,27452.98,132645.76
|
||||||
|
2024-12-04,103392.78,27856.059999999998,131248.84
|
||||||
|
2024-12-05,103392.78,28402.48,131795.26
|
||||||
|
2024-12-06,103392.78,28411.32,131804.1
|
||||||
|
2024-12-09,97964.12,32072.58,130036.7
|
||||||
|
2024-12-10,96164.12,34057.399999999994,130221.51999999999
|
||||||
|
2024-12-11,94564.12,35279.64,129843.76
|
||||||
|
2024-12-12,94844.12,35000.0,129844.12
|
||||||
|
2024-12-13,96844.12,33198.0,130042.12
|
||||||
|
2024-12-16,92244.12,37306.0,129550.12
|
||||||
|
2024-12-17,90444.12,36824.0,127268.12
|
||||||
|
2024-12-18,85644.12,41096.0,126740.12
|
||||||
|
2024-12-19,82444.12,45412.0,127856.12
|
||||||
|
2024-12-20,82444.12,44764.0,127208.12
|
||||||
|
2024-12-23,86146.12,38902.0,125048.12
|
||||||
|
2024-12-24,88234.12,37016.0,125250.12
|
||||||
|
2024-12-25,83634.12,39390.0,123024.12
|
||||||
|
2024-12-26,85918.12,38396.0,124314.12
|
||||||
|
2024-12-27,89318.12,35982.0,125300.12
|
||||||
|
2024-12-30,84450.12,39132.0,123582.12
|
||||||
|
2024-12-31,86250.12,38410.0,124660.12
|
||||||
|
2025-01-02,85608.12,39822.0,125430.12
|
||||||
|
2025-01-03,87408.12,37578.0,124986.12
|
||||||
|
2025-01-06,87180.12,36526.0,123706.12
|
||||||
|
2025-01-07,85580.12,39196.0,124776.12
|
||||||
|
2025-01-08,85580.12,39276.0,124856.12
|
||||||
|
2025-01-09,85580.12,40108.0,125688.12
|
||||||
|
2025-01-10,85580.12,38096.0,123676.12
|
||||||
|
2025-01-13,86472.12,36632.0,123104.12
|
||||||
|
2025-01-14,90534.12,35542.0,126076.12
|
||||||
|
2025-01-15,92964.12,33396.0,126360.12
|
||||||
|
2025-01-16,92964.12,33474.0,126438.12
|
||||||
|
2025-01-17,92964.12,32718.0,125682.12
|
||||||
|
2025-01-20,92444.12,33334.0,125778.12
|
||||||
|
2025-01-21,90644.12,34562.0,125206.12
|
||||||
|
2025-01-22,88844.12,35200.0,124044.12
|
||||||
|
2025-01-23,92964.12,31104.0,124068.12
|
||||||
|
2025-01-24,92964.12,30942.0,123906.12
|
||||||
|
2025-01-27,93264.12,29920.0,123184.12
|
||||||
|
2025-02-05,95550.12,28902.0,124452.12
|
||||||
|
2025-02-06,95386.12,30334.0,125720.12
|
||||||
|
2025-02-07,99682.12,27098.0,126780.12
|
||||||
|
2025-02-10,101682.12,26282.0,127964.12
|
||||||
|
2025-02-11,102255.36,25700.760000000002,127956.12
|
||||||
|
2025-02-12,106503.36,22042.74,128546.1
|
||||||
|
2025-02-13,106503.36,21696.8,128200.16
|
||||||
|
2025-02-14,106503.36,21418.78,127922.14
|
||||||
|
2025-02-17,106503.36,22392.739999999998,128896.1
|
||||||
|
2025-02-18,106863.36,21594.6,128457.95999999999
|
||||||
|
2025-02-19,107549.72,21399.64,128949.36
|
||||||
|
2025-02-20,105749.72,23389.28,129139.0
|
||||||
|
2025-02-21,105749.72,23782.4,129532.12
|
||||||
|
2025-02-24,107749.72,22060.94,129810.66
|
||||||
|
2025-02-25,106050.72,23940.18,129990.9
|
||||||
|
2025-02-26,106050.72,24078.079999999998,130128.8
|
||||||
|
2025-02-27,104528.72,25574.48,130103.2
|
||||||
|
2025-02-28,102728.72,26542.96,129271.68
|
||||||
|
2025-03-03,99240.72,30762.88,130003.6
|
||||||
|
2025-03-04,104273.36,27485.84,131759.2
|
||||||
|
2025-03-05,106519.36,25571.420000000002,132090.78
|
||||||
|
2025-03-06,103171.36,29194.32,132365.68
|
||||||
|
2025-03-07,105425.36,27040.4,132465.76
|
||||||
|
2025-03-10,105425.36,27684.64,133110.0
|
||||||
|
2025-03-11,105757.36,27476.72,133234.08000000002
|
||||||
|
2025-03-12,107757.36,26166.48,133923.84
|
||||||
|
2025-03-13,110301.82,23848.38,134150.2
|
||||||
|
2025-03-14,115013.82,19782.36,134796.18
|
||||||
|
2025-03-17,115373.82,19713.06,135086.88
|
||||||
|
2025-03-18,116287.82,19132.559999999998,135420.38
|
||||||
|
2025-03-19,112687.82,22446.72,135134.54
|
||||||
|
2025-03-20,111223.82,24020.72,135244.54
|
||||||
|
2025-03-21,109423.82,25211.120000000003,134634.94
|
||||||
|
2025-03-24,102104.94,32562.0,134666.94
|
||||||
|
2025-03-25,104460.1,30420.84,134880.94
|
||||||
|
2025-03-26,106460.1,29221.84,135681.94
|
||||||
|
2025-03-27,106660.1,28888.8,135548.9
|
||||||
|
2025-03-28,103822.1,31484.3,135306.4
|
||||||
|
2025-03-31,100434.62000000001,34067.08,134501.7
|
||||||
|
2025-04-01,101400.62000000001,32980.64,134381.26
|
||||||
|
2025-04-02,101600.62000000001,32563.239999999998,134163.86000000002
|
||||||
|
2025-04-03,99800.62000000001,34518.32,134318.94
|
||||||
|
2025-04-07,87000.62000000001,41837.2,128837.82
|
||||||
|
2025-04-08,80800.62000000001,49853.04,130653.66
|
||||||
|
2025-04-09,88600.62000000001,46010.08,134610.7
|
||||||
|
2025-04-10,98000.62000000001,38655.6,136656.22
|
||||||
|
2025-04-11,99600.62000000001,37063.76,136664.38
|
||||||
|
2025-04-14,106254.62000000001,31782.72,138037.34000000003
|
||||||
|
2025-04-15,104654.62000000001,33317.28,137971.90000000002
|
||||||
|
2025-04-16,106747.54000000001,31677.72,138425.26
|
||||||
|
2025-04-17,108747.54000000001,30180.46,138928.0
|
||||||
|
2025-04-18,109341.54000000001,30561.36,139902.90000000002
|
||||||
|
2025-04-21,107815.54000000001,32425.2,140240.74000000002
|
||||||
|
2025-04-22,107815.54000000001,31336.8,139152.34
|
||||||
|
2025-04-23,109415.54000000001,30456.64,139872.18
|
||||||
|
2025-04-24,109657.54000000001,29465.66,139123.2
|
||||||
|
2025-04-25,106835.54000000001,32281.18,139116.72
|
||||||
|
2025-04-28,106345.54000000001,32082.7,138428.24000000002
|
||||||
|
2025-04-29,102745.54000000001,35483.0,138228.54
|
||||||
|
2025-04-30,102745.54000000001,35759.3,138504.84000000003
|
||||||
|
2025-05-06,104510.84000000001,35040.0,139550.84000000003
|
||||||
|
2025-05-07,107283.84000000001,32649.0,139932.84000000003
|
||||||
|
2025-05-08,104437.84000000001,35792.24,140230.08000000002
|
||||||
|
2025-05-09,102637.84000000001,37585.88,140223.72
|
||||||
|
2025-05-12,103367.84000000001,37894.72,141262.56
|
||||||
|
2025-05-13,103367.84000000001,37302.54,140670.38
|
||||||
|
2025-05-14,103763.84000000001,36662.0,140425.84000000003
|
||||||
|
2025-05-15,101963.84000000001,38680.0,140643.84000000003
|
||||||
|
2025-05-16,101963.84000000001,38900.0,140863.84000000003
|
||||||
|
2025-05-19,101963.84000000001,39840.0,141803.84000000003
|
||||||
|
2025-05-20,105763.84000000001,36696.0,142459.84000000003
|
||||||
|
2025-05-21,107363.84000000001,35314.0,142677.84000000003
|
||||||
|
2025-05-22,109423.84000000001,33250.0,142673.84000000003
|
||||||
|
2025-05-23,109423.84000000001,32758.0,142181.84000000003
|
||||||
|
2025-05-26,110715.84000000001,32416.0,143131.84000000003
|
||||||
|
2025-05-27,114765.84000000001,28756.0,143521.84000000003
|
||||||
|
2025-05-28,118565.84000000001,24386.0,142951.84000000003
|
||||||
|
2025-05-29,118565.84000000001,24558.0,143123.84000000003
|
||||||
|
2025-05-30,115165.84000000001,27606.0,142771.84000000003
|
||||||
|
2025-06-03,113691.88,29479.86,143171.74
|
||||||
|
2025-06-04,117691.88,26375.22,144067.1
|
||||||
|
2025-06-05,118367.88,25461.82,143829.7
|
||||||
|
2025-06-06,118567.88,25523.98,144091.86000000002
|
||||||
|
2025-06-09,118468.06,26145.58,144613.64
|
||||||
|
2025-06-10,119242.06,26013.68,145255.74
|
||||||
|
2025-06-11,115642.06,29543.460000000003,145185.52
|
||||||
|
2025-06-12,119442.06,26731.8,146173.86
|
||||||
|
2025-06-13,121827.45999999999,23271.14,145098.59999999998
|
||||||
|
2025-06-16,118827.45999999999,26558.000000000004,145385.46
|
||||||
|
2025-06-17,119851.45999999999,25974.019999999997,145825.47999999998
|
||||||
|
2025-06-18,118051.45999999999,27032.4,145083.86
|
||||||
|
2025-06-19,112651.45999999999,31251.42,143902.88
|
||||||
|
2025-06-20,109651.45999999999,33717.020000000004,143368.47999999998
|
||||||
|
2025-06-23,108051.45999999999,36004.66,144056.12
|
||||||
|
2025-06-24,108051.45999999999,36450.659999999996,144502.12
|
||||||
|
2025-06-25,108567.45999999999,36190.72,144758.18
|
||||||
|
2025-06-26,108767.45999999999,36286.82,145054.28
|
||||||
|
2025-06-27,108767.45999999999,36246.7,145014.15999999997
|
||||||
|
2025-06-30,108321.45999999999,37252.479999999996,145573.94
|
||||||
|
2025-07-01,115200.18,31736.68,146936.86
|
||||||
|
2025-07-02,117302.28,30046.74,147349.02
|
||||||
|
2025-07-03,117302.28,29892.84,147195.12
|
||||||
|
2025-07-04,117302.28,29383.94,146686.22
|
||||||
|
2025-07-07,117586.28,29861.88,147448.16
|
||||||
|
2025-07-08,117586.28,29913.9,147500.18
|
||||||
|
2025-07-09,117912.28,29733.94,147646.22
|
||||||
|
2025-07-10,118112.28,29558.0,147670.28
|
||||||
|
2025-07-11,116312.28,31418.0,147730.28
|
||||||
|
2025-07-14,114316.28,33108.0,147424.28
|
||||||
|
2025-07-15,112716.28,34186.0,146902.28
|
||||||
|
2025-07-16,114716.28,32426.0,147142.28
|
||||||
|
2025-07-17,114960.2,32414.08,147374.28
|
||||||
|
2025-07-18,113160.2,33946.38,147106.58
|
||||||
|
2025-07-21,116704.2,30680.16,147384.36
|
||||||
|
2025-07-22,116980.59999999999,30577.559999999998,147558.15999999997
|
||||||
|
2025-07-23,116980.59999999999,30195.579999999998,147176.18
|
||||||
|
2025-07-24,115180.59999999999,32133.800000000003,147314.4
|
||||||
|
2025-07-25,115180.59999999999,32153.94,147334.53999999998
|
||||||
|
2025-07-28,115180.59999999999,32243.62,147424.22
|
||||||
|
2025-07-29,115180.59999999999,31921.92,147102.52
|
||||||
|
2025-07-30,115180.59999999999,31987.88,147168.47999999998
|
||||||
|
2025-07-31,113580.59999999999,33084.16,146664.76
|
||||||
|
2025-08-01,113316.59999999999,33357.8,146674.4
|
||||||
|
2025-08-04,116562.59999999999,30359.96,146922.56
|
||||||
|
2025-08-05,114762.59999999999,32258.28,147020.88
|
||||||
|
2025-08-06,114762.59999999999,32168.1,146930.69999999998
|
||||||
|
2025-08-07,114762.59999999999,32379.9,147142.5
|
||||||
|
2025-08-08,119086.59999999999,28390.0,147476.59999999998
|
||||||
|
2025-08-11,119218.59999999999,28752.0,147970.59999999998
|
||||||
|
2025-08-12,117418.59999999999,30502.0,147920.59999999998
|
||||||
|
2025-08-13,121062.59999999999,26722.0,147784.59999999998
|
||||||
|
2025-08-14,117662.59999999999,29492.0,147154.59999999998
|
||||||
|
2025-08-15,119662.59999999999,28798.0,148460.59999999998
|
||||||
|
2025-08-18,123252.59999999999,26458.0,149710.59999999998
|
||||||
|
2025-08-19,122090.59999999999,27948.0,150038.59999999998
|
||||||
|
2025-08-20,120290.59999999999,29984.0,150274.59999999998
|
||||||
|
2025-08-21,126290.59999999999,24298.0,150588.59999999998
|
||||||
|
2025-08-22,126652.59999999999,23932.0,150584.59999999998
|
||||||
|
2025-08-25,126652.59999999999,24162.0,150814.59999999998
|
||||||
|
2025-08-26,126852.59999999999,24104.0,150956.59999999998
|
||||||
|
2025-08-27,123646.59999999999,26804.0,150450.59999999998
|
||||||
|
2025-08-28,118640.59999999999,32296.0,150936.59999999998
|
||||||
|
2025-08-29,116840.59999999999,33900.0,150740.59999999998
|
||||||
|
2025-09-01,122140.01999999999,29354.24,151494.25999999998
|
||||||
|
2025-09-02,120726.01999999999,30304.0,151030.02
|
||||||
|
2025-09-03,117326.01999999999,32868.0,150194.02
|
||||||
|
2025-09-04,114126.01999999999,35582.0,149708.02
|
||||||
|
2025-09-05,114126.01999999999,36400.0,150526.02
|
||||||
|
2025-09-08,114126.01999999999,36670.0,150796.02
|
||||||
|
2025-09-09,114126.01999999999,35764.0,149890.02
|
||||||
|
2025-09-10,114126.01999999999,36264.0,150390.02
|
||||||
|
2025-09-11,116326.01999999999,34852.0,151178.02
|
||||||
|
2025-09-12,116580.01999999999,34638.0,151218.02
|
||||||
|
2025-09-15,116840.01999999999,34224.0,151064.02
|
||||||
|
2025-09-16,116840.01999999999,34266.0,151106.02
|
||||||
|
2025-09-17,116840.01999999999,34016.0,150856.02
|
||||||
|
2025-09-18,115522.01999999999,34918.0,150440.02
|
||||||
|
2025-09-19,115522.01999999999,34548.0,150070.02
|
||||||
|
2025-09-22,116524.01999999999,33574.0,150098.02
|
||||||
|
2025-09-23,111924.01999999999,37476.0,149400.02
|
||||||
|
2025-09-24,112264.01999999999,37360.0,149624.02
|
||||||
|
2025-09-25,112264.01999999999,36962.0,149226.02
|
||||||
|
2025-09-26,112264.01999999999,36744.0,149008.02
|
||||||
|
2025-09-29,116726.01999999999,32582.0,149308.02
|
||||||
|
2025-09-30,116726.01999999999,32560.0,149286.02
|
||||||
|
2025-10-09,118370.01999999999,30188.0,148558.02
|
||||||
|
2025-10-10,116570.01999999999,31992.0,148562.02
|
||||||
|
2025-10-13,112600.01999999999,36462.0,149062.02
|
||||||
|
2025-10-14,112600.01999999999,36110.0,148710.02
|
||||||
|
2025-10-15,112600.01999999999,36570.0,149170.02
|
||||||
|
2025-10-16,114600.01999999999,34026.0,148626.02
|
||||||
|
2025-10-17,113000.01999999999,34894.0,147894.02
|
||||||
|
2025-10-20,112998.01999999999,35922.0,148920.02
|
||||||
|
2025-10-21,114998.01999999999,34854.0,149852.02
|
||||||
|
2025-10-22,114998.01999999999,34606.0,149604.02
|
||||||
|
2025-10-23,113198.01999999999,36532.0,149730.02
|
||||||
|
2025-10-24,113198.01999999999,36844.0,150042.02
|
||||||
|
2025-10-27,116430.01999999999,33332.0,149762.02
|
||||||
|
2025-10-28,116430.01999999999,33280.0,149710.02
|
||||||
|
2025-10-29,116430.01999999999,33934.0,150364.02
|
||||||
|
2025-10-30,116430.01999999999,33806.0,150236.02
|
||||||
|
2025-10-31,116430.01999999999,34148.0,150578.02
|
||||||
|
2025-11-03,121148.01999999999,29008.0,150156.02
|
||||||
|
2025-11-04,120108.01999999999,30110.0,150218.02
|
||||||
|
2025-11-05,120108.01999999999,30552.0,150660.02
|
||||||
|
2025-11-06,118308.01999999999,32286.0,150594.02
|
||||||
|
2025-11-07,118702.01999999999,31342.0,150044.02
|
||||||
|
2025-11-10,118702.01999999999,31688.0,150390.02
|
||||||
|
2025-11-11,123102.01999999999,27790.0,150892.02
|
||||||
|
2025-11-12,118426.01999999999,32408.0,150834.02
|
||||||
|
2025-11-13,119016.01999999999,32752.0,151768.02
|
||||||
|
2025-11-14,119016.01999999999,33214.0,152230.02
|
||||||
|
2025-11-17,123270.01999999999,29114.0,152384.02
|
||||||
|
2025-11-18,119670.01999999999,31876.0,151546.02
|
||||||
|
2025-11-19,118070.01999999999,33268.0,151338.02
|
||||||
|
2025-11-20,116470.01999999999,34108.0,150578.02
|
||||||
|
2025-11-21,109870.01999999999,38562.0,148432.02
|
||||||
|
2025-11-24,109938.01999999999,38512.0,148450.02
|
||||||
|
2025-11-25,111938.01999999999,37762.0,149700.02
|
||||||
|
2025-11-26,111938.01999999999,37170.0,149108.02
|
||||||
|
2025-11-27,111938.01999999999,37172.0,149110.02
|
||||||
|
2025-11-28,111938.01999999999,37834.0,149772.02
|
||||||
|
2025-12-01,115825.68,33942.38,149768.06
|
||||||
|
2025-12-02,115825.68,33994.38,149820.06
|
||||||
|
2025-12-03,117825.68,31736.9,149562.58
|
||||||
|
2025-12-04,112425.68,36213.44,148639.12
|
||||||
|
2025-12-05,109425.68,40135.04,149560.72
|
||||||
|
2025-12-08,112603.68,37374.88,149978.56
|
||||||
|
2025-12-09,116729.68,33392.68,150122.36
|
||||||
|
2025-12-10,117313.68,33230.36,150544.03999999998
|
||||||
|
2025-12-11,115769.68,34872.0,150641.68
|
||||||
|
2025-12-12,115769.68,35290.0,151059.68
|
||||||
|
2025-12-15,118523.68,33424.0,151947.68
|
||||||
|
2025-12-16,118723.68,32706.0,151429.68
|
||||||
|
2025-12-17,111723.68,39646.0,151369.68
|
||||||
|
2025-12-18,111723.68,39308.0,151031.68
|
||||||
|
2025-12-19,110123.68,41948.0,152071.68
|
||||||
|
2025-12-22,116025.68,36470.0,152495.68
|
||||||
|
2025-12-23,116225.68,36004.0,152229.68
|
||||||
|
2025-12-24,115195.68,37596.0,152791.68
|
||||||
|
2025-12-25,115571.68,37138.0,152709.68
|
||||||
|
2025-12-26,115371.68,37724.0,153095.68
|
||||||
|
2025-12-29,118263.68,34576.0,152839.68
|
||||||
|
2025-12-30,116959.68,35302.0,152261.68
|
||||||
|
2025-12-31,115159.68,36376.0,151535.68
|
||||||
|
2026-01-05,114658.76,36630.8,151289.56
|
||||||
|
2026-01-06,113458.76,38219.44,151678.2
|
||||||
|
2026-01-07,113854.76,37808.0,151662.76
|
||||||
|
2026-01-08,112054.76,40598.0,152652.76
|
||||||
|
2026-01-09,113854.76,39384.0,153238.76
|
||||||
|
2026-01-12,114728.76,38852.0,153580.76
|
||||||
|
2026-01-13,120616.76,32848.0,153464.76
|
||||||
|
2026-01-14,121016.76,32938.0,153954.76
|
||||||
|
2026-01-15,121216.76,32146.0,153362.76
|
||||||
|
2026-01-16,117816.76,34904.0,152720.76
|
||||||
|
2026-01-19,118854.76,34260.0,153114.76
|
||||||
|
2026-01-20,117516.76,35734.0,153250.76
|
||||||
|
2026-01-21,120376.76,34212.0,154588.76
|
||||||
|
2026-01-22,120376.76,34816.0,155192.76
|
||||||
|
2026-01-23,120376.76,35548.0,155924.76
|
||||||
|
2026-01-26,119168.76,36470.0,155638.76
|
||||||
|
2026-01-27,119368.76,36470.0,155838.76
|
||||||
|
2026-01-28,119368.76,35508.0,154876.76
|
||||||
|
2026-01-29,117768.76,37012.0,154780.76
|
||||||
|
2026-01-30,118104.76,36686.0,154790.76
|
||||||
|
2026-02-02,123830.76,30870.0,154700.76
|
||||||
|
2026-02-03,123830.76,31876.000000000004,155706.76
|
||||||
|
2026-02-04,123830.76,31492.0,155322.76
|
||||||
|
2026-02-05,123830.76,31724.0,155554.76
|
||||||
|
2026-02-06,123830.76,32184.0,156014.76
|
||||||
|
2026-02-09,124128.76,32940.0,157068.76
|
||||||
|
2026-02-10,130252.76000000001,27476.0,157728.76
|
||||||
|
2026-02-11,128452.76000000001,29178.0,157630.76
|
||||||
|
2026-02-12,130452.76000000001,27600.0,158052.76
|
||||||
|
2026-02-13,130652.76000000001,27454.0,158106.76
|
||||||
|
2026-02-24,129478.76000000001,29166.0,158644.76
|
||||||
|
2026-02-25,129678.76000000001,29674.0,159352.76
|
||||||
|
2026-02-26,129878.76000000001,29368.0,159246.76
|
||||||
|
2026-02-27,128078.76000000001,31448.0,159526.76
|
||||||
|
2026-03-02,126568.76000000001,31928.0,158496.76
|
||||||
|
2026-03-03,123168.76000000001,33314.0,156482.76
|
||||||
|
2026-03-04,121530.76000000001,35378.0,156908.76
|
||||||
|
2026-03-05,123530.76000000001,33996.0,157526.76
|
||||||
|
2026-03-06,121930.76000000001,35786.0,157716.76
|
||||||
|
2026-03-09,120130.76000000001,37128.0,157258.76
|
||||||
|
2026-03-10,122130.76000000001,36208.0,158338.76
|
||||||
|
2026-03-11,122498.76000000001,35428.0,157926.76
|
||||||
|
2026-03-12,122498.76000000001,34782.0,157280.76
|
||||||
|
2026-03-13,122818.76000000001,33916.0,156734.76
|
||||||
|
2026-03-16,119866.98000000001,36893.56,156760.54
|
||||||
|
2026-03-17,120266.98000000001,35880.16,156147.14
|
||||||
|
2026-03-18,120266.98000000001,36240.88,156507.86000000002
|
||||||
|
2026-03-19,115666.98000000001,39534.56,155201.54
|
||||||
|
2026-03-20,114066.98000000001,39770.48,153837.46000000002
|
||||||
|
2026-03-23,112544.98000000001,40740.8,153285.78000000003
|
||||||
|
2026-03-24,111144.98000000001,44227.28,155372.26
|
||||||
|
2026-03-25,113144.98000000001,43183.8,156328.78000000003
|
||||||
|
2026-03-26,114944.98000000001,40874.96,155819.94
|
||||||
|
2026-03-27,114944.98000000001,41492.52,156437.5
|
||||||
|
2026-03-30,118332.98000000001,38077.28,156410.26
|
||||||
|
2026-03-31,118332.98000000001,37634.52,155967.5
|
||||||
|
2026-04-01,121430.98000000001,35512.04,156943.02000000002
|
||||||
|
2026-04-02,121430.98000000001,34988.72,156419.7
|
||||||
|
2026-04-03,118420.98000000001,37343.56,155764.54
|
||||||
|
2026-04-07,117714.98000000001,38003.479999999996,155718.46000000002
|
||||||
|
2026-04-08,117980.98000000001,39241.32,157222.30000000002
|
||||||
|
2026-04-09,117980.98000000001,38689.08,156670.06
|
||||||
|
2026-04-10,118180.98000000001,38458.6,156639.58000000002
|
||||||
|
2026-04-13,116651.58000000002,39420.0,156071.58000000002
|
||||||
|
2026-04-14,115051.58000000002,40588.0,155639.58000000002
|
||||||
|
2026-04-15,114451.58000000002,40568.0,155019.58000000002
|
||||||
|
2026-04-16,113051.58000000002,41612.0,154663.58000000002
|
||||||
|
2026-04-17,111851.58000000002,42540.0,154391.58000000002
|
||||||
|
2026-04-20,111939.58000000002,43314.0,155253.58000000002
|
||||||
|
2026-04-21,111939.58000000002,43070.0,155009.58000000002
|
||||||
|
2026-04-22,111939.58000000002,42160.0,154099.58000000002
|
||||||
|
2026-04-23,110939.58000000002,41660.0,152599.58000000002
|
||||||
|
2026-04-24,109899.58000000002,42320.0,152219.58000000002
|
||||||
|
2026-04-27,113003.58000000002,39480.0,152483.58000000002
|
||||||
|
2026-04-28,112317.58000000002,38134.0,150451.58000000002
|
||||||
|
2026-04-29,113317.58000000002,37898.0,151215.58000000002
|
||||||
|
2026-04-30,111717.58000000002,39436.0,151153.58000000002
|
||||||
|
@@ -0,0 +1,37 @@
|
|||||||
|
date,cash,stock_value,total_value,positions,month
|
||||||
|
2023-05-31,17736.34,41098.4,58834.74,10,2023-05
|
||||||
|
2023-06-30,20515.46,40323.66,60839.12,10,2023-06
|
||||||
|
2023-07-31,30397.84,29960.78,60358.62,9,2023-07
|
||||||
|
2023-08-31,26136.7,34300.62,60437.32,10,2023-08
|
||||||
|
2023-09-30,39873.34,27709.4,67582.74,10,2023-09
|
||||||
|
2023-10-31,36088.52,31940.92,68029.44,10,2023-10
|
||||||
|
2023-11-30,49700.74,27047.76,76748.5,10,2023-11
|
||||||
|
2023-12-31,56387.94,33956.0,90343.94,10,2023-12
|
||||||
|
2024-01-31,43543.94,44782.0,88325.94,10,2024-01
|
||||||
|
2024-02-29,41861.28,44551.96,86413.24,10,2024-02
|
||||||
|
2024-03-31,60401.36,30781.6,91182.96,9,2024-03
|
||||||
|
2024-04-30,51342.72,39697.38,91040.1,10,2024-04
|
||||||
|
2024-05-31,65813.5,29130.0,94943.5,9,2024-05
|
||||||
|
2024-06-30,52527.76,40070.0,92597.76,10,2024-06
|
||||||
|
2024-07-31,61456.88,33980.7,95437.58,10,2024-07
|
||||||
|
2024-08-31,52183.52,42771.06,94954.58,10,2024-08
|
||||||
|
2024-09-30,80349.32,21369.96,101719.28,10,2024-09
|
||||||
|
2024-10-31,87055.28,25324.0,112379.28,10,2024-10
|
||||||
|
2024-11-30,101950.1,29600.28,131550.38,10,2024-11
|
||||||
|
2024-12-31,89318.12,35982.0,125300.12,8,2024-12
|
||||||
|
2025-01-31,95062.12,29920.0,124982.12,9,2025-01
|
||||||
|
2025-02-28,102728.72,26542.96,129271.68,9,2025-02
|
||||||
|
2025-03-31,103822.1,31484.3,135306.4,10,2025-03
|
||||||
|
2025-04-30,102745.54,35759.3,138504.84,10,2025-04
|
||||||
|
2025-05-31,115165.84,27606.0,142771.84,8,2025-05
|
||||||
|
2025-06-30,108767.46,36246.7,145014.16,9,2025-06
|
||||||
|
2025-07-31,115180.6,32153.94,147334.54,10,2025-07
|
||||||
|
2025-08-31,116840.6,33900.0,150740.6,10,2025-08
|
||||||
|
2025-09-30,116726.02,32560.0,149286.02,10,2025-09
|
||||||
|
2025-10-31,116430.02,34148.0,150578.02,10,2025-10
|
||||||
|
2025-11-30,111938.02,37834.0,149772.02,10,2025-11
|
||||||
|
2025-12-31,115159.68,36376.0,151535.68,10,2025-12
|
||||||
|
2026-01-31,118104.76,36686.0,154790.76,10,2026-01
|
||||||
|
2026-02-28,128078.76,31448.0,159526.76,10,2026-02
|
||||||
|
2026-03-31,114944.98,41492.52,156437.5,10,2026-03
|
||||||
|
2026-04-30,111717.58,39436.0,151153.58,10,2026-04
|
||||||
|
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"version": "v6.7r2",
|
||||||
|
"rank_model": "lambdarank (56-dim v6.7)",
|
||||||
|
"backtest_start": "2023-05-01",
|
||||||
|
"backtest_end": "2026-04-30",
|
||||||
|
"n_weeks": 154,
|
||||||
|
"n_stocks_in_pool": 5378,
|
||||||
|
"top_n": 10,
|
||||||
|
"shares_per_grid": 200,
|
||||||
|
"initial_cash": 60000.0,
|
||||||
|
"final_total_value": 151153.58,
|
||||||
|
"total_return_pct": 151.92,
|
||||||
|
"annual_return_pct": 36.1,
|
||||||
|
"annual_sharpe": 1.7404,
|
||||||
|
"max_drawdown_pct": -12.1,
|
||||||
|
"weekly_win_rate_pct": 59.48,
|
||||||
|
"n_trades_buy": 1144,
|
||||||
|
"n_trades_sell": 866,
|
||||||
|
"realized_pnl_total": 95495.58,
|
||||||
|
"n_lifecycles": 431
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,155 @@
|
|||||||
|
date,cash,stock_value,total_value,positions,year
|
||||||
|
2023-05-05,29353.86,32311.96,61665.82,10,2023
|
||||||
|
2023-05-12,24353.86,37200.04,61553.9,10,2023
|
||||||
|
2023-05-19,19999.86,40128.84,60128.7,10,2023
|
||||||
|
2023-05-26,17736.34,41098.4,58834.74,10,2023
|
||||||
|
2023-06-02,22233.92,40253.44,62487.36,10,2023
|
||||||
|
2023-06-09,23083.9,39432.4,62516.3,10,2023
|
||||||
|
2023-06-16,27171.9,36662.04,63833.94,10,2023
|
||||||
|
2023-06-21,27177.36,35306.82,62484.18,10,2023
|
||||||
|
2023-06-30,20515.46,40323.66,60839.12,10,2023
|
||||||
|
2023-07-07,27155.76,33151.0,60306.76,10,2023
|
||||||
|
2023-07-14,29091.76,31705.8,60797.56,10,2023
|
||||||
|
2023-07-21,29114.82,32509.1,61623.92,9,2023
|
||||||
|
2023-07-28,30397.84,29960.78,60358.62,9,2023
|
||||||
|
2023-08-04,31326.78,29340.22,60667.0,10,2023
|
||||||
|
2023-08-11,28602.46,31713.8,60316.26,10,2023
|
||||||
|
2023-08-18,33136.7,28257.34,61394.04,10,2023
|
||||||
|
2023-08-25,26136.7,34300.62,60437.32,10,2023
|
||||||
|
2023-09-01,33946.78,29311.82,63258.6,10,2023
|
||||||
|
2023-09-08,29682.92,33243.98,62926.9,10,2023
|
||||||
|
2023-09-15,33929.1,29926.24,63855.34,10,2023
|
||||||
|
2023-09-22,35117.34,29932.44,65049.78,10,2023
|
||||||
|
2023-09-28,39873.34,27709.4,67582.74,10,2023
|
||||||
|
2023-10-13,44083.08,23683.52,67766.6,10,2023
|
||||||
|
2023-10-20,33289.08,32655.7,65944.78,10,2023
|
||||||
|
2023-10-27,36088.52,31940.92,68029.44,10,2023
|
||||||
|
2023-11-03,40429.7,29377.8,69807.5,10,2023
|
||||||
|
2023-11-10,46479.74,25900.26,72380.0,10,2023
|
||||||
|
2023-11-17,49341.34,24523.56,73864.9,10,2023
|
||||||
|
2023-11-24,49700.74,27047.76,76748.5,10,2023
|
||||||
|
2023-12-01,33892.34,39456.38,73348.72,10,2023
|
||||||
|
2023-12-08,50831.34,30194.3,81025.64,10,2023
|
||||||
|
2023-12-15,52387.94,31884.0,84271.94,10,2023
|
||||||
|
2023-12-22,52909.94,32946.0,85855.94,10,2023
|
||||||
|
2023-12-29,56387.94,33956.0,90343.94,10,2023
|
||||||
|
2024-01-05,56405.94,34602.0,91007.94,10,2024
|
||||||
|
2024-01-12,42405.94,42438.0,84843.94,10,2024
|
||||||
|
2024-01-19,44405.94,46458.0,90863.94,10,2024
|
||||||
|
2024-01-26,43543.94,44782.0,88325.94,10,2024
|
||||||
|
2024-02-02,27305.94,52690.0,79995.94,10,2024
|
||||||
|
2024-02-08,31419.94,51926.0,83345.94,10,2024
|
||||||
|
2024-02-23,41861.28,44551.96,86413.24,10,2024
|
||||||
|
2024-03-01,49575.3,40919.28,90494.58,10,2024
|
||||||
|
2024-03-08,53677.94,35737.8,89415.74,10,2024
|
||||||
|
2024-03-15,60047.92,31026.78,91074.7,10,2024
|
||||||
|
2024-03-22,65873.84,26076.62,91950.46,10,2024
|
||||||
|
2024-03-29,60401.36,30781.6,91182.96,9,2024
|
||||||
|
2024-04-03,66965.58,26641.44,93607.02,9,2024
|
||||||
|
2024-04-12,56903.58,32507.18,89410.76,10,2024
|
||||||
|
2024-04-19,33499.58,52126.22,85625.8,10,2024
|
||||||
|
2024-04-26,42761.58,46693.66,89455.24,10,2024
|
||||||
|
2024-04-30,51342.72,39697.38,91040.1,10,2024
|
||||||
|
2024-05-10,56205.06,34632.66,90837.72,10,2024
|
||||||
|
2024-05-17,60869.06,31647.16,92516.22,10,2024
|
||||||
|
2024-05-24,59605.06,32880.44,92485.5,10,2024
|
||||||
|
2024-05-31,65813.5,29130.0,94943.5,9,2024
|
||||||
|
2024-06-07,47960.4,42130.56,90090.96,10,2024
|
||||||
|
2024-06-14,56152.4,36035.36,92187.76,10,2024
|
||||||
|
2024-06-21,62239.76,31984.0,94223.76,10,2024
|
||||||
|
2024-06-28,52527.76,40070.0,92597.76,10,2024
|
||||||
|
2024-07-05,52351.76,40466.0,92817.76,10,2024
|
||||||
|
2024-07-12,51451.72,42662.64,94114.36,10,2024
|
||||||
|
2024-07-19,55290.88,38665.44,93956.32,10,2024
|
||||||
|
2024-07-26,61456.88,33980.7,95437.58,10,2024
|
||||||
|
2024-08-02,61925.74,34468.0,96393.74,10,2024
|
||||||
|
2024-08-09,63005.74,33154.0,96159.74,10,2024
|
||||||
|
2024-08-16,56715.74,39058.0,95773.74,10,2024
|
||||||
|
2024-08-23,55364.2,37672.44,93036.64,10,2024
|
||||||
|
2024-08-30,52183.52,42771.06,94954.58,10,2024
|
||||||
|
2024-09-06,60506.34,35878.58,96384.92,9,2024
|
||||||
|
2024-09-13,62642.34,30741.5,93383.84,8,2024
|
||||||
|
2024-09-20,58359.32,34882.94,93242.26,10,2024
|
||||||
|
2024-09-27,69235.32,29121.08,98356.4,10,2024
|
||||||
|
2024-09-30,80349.32,21369.96,101719.28,10,2024
|
||||||
|
2024-10-11,56083.28,43112.0,99195.28,10,2024
|
||||||
|
2024-10-18,71101.28,35670.0,106771.28,10,2024
|
||||||
|
2024-10-25,87055.28,25324.0,112379.28,10,2024
|
||||||
|
2024-11-01,90986.66,26929.52,117916.18,10,2024
|
||||||
|
2024-11-08,100636.82,23667.66,124304.48,10,2024
|
||||||
|
2024-11-15,96557.2,28499.72,125056.92,10,2024
|
||||||
|
2024-11-22,102164.48,26932.02,129096.5,10,2024
|
||||||
|
2024-11-29,101950.1,29600.28,131550.38,10,2024
|
||||||
|
2024-12-06,103392.78,28411.32,131804.1,10,2024
|
||||||
|
2024-12-13,96844.12,33198.0,130042.12,10,2024
|
||||||
|
2024-12-20,82444.12,44764.0,127208.12,10,2024
|
||||||
|
2024-12-27,89318.12,35982.0,125300.12,8,2024
|
||||||
|
2025-01-03,87408.12,37578.0,124986.12,10,2025
|
||||||
|
2025-01-10,85580.12,38096.0,123676.12,10,2025
|
||||||
|
2025-01-17,92964.12,32718.0,125682.12,9,2025
|
||||||
|
2025-01-24,97680.12,30942.0,128622.12,8,2025
|
||||||
|
2025-01-27,95062.12,29920.0,124982.12,9,2025
|
||||||
|
2025-02-07,99682.12,27098.0,126780.12,9,2025
|
||||||
|
2025-02-14,106503.36,21418.78,127922.14,9,2025
|
||||||
|
2025-02-21,105749.72,23782.4,129532.12,9,2025
|
||||||
|
2025-02-28,102728.72,26542.96,129271.68,9,2025
|
||||||
|
2025-03-07,105425.36,27040.4,132465.76,10,2025
|
||||||
|
2025-03-14,115013.82,19782.36,134796.18,9,2025
|
||||||
|
2025-03-21,109423.82,25211.12,134634.94,9,2025
|
||||||
|
2025-03-28,103822.1,31484.3,135306.4,10,2025
|
||||||
|
2025-04-03,99800.62,34518.32,134318.94,10,2025
|
||||||
|
2025-04-11,99600.62,37063.76,136664.38,10,2025
|
||||||
|
2025-04-18,109341.54,30561.36,139902.9,10,2025
|
||||||
|
2025-04-25,106835.54,32281.18,139116.72,10,2025
|
||||||
|
2025-04-30,102745.54,35759.3,138504.84,10,2025
|
||||||
|
2025-05-09,102637.84,37585.88,140223.72,10,2025
|
||||||
|
2025-05-16,101963.84,38900.0,140863.84,10,2025
|
||||||
|
2025-05-23,109423.84,32758.0,142181.84,9,2025
|
||||||
|
2025-05-30,115165.84,27606.0,142771.84,8,2025
|
||||||
|
2025-06-06,118567.88,25523.98,144091.86,10,2025
|
||||||
|
2025-06-13,121827.46,23271.14,145098.6,9,2025
|
||||||
|
2025-06-20,109651.46,33717.02,143368.48,9,2025
|
||||||
|
2025-06-27,108767.46,36246.7,145014.16,9,2025
|
||||||
|
2025-07-04,117302.28,29383.94,146686.22,10,2025
|
||||||
|
2025-07-11,116312.28,31418.0,147730.28,10,2025
|
||||||
|
2025-07-18,113160.2,33946.38,147106.58,10,2025
|
||||||
|
2025-07-25,115180.6,32153.94,147334.54,10,2025
|
||||||
|
2025-08-01,113316.6,33357.8,146674.4,10,2025
|
||||||
|
2025-08-08,119086.6,28390.0,147476.6,10,2025
|
||||||
|
2025-08-15,119662.6,28798.0,148460.6,9,2025
|
||||||
|
2025-08-22,126652.6,23932.0,150584.6,10,2025
|
||||||
|
2025-08-29,116840.6,33900.0,150740.6,10,2025
|
||||||
|
2025-09-05,114126.02,36400.0,150526.02,10,2025
|
||||||
|
2025-09-12,116580.02,34638.0,151218.02,10,2025
|
||||||
|
2025-09-19,115522.02,34548.0,150070.02,10,2025
|
||||||
|
2025-09-26,115364.02,36744.0,152108.02,9,2025
|
||||||
|
2025-09-30,116726.02,32560.0,149286.02,10,2025
|
||||||
|
2025-10-10,116570.02,31992.0,148562.02,10,2025
|
||||||
|
2025-10-17,113000.02,34894.0,147894.02,10,2025
|
||||||
|
2025-10-24,113198.02,36844.0,150042.02,10,2025
|
||||||
|
2025-10-31,116430.02,34148.0,150578.02,10,2025
|
||||||
|
2025-11-07,118702.02,31342.0,150044.02,10,2025
|
||||||
|
2025-11-14,119016.02,33214.0,152230.02,10,2025
|
||||||
|
2025-11-21,109870.02,38562.0,148432.02,10,2025
|
||||||
|
2025-11-28,111938.02,37834.0,149772.02,10,2025
|
||||||
|
2025-12-05,109425.68,40135.04,149560.72,10,2025
|
||||||
|
2025-12-12,115769.68,35290.0,151059.68,10,2025
|
||||||
|
2025-12-19,110123.68,41948.0,152071.68,10,2025
|
||||||
|
2025-12-26,115371.68,37724.0,153095.68,10,2025
|
||||||
|
2025-12-31,115159.68,36376.0,151535.68,10,2025
|
||||||
|
2026-01-09,113854.76,39384.0,153238.76,10,2026
|
||||||
|
2026-01-16,117816.76,34904.0,152720.76,10,2026
|
||||||
|
2026-01-23,120376.76,35548.0,155924.76,10,2026
|
||||||
|
2026-01-30,118104.76,36686.0,154790.76,10,2026
|
||||||
|
2026-02-06,123830.76,32184.0,156014.76,10,2026
|
||||||
|
2026-02-13,130652.76,27454.0,158106.76,10,2026
|
||||||
|
2026-02-27,128078.76,31448.0,159526.76,10,2026
|
||||||
|
2026-03-06,121930.76,35786.0,157716.76,9,2026
|
||||||
|
2026-03-13,122818.76,33916.0,156734.76,9,2026
|
||||||
|
2026-03-20,114066.98,39770.48,153837.46,10,2026
|
||||||
|
2026-03-27,114944.98,41492.52,156437.5,10,2026
|
||||||
|
2026-04-03,118420.98,37343.56,155764.54,10,2026
|
||||||
|
2026-04-10,118180.98,38458.6,156639.58,10,2026
|
||||||
|
2026-04-17,111851.58,42540.0,154391.58,10,2026
|
||||||
|
2026-04-24,109899.58,42320.0,152219.58,10,2026
|
||||||
|
2026-04-30,111717.58,39436.0,151153.58,10,2026
|
||||||
|
Binary file not shown.
@@ -0,0 +1,57 @@
|
|||||||
|
{
|
||||||
|
"version": "v6.7-rank-lambdarank-mvp",
|
||||||
|
"feature_version": "v3.4",
|
||||||
|
"objective": "lambdarank",
|
||||||
|
"metric": "ndcg@5,10",
|
||||||
|
"ndcg_at_5": 0.5108501630463596,
|
||||||
|
"ndcg_at_10": 0.577072484777789,
|
||||||
|
"spearman": 0.5896180660523218,
|
||||||
|
"spearman_baseline_regression": 0.573016131374212,
|
||||||
|
"spearman_ratio_vs_baseline": 1.0289728923307881,
|
||||||
|
"best_iter": 29,
|
||||||
|
"train_seconds": 1.4682528972625732,
|
||||||
|
"n_train": 254234,
|
||||||
|
"n_val": 73785,
|
||||||
|
"top10_features": [
|
||||||
|
{
|
||||||
|
"name": "dist_to_grid_lower",
|
||||||
|
"gain": 26718.906676471233
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "amp_x_grid",
|
||||||
|
"gain": 5439.311601281166
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "cross_freq_x_bb",
|
||||||
|
"gain": 4232.088328957558
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "amp_x_grid_vol",
|
||||||
|
"gain": 3015.4479908943176
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "dist_to_grid_upper",
|
||||||
|
"gain": 2406.65438079834
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "ln_float_mv",
|
||||||
|
"gain": 1248.8392915129662
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "avg_daily_amp",
|
||||||
|
"gain": 794.0413353443146
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "amount_mean_20d",
|
||||||
|
"gain": 670.1584417819977
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "vol_decay_x_dist_lower",
|
||||||
|
"gain": 659.5440436601639
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "vol_decay_x_grid_balance",
|
||||||
|
"gain": 653.8379725217819
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,158 @@
|
|||||||
|
# v6.7r3 代码使用说明
|
||||||
|
|
||||||
|
`code/` 目录包含 v6.7r3 策略的**自包含**实现,可在不依赖项目其他模块的情况下独立运行。
|
||||||
|
|
||||||
|
## 文件清单
|
||||||
|
|
||||||
|
| 文件 | 大小 | 作用 |
|
||||||
|
|---|---|---|
|
||||||
|
| `strategy.py` | ~22 KB | 完整策略实现(模型加载/特征/网格/沉寂/周度淘汰) |
|
||||||
|
|
||||||
|
## 文件结构
|
||||||
|
|
||||||
|
```
|
||||||
|
strategy.py
|
||||||
|
├── 配置常量 (网格/价格/沉寂/周度)
|
||||||
|
├── 1. 持仓 + 网格交易
|
||||||
|
│ ├── Position dataclass
|
||||||
|
│ ├── compute_initial_position() 初始建仓
|
||||||
|
│ ├── compute_single_position() 单格建仓
|
||||||
|
│ └── simulate_grid_day() 单日网格 (LIFO)
|
||||||
|
├── 2. 5 特征沉寂检测
|
||||||
|
│ └── is_slumbering() 5 特征 ≥ 3 触发
|
||||||
|
├── 3. 模型加载 + 三件套预测
|
||||||
|
│ ├── ModelBundle class
|
||||||
|
│ │ ├── load 3 .pkl
|
||||||
|
│ │ └── predict(X56) → {rank_score, top_prob, stack_prob}
|
||||||
|
├── 4. 特征计算
|
||||||
|
│ ├── compute_52_base_features() 52 维 v3.4 基础
|
||||||
|
│ └── compute_4_v67_new_features() 4 维 v6.7 新增
|
||||||
|
├── 5. 评分池
|
||||||
|
│ └── score_pool() 单日全市场评分
|
||||||
|
├── 6. 主回测入口(精简版)
|
||||||
|
│ └── quick_backtest() 生产级完整版见 tools/backtest_v67r2.py
|
||||||
|
└── 7. 入口示例
|
||||||
|
└── __main__ 加载模型 + 加载行情 + 跑回测
|
||||||
|
```
|
||||||
|
|
||||||
|
## 快速开始
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. 安装依赖
|
||||||
|
pip install pandas numpy lightgbm scipy
|
||||||
|
|
||||||
|
# 2. 准备数据
|
||||||
|
# 方式 A: 用项目内的 dump_market_data_to_parquet.py 拉 Postgres
|
||||||
|
python tools/dump_market_data_to_parquet.py
|
||||||
|
# 方式 B: 直接用现有 parquet (release/v6.7r3 之前已生成 5025 个)
|
||||||
|
|
||||||
|
# 3. 运行
|
||||||
|
cd release/v6.7r3/code
|
||||||
|
python strategy.py
|
||||||
|
```
|
||||||
|
|
||||||
|
## 核心 API 速查
|
||||||
|
|
||||||
|
### 1. 加载模型
|
||||||
|
```python
|
||||||
|
from strategy import ModelBundle
|
||||||
|
|
||||||
|
models = ModelBundle("../models")
|
||||||
|
# 等价: models = ModelBundle("release/v6.7r3/models")
|
||||||
|
print(models.rank_feats[:5]) # ['rolling_grid_ratio_20d', ...]
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. 三件套预测
|
||||||
|
```python
|
||||||
|
import numpy as np
|
||||||
|
import pandas as pd
|
||||||
|
|
||||||
|
# 加载单只股 120 日窗口
|
||||||
|
df_window = pd.read_parquet("data/market_data/share/000001.parquet")
|
||||||
|
df_window["date"] = pd.to_datetime(df_window["date"])
|
||||||
|
df_window = df_window.tail(120).reset_index(drop=True)
|
||||||
|
|
||||||
|
# 算 56 维特征 (这里用简化版, 生产建议用项目 core.features)
|
||||||
|
from strategy import compute_52_base_features, compute_4_v67_new_features
|
||||||
|
fd = compute_52_base_features(df_window)
|
||||||
|
fd = compute_4_v67_new_features(df_window, fd)
|
||||||
|
X56 = np.array([fd.get(k, 0.0) for k in models.rank_feats], dtype=np.float64).reshape(1, -1)
|
||||||
|
|
||||||
|
# 三件套预测
|
||||||
|
pred = models.predict(X56)
|
||||||
|
print(f"rank_score: {pred['rank_score'][0]:.3f}")
|
||||||
|
print(f"top_prob: {pred['top_prob'][0]:.3f}")
|
||||||
|
print(f"stack_prob: {pred['stack_prob'][0]:.3f}") # 月末选股用
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. 沉寂检测
|
||||||
|
```python
|
||||||
|
from strategy import is_slumbering
|
||||||
|
|
||||||
|
df = pd.read_parquet("data/market_data/share/000001.parquet")
|
||||||
|
df["date"] = pd.to_datetime(df["date"])
|
||||||
|
slumbering = is_slumbering(df, min_triggers=3)
|
||||||
|
# True = 5 特征中至少 3 个触发 → 资金离场
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. 网格交易(单日)
|
||||||
|
```python
|
||||||
|
from strategy import Position, simulate_grid_day
|
||||||
|
|
||||||
|
pos = Position(code="000001", base=10, queue=[10.0, 9.5], entry_date="2023-05-01")
|
||||||
|
new_base, new_queue, trades = simulate_grid_day(
|
||||||
|
pos.base, pos.queue,
|
||||||
|
open_p=9.4, high_p=10.6, low_p=9.3, close_p=10.5,
|
||||||
|
)
|
||||||
|
# 触发 buy at 9.0 (low 9.3 <= 9.0? no, 9.3 > 9.0 不触发)
|
||||||
|
# 触发 sell at 10.0 (high 10.6 >= 10.0, 卖出 1 格)
|
||||||
|
# 最终: base=11, queue=[9.5] (1 格清仓)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. 评分池(每日全市场)
|
||||||
|
```python
|
||||||
|
from strategy import score_pool, ModelBundle
|
||||||
|
import pandas as pd
|
||||||
|
|
||||||
|
# 假设 kline_cache 是 dict[code, DataFrame]
|
||||||
|
models = ModelBundle("../models")
|
||||||
|
pool = score_pool(pd.Timestamp("2024-03-15"), kline_cache, models, models.rank_feats)
|
||||||
|
# pool 列: code6, latest_close, rank_score, top_prob, stack_prob
|
||||||
|
# 按 stack_prob 降序, 取 top 10
|
||||||
|
top10 = pool.head(10)
|
||||||
|
```
|
||||||
|
|
||||||
|
## 关键参数(可调)
|
||||||
|
|
||||||
|
| 参数 | 默认 | 说明 | 调优方向 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `WEEKLY_ELIM_N` | 2 | 周度淘汰: 连续 N 周不在 top 50 | 1 太频, 3+ 太慢 |
|
||||||
|
| `SLUMBER_TRIGGERS` | 3 | 沉寂检测: >= 3/5 特征触发 | 2 太宽, 4 太严 |
|
||||||
|
| `SLUMBER_DAYS` | 10 | 连续触发多少天清仓 | 5 太短, 20 太长 |
|
||||||
|
| `TOP_N` | 10 | 最大持仓数 | 5-15 视资金量 |
|
||||||
|
| `SHARES_PER_GRID` | 200 | 单格股数 (2 手) | 100 (1 手) 也可 |
|
||||||
|
| `REFILL_PRICE` | 9.0-9.8 | 补仓价格区间 | 紧贴 9-10 网格上限 |
|
||||||
|
|
||||||
|
## 依赖项目其他模块?
|
||||||
|
|
||||||
|
为保持 `code/` 目录**自包含**:
|
||||||
|
- ✅ 不依赖 `core/features.py` (内置 `compute_52_base_features` 简化版)
|
||||||
|
- ✅ 不依赖 `training/dataset_builder.py` (内置 `compute_4_v67_new_features`)
|
||||||
|
- ❌ 需要数据: parquet 行情文件
|
||||||
|
|
||||||
|
**生产部署建议**:用 `core/features.calculate_features` 替换 `compute_52_base_features`,
|
||||||
|
它有完整版 v3.4 52 维特征实现,精度更高。
|
||||||
|
|
||||||
|
## 完整版 vs 精简版
|
||||||
|
|
||||||
|
| 维度 | `code/strategy.py` (精简) | `tools/backtest_v67r2.py` (生产) |
|
||||||
|
|---|---|---|
|
||||||
|
| 特征计算 | 简化版 (10 维示例) | 完整版 (52 维 v3.4 + 4 维 v6.7) |
|
||||||
|
| 数据加载 | dict of DataFrame | parquet 目录 + score cache |
|
||||||
|
| 日志 | 无 | 详细进度打印 |
|
||||||
|
| 输出 | dict 指标 | CSV/JSON/PNG 完整产物 |
|
||||||
|
| 速度 | 慢 (无缓存) | 快 (有 score cache) |
|
||||||
|
| 用途 | 教学/集成 | 完整回测 |
|
||||||
|
|
||||||
|
**生产环境**:用 `tools/backtest_v67r2.py` 跑回测,确保完整功能。
|
||||||
|
**集成到实盘系统**:用 `code/strategy.py` 中的 `ModelBundle` 和 `quick_backtest` 作为模板。
|
||||||
@@ -0,0 +1,574 @@
|
|||||||
|
"""
|
||||||
|
v6.7r3 策略核心实现 —— 自包含版
|
||||||
|
================================
|
||||||
|
|
||||||
|
包含:
|
||||||
|
1. 模型加载
|
||||||
|
2. 56 维特征计算
|
||||||
|
3. 三件套预测 (rank → top → stacking)
|
||||||
|
4. 网格交易模拟器
|
||||||
|
5. 5 特征沉寂检测
|
||||||
|
6. 周度评分淘汰
|
||||||
|
7. 月末调仓 + 清仓补入
|
||||||
|
|
||||||
|
依赖:
|
||||||
|
pip install pandas numpy lightgbm scipy
|
||||||
|
(内嵌了核心算法, 不依赖项目其他模块, 可独立运行)
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import math
|
||||||
|
import pickle
|
||||||
|
import time
|
||||||
|
from collections import defaultdict
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Dict, List, Optional, Tuple
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import pandas as pd
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# 0. 配置
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
# 网格
|
||||||
|
INITIAL_CASH = 60_000.0
|
||||||
|
TOP_N = 10
|
||||||
|
TOP_MODEL_N = 50
|
||||||
|
SHARES_PER_GRID = 200
|
||||||
|
MIN_BUY_PRICE = 7.0
|
||||||
|
MAX_BUY_PRICE = 10.0
|
||||||
|
REFILL_MIN_PRICE = 9.0
|
||||||
|
REFILL_MAX_PRICE = 9.8
|
||||||
|
GRID_LOWER, GRID_UPPER = 1, 11
|
||||||
|
|
||||||
|
# 沉寂检测
|
||||||
|
SLUMBER_TRIGGERS = 3 # >= 3/5 特征触发
|
||||||
|
SLUMBER_DAYS = 10 # 连续 10 日触发
|
||||||
|
SLUMBER_LOOKBACK_60 = 60
|
||||||
|
SLUMBER_LOOKBACK_20 = 20
|
||||||
|
|
||||||
|
# 周度淘汰
|
||||||
|
WEEKLY_ELIM_N = 2 # 连续 2 周不在 top 50 → 卖
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# 1. 持仓 + 网格交易
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Position:
|
||||||
|
code: str
|
||||||
|
base: int # 当前基准价 (整数)
|
||||||
|
queue: list = field(default_factory=list) # 持仓队列: 每格成本价
|
||||||
|
entry_date: str = ""
|
||||||
|
|
||||||
|
|
||||||
|
def compute_initial_position(close: float) -> Tuple[int, list]:
|
||||||
|
"""初始建仓: base=ceil(close), queue=[10, 9, ..., base] 且 >= close"""
|
||||||
|
base = math.ceil(close)
|
||||||
|
base = max(GRID_LOWER, min(base, GRID_UPPER))
|
||||||
|
queue = [g for g in range(GRID_UPPER, base - 1, -1) if g >= close]
|
||||||
|
return base, queue
|
||||||
|
|
||||||
|
|
||||||
|
def compute_single_position(close: float) -> Tuple[int, list]:
|
||||||
|
"""单格建仓"""
|
||||||
|
base = math.ceil(close)
|
||||||
|
base = max(GRID_LOWER, min(base, GRID_UPPER))
|
||||||
|
return base, [base]
|
||||||
|
|
||||||
|
|
||||||
|
def simulate_grid_day(base, queue, open_p, high_p, low_p, close_p, can_buy=True):
|
||||||
|
"""单日网格交易 (LIFO)"""
|
||||||
|
trades = []
|
||||||
|
new_base, new_queue = base, list(queue)
|
||||||
|
|
||||||
|
# 1) 买
|
||||||
|
buy_price = new_base - 1
|
||||||
|
if low_p <= buy_price and new_base > GRID_LOWER and can_buy:
|
||||||
|
new_base = buy_price
|
||||||
|
new_queue.append(buy_price)
|
||||||
|
trades.append({"direction": "buy", "price": buy_price, "shares": SHARES_PER_GRID, "pnl": 0.0})
|
||||||
|
|
||||||
|
# 2) 卖 (循环)
|
||||||
|
while True:
|
||||||
|
sell_price = new_base + 1
|
||||||
|
if high_p >= sell_price and new_queue:
|
||||||
|
buy_cost = new_queue.pop()
|
||||||
|
pnl = (sell_price - buy_cost) * SHARES_PER_GRID
|
||||||
|
trades.append({"direction": "sell", "price": sell_price, "shares": SHARES_PER_GRID, "pnl": pnl})
|
||||||
|
new_base = sell_price
|
||||||
|
else:
|
||||||
|
break
|
||||||
|
|
||||||
|
return new_base, new_queue, trades
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# 2. 5 特征沉寂检测
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
def is_slumbering(df: pd.DataFrame, lookback_60=60, lookback_20=20,
|
||||||
|
min_triggers=SLUMBER_TRIGGERS) -> bool:
|
||||||
|
"""检测一只股是否陷入'沉寂' (资金离场后长期低位震荡).
|
||||||
|
5 特征, >= min_triggers 触发.
|
||||||
|
"""
|
||||||
|
if df is None or len(df) < lookback_60:
|
||||||
|
return False
|
||||||
|
sub = df.tail(lookback_60)
|
||||||
|
close = sub["close"].values
|
||||||
|
high = sub["high"].values
|
||||||
|
low = sub["low"].values
|
||||||
|
vol = sub["volume"].values
|
||||||
|
|
||||||
|
# 1. 波动率塌陷
|
||||||
|
log_ret = np.log(close[1:] / close[:-1])
|
||||||
|
if len(log_ret) < lookback_20:
|
||||||
|
return False
|
||||||
|
vol_20d = float(np.std(log_ret[-lookback_20:], ddof=1))
|
||||||
|
vol_60d = float(np.std(log_ret, ddof=1))
|
||||||
|
vol_collapse = (vol_60d > 0) and (vol_20d / vol_60d < 0.6)
|
||||||
|
|
||||||
|
# 2. 振幅萎缩
|
||||||
|
amp_20d = float(np.mean((high[-lookback_20:] - low[-lookback_20:]) / close[-lookback_20:]) * 100)
|
||||||
|
amp_shrink = amp_20d < 2.5
|
||||||
|
|
||||||
|
# 3. 成交量枯竭
|
||||||
|
avg_vol_20 = float(np.mean(vol[-lookback_20:]))
|
||||||
|
avg_vol_60 = float(np.mean(vol))
|
||||||
|
vol_dry = (avg_vol_60 > 0) and (avg_vol_20 / avg_vol_60 < 0.5)
|
||||||
|
|
||||||
|
# 4. 价格弱势
|
||||||
|
price_max_60 = float(np.max(close))
|
||||||
|
price_weak = price_max_60 > 0 and (close[-1] / price_max_60) < 0.85
|
||||||
|
|
||||||
|
# 5. 反弹失败
|
||||||
|
recent_high_30 = float(np.max(high[-30:]))
|
||||||
|
past_high_60 = float(np.max(high))
|
||||||
|
rebound_fail = past_high_60 > 0 and (recent_high_30 / past_high_60) < 0.95
|
||||||
|
|
||||||
|
triggers = [vol_collapse, amp_shrink, vol_dry, price_weak, rebound_fail]
|
||||||
|
return sum(triggers) >= min_triggers
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# 3. 模型加载 + 三件套预测
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
class ModelBundle:
|
||||||
|
"""v6.7r3 三件套模型封装"""
|
||||||
|
def __init__(self, model_dir: str | Path):
|
||||||
|
model_dir = Path(model_dir)
|
||||||
|
with open(model_dir / "rank_lambdarank.pkl", "rb") as f:
|
||||||
|
rank_b = pickle.load(f)
|
||||||
|
with open(model_dir / "top_v67r2.pkl", "rb") as f:
|
||||||
|
top_b = pickle.load(f)
|
||||||
|
with open(model_dir / "stacking_v67r2.pkl", "rb") as f:
|
||||||
|
stack_b = pickle.load(f)
|
||||||
|
|
||||||
|
self.rank_model = rank_b["model"]
|
||||||
|
self.rank_feats = rank_b["feat_names"] # 56 维
|
||||||
|
self.top_model = top_b["model"]
|
||||||
|
self.top_feats = top_b["feat_names"] # 53 维
|
||||||
|
self.stack_model = stack_b["model"]
|
||||||
|
self.stack_feats = stack_b["feat_names"] # 55 维
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _safe_predict_proba(model, X):
|
||||||
|
if hasattr(model, "predict_proba"):
|
||||||
|
return model.predict_proba(X)[:, 1]
|
||||||
|
return model.predict(X, raw_score=False)
|
||||||
|
|
||||||
|
def predict(self, X56: np.ndarray) -> dict:
|
||||||
|
"""输入 56 维特征矩阵 (n, 56), 返回三件套预测 dict.
|
||||||
|
返回: rank_score (n,), top_prob (n,), stack_prob (n,)
|
||||||
|
"""
|
||||||
|
rank_pred = self.rank_model.predict(X56)
|
||||||
|
|
||||||
|
# 52 维基础特征在 X56 中的索引
|
||||||
|
base_52_idx = [self.rank_feats.index(f) for f in self.top_feats if f != "rank_predicted_rounds"]
|
||||||
|
X53 = np.column_stack([X56[:, base_52_idx], rank_pred])
|
||||||
|
top_prob = self._safe_predict_proba(self.top_model, X53)
|
||||||
|
|
||||||
|
X55 = np.column_stack([X53, top_prob])
|
||||||
|
stack_prob = self._safe_predict_proba(self.stack_model, X55)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"rank_score": rank_pred,
|
||||||
|
"top_prob": top_prob,
|
||||||
|
"stack_prob": stack_prob,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# 4. 特征计算 (从项目 core/features.py 抽取, 关键函数)
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
def _log_returns(close: np.ndarray) -> np.ndarray:
|
||||||
|
return np.log(close[1:] / close[:-1])
|
||||||
|
|
||||||
|
|
||||||
|
def _ema(arr: np.ndarray, period: int) -> np.ndarray:
|
||||||
|
"""指数移动平均"""
|
||||||
|
alpha = 2.0 / (period + 1)
|
||||||
|
out = np.zeros_like(arr)
|
||||||
|
out[0] = arr[0]
|
||||||
|
for i in range(1, len(arr)):
|
||||||
|
out[i] = alpha * arr[i] + (1 - alpha) * out[i-1]
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def compute_52_base_features(df: pd.DataFrame, total_share: Optional[float] = None) -> dict:
|
||||||
|
"""计算 v3.4 的 52 维基础特征 (简化版, 不完全等同于原版).
|
||||||
|
注: 完整版在 core/features.py, 这里用 pandas/numpy 简化.
|
||||||
|
"""
|
||||||
|
n = len(df)
|
||||||
|
if n < 60:
|
||||||
|
return None
|
||||||
|
|
||||||
|
close = df["close"].values
|
||||||
|
high = df["high"].values
|
||||||
|
low = df["low"].values
|
||||||
|
open_ = df["open"].values
|
||||||
|
vol = df["volume"].values
|
||||||
|
|
||||||
|
feats = {}
|
||||||
|
|
||||||
|
# 1. rolling_grid_ratio_20d
|
||||||
|
feats["rolling_grid_ratio_20d"] = float(np.mean((close[-20:] >= 1) & (close[-20:] <= 11)) * 100)
|
||||||
|
|
||||||
|
# 2. cross_freq_20d
|
||||||
|
diff = close[1:] - close[:-1]
|
||||||
|
sign_change = np.sum(np.abs(np.diff(np.sign(diff[-19:]))) > 0)
|
||||||
|
feats["cross_freq_20d"] = float(sign_change / 19 * 100) if 19 > 0 else 0.0
|
||||||
|
|
||||||
|
# 3. avg_daily_amp
|
||||||
|
feats["avg_daily_amp"] = float(np.mean((high - low) / open_) * 100) if n > 0 else 0.0
|
||||||
|
|
||||||
|
# 4. high_amp_days
|
||||||
|
feats["high_amp_days"] = float(np.mean((high - low) / open_ > 0.02) * 100) if n > 0 else 0.0
|
||||||
|
|
||||||
|
# 5. atr_pct
|
||||||
|
tr = np.maximum(high - low, np.maximum(np.abs(high - np.roll(close, 1)),
|
||||||
|
np.abs(low - np.roll(close, 1))))
|
||||||
|
feats["atr_pct"] = float(np.mean(tr[-14:]) / close[-1] * 100) if close[-1] > 0 else 0.0
|
||||||
|
|
||||||
|
# 6. volatility_20d (年化)
|
||||||
|
log_ret = _log_returns(close)
|
||||||
|
feats["volatility_20d"] = float(np.std(log_ret[-20:], ddof=1) * np.sqrt(252) * 100) if len(log_ret) >= 20 else 0.0
|
||||||
|
|
||||||
|
# 7. price_cv
|
||||||
|
feats["price_cv"] = float(np.std(close, ddof=1) / np.mean(close) * 100) if n > 1 and np.mean(close) > 0 else 0.0
|
||||||
|
|
||||||
|
# 8. bb_width (布林带宽)
|
||||||
|
ma20 = np.mean(close[-20:])
|
||||||
|
sd20 = np.std(close[-20:], ddof=1)
|
||||||
|
feats["bb_width"] = float((4 * sd20) / ma20 * 100) if ma20 > 0 else 0.0
|
||||||
|
|
||||||
|
# 9. volume_ratio
|
||||||
|
avg_vol_20 = float(np.mean(vol[-20:])) if n >= 20 else float(np.mean(vol))
|
||||||
|
avg_vol_60 = float(np.mean(vol[-60:])) if n >= 60 else float(np.mean(vol))
|
||||||
|
feats["volume_ratio"] = avg_vol_20 / avg_vol_60 if avg_vol_60 > 0 else 0.0
|
||||||
|
|
||||||
|
# 10. obv_slope
|
||||||
|
direction = np.sign(np.diff(close))
|
||||||
|
direction = np.concatenate([[0], direction])
|
||||||
|
obv = np.cumsum(direction * vol)
|
||||||
|
if len(obv) >= 20:
|
||||||
|
x = np.arange(20)
|
||||||
|
y = obv[-20:]
|
||||||
|
feats["obv_slope"] = float((np.polyfit(x, y, 1)[0]) / (np.mean(np.abs(y)) + 1e-10))
|
||||||
|
else:
|
||||||
|
feats["obv_slope"] = 0.0
|
||||||
|
|
||||||
|
# ... (其他 42 维特征省略, 完整版在 core/features.py)
|
||||||
|
# 这里只展示 10 个核心特征的计算模式
|
||||||
|
# 实际部署时建议直接调用项目 core.features.calculate_features
|
||||||
|
|
||||||
|
return feats
|
||||||
|
|
||||||
|
|
||||||
|
def compute_4_v67_new_features(df: pd.DataFrame, fd: dict) -> dict:
|
||||||
|
"""计算 v6.7 新增的 4 维特征"""
|
||||||
|
n = len(df)
|
||||||
|
if n < 60:
|
||||||
|
return fd
|
||||||
|
|
||||||
|
close = df["close"].values
|
||||||
|
high = df["high"].values
|
||||||
|
low = df["low"].values
|
||||||
|
|
||||||
|
# 53. vol_decay_5d
|
||||||
|
log_ret = np.log(close[1:] / close[:-1])
|
||||||
|
if len(log_ret) >= 20:
|
||||||
|
vol_5d = float(np.std(log_ret[-5:], ddof=1))
|
||||||
|
vol_20d = float(np.std(log_ret[-20:], ddof=1))
|
||||||
|
fd["vol_decay_5d"] = vol_5d / vol_20d if vol_20d > 0 else 0.0
|
||||||
|
else:
|
||||||
|
fd["vol_decay_5d"] = 0.0
|
||||||
|
|
||||||
|
# 54. grid_touch_relative_10d
|
||||||
|
if n >= 60:
|
||||||
|
range_10d = float(np.max(high[-10:]) - np.min(low[-10:]))
|
||||||
|
range_60d = float(np.max(high[-60:]) - np.min(low[-60:]))
|
||||||
|
close_now = float(close[-1])
|
||||||
|
close_60d_mean = float(np.mean(close[-60:]))
|
||||||
|
if close_now > 0 and close_60d_mean > 0 and range_60d > 0:
|
||||||
|
fd["grid_touch_relative_10d"] = (range_10d / close_now) / (range_60d / close_60d_mean)
|
||||||
|
else:
|
||||||
|
fd["grid_touch_relative_10d"] = 0.0
|
||||||
|
else:
|
||||||
|
fd["grid_touch_relative_10d"] = 0.0
|
||||||
|
|
||||||
|
# 55. vol_decay_x_grid_balance
|
||||||
|
fd["vol_decay_x_grid_balance"] = fd.get("vol_decay_5d", 0.0) * fd.get("grid_room_balance", 0.0)
|
||||||
|
|
||||||
|
# 56. vol_decay_x_dist_lower
|
||||||
|
fd["vol_decay_x_dist_lower"] = fd.get("vol_decay_5d", 0.0) * fd.get("dist_to_grid_lower", 0.0)
|
||||||
|
|
||||||
|
return fd
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# 5. 评分池(单日)
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
def score_pool(date: pd.Timestamp,
|
||||||
|
kline_cache: Dict[str, pd.DataFrame],
|
||||||
|
models: ModelBundle,
|
||||||
|
feat_names: list) -> pd.DataFrame:
|
||||||
|
"""对所有有 120 日历史的股算 v6.7 三件套预测.
|
||||||
|
返回 DataFrame: code6, latest_close, rank_score, top_prob, stack_prob
|
||||||
|
"""
|
||||||
|
PRICE_MIN, PRICE_MAX = 1.0, 11.0
|
||||||
|
rows = []
|
||||||
|
codes = []
|
||||||
|
closes = []
|
||||||
|
|
||||||
|
for code, df in kline_cache.items():
|
||||||
|
sub = df[df["date"] <= date]
|
||||||
|
if len(sub) < 120:
|
||||||
|
continue
|
||||||
|
latest = float(sub["close"].iloc[-1])
|
||||||
|
if not (PRICE_MIN <= latest <= PRICE_MAX):
|
||||||
|
continue
|
||||||
|
|
||||||
|
# 算 56 维特征
|
||||||
|
obs = sub.tail(120).reset_index(drop=True)
|
||||||
|
fd = compute_52_base_features(obs)
|
||||||
|
if fd is None:
|
||||||
|
continue
|
||||||
|
fd = compute_4_v67_new_features(obs, fd)
|
||||||
|
try:
|
||||||
|
X = np.array([fd.get(k, 0.0) for k in feat_names], dtype=np.float64).reshape(1, -1)
|
||||||
|
pred = models.predict(X)
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
rows.append(pred)
|
||||||
|
codes.append(code)
|
||||||
|
closes.append(latest)
|
||||||
|
|
||||||
|
if not rows:
|
||||||
|
return pd.DataFrame(columns=["code6", "latest_close", "rank_score", "top_prob", "stack_prob"])
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
return pd.DataFrame({
|
||||||
|
"code6": codes,
|
||||||
|
"latest_close": closes,
|
||||||
|
"rank_score": [r["rank_score"][0] for r in rows],
|
||||||
|
"top_prob": [r["top_prob"][0] for r in rows],
|
||||||
|
"stack_prob": [r["stack_prob"][0] for r in rows],
|
||||||
|
}).sort_values("stack_prob", ascending=False).reset_index(drop=True)
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# 6. 主回测入口(精简版, 仅展示核心逻辑)
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
def quick_backtest(kline_cache: Dict[str, pd.DataFrame],
|
||||||
|
models: ModelBundle,
|
||||||
|
start_date: str = "2023-05-01",
|
||||||
|
end_date: str = "2026-04-30",
|
||||||
|
weekly_elim_n: int = WEEKLY_ELIM_N,
|
||||||
|
slumber_days: int = SLUMBER_DAYS) -> dict:
|
||||||
|
"""精简版 3 年回测 (生产级完整版见 tools/backtest_v67r2.py).
|
||||||
|
|
||||||
|
核心流程:
|
||||||
|
1. 初始建仓
|
||||||
|
2. 每日: 网格交易 + 沉寂检测
|
||||||
|
3. 周五: 周度评分淘汰 + 补仓
|
||||||
|
4. 月末: 清仓补入 (触及 11 元)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict: 总收益率, 年化夏普, 最大回撤, 周胜率, 终值
|
||||||
|
"""
|
||||||
|
feat_names = models.rank_feats
|
||||||
|
|
||||||
|
# 交易日索引
|
||||||
|
sample = next(iter(kline_cache.values()))
|
||||||
|
all_dates = pd.DatetimeIndex(sorted(sample["date"].unique()))
|
||||||
|
mask = (all_dates >= start_date) & (all_dates <= end_date)
|
||||||
|
backtest_dates = all_dates[mask]
|
||||||
|
|
||||||
|
# 初始评分 (INIT_SELECT_DATE)
|
||||||
|
init_date = pd.Timestamp("2023-04-28")
|
||||||
|
init_pool = score_pool(init_date, kline_cache, models, feat_names)
|
||||||
|
init_pool = init_pool[init_pool["latest_close"].apply(lambda p: MIN_BUY_PRICE <= p <= MAX_BUY_PRICE)]
|
||||||
|
init_pool = init_pool.sort_values("stack_prob", ascending=False).head(TOP_N)
|
||||||
|
|
||||||
|
# 初始建仓
|
||||||
|
positions: Dict[str, Position] = {}
|
||||||
|
cash = INITIAL_CASH
|
||||||
|
for _, row in init_pool.iterrows():
|
||||||
|
code = row["code6"]
|
||||||
|
actual_close = float(row["latest_close"])
|
||||||
|
base, grid_queue = compute_initial_position(actual_close)
|
||||||
|
if not grid_queue:
|
||||||
|
continue
|
||||||
|
positions[code] = Position(code, base, [actual_close] * len(grid_queue), "2023-04-28")
|
||||||
|
for _ in grid_queue:
|
||||||
|
cash -= actual_close * SHARES_PER_GRID
|
||||||
|
|
||||||
|
# 周度淘汰历史
|
||||||
|
top50_history = defaultdict(list)
|
||||||
|
slumber_streak: Dict[str, int] = {}
|
||||||
|
total_asset_history = []
|
||||||
|
weekly_pnl = []
|
||||||
|
|
||||||
|
for i, cur_date in enumerate(backtest_dates):
|
||||||
|
cur_str = cur_date.strftime("%Y-%m-%d")
|
||||||
|
is_week_end = (i == len(backtest_dates) - 1) or (backtest_dates[i + 1].week != cur_date.week)
|
||||||
|
|
||||||
|
# === 网格日间交易 ===
|
||||||
|
for code, pos in list(positions.items()):
|
||||||
|
if not pos.queue:
|
||||||
|
continue
|
||||||
|
df = kline_cache[code]
|
||||||
|
sub = df[df["date"] == cur_date]
|
||||||
|
if sub.empty:
|
||||||
|
continue
|
||||||
|
row = sub.iloc[0]
|
||||||
|
new_base, new_queue, day_trades = simulate_grid_day(
|
||||||
|
pos.base, pos.queue,
|
||||||
|
float(row["open"]), float(row["high"]), float(row["low"]), float(row["close"]),
|
||||||
|
)
|
||||||
|
if day_trades:
|
||||||
|
pos.base, pos.queue = new_base, new_queue
|
||||||
|
for t in day_trades:
|
||||||
|
if t["direction"] == "buy":
|
||||||
|
cash -= t["price"] * t["shares"]
|
||||||
|
else:
|
||||||
|
cash += t["price"] * t["shares"]
|
||||||
|
|
||||||
|
# === 沉寂检测 ===
|
||||||
|
for code, pos in list(positions.items()):
|
||||||
|
if not pos.queue:
|
||||||
|
continue
|
||||||
|
df = kline_cache[code]
|
||||||
|
sub = df[df["date"] <= cur_date]
|
||||||
|
if len(sub) < 60:
|
||||||
|
continue
|
||||||
|
slumber = is_slumbering(sub)
|
||||||
|
slumber_streak[code] = slumber_streak.get(code, 0) + 1 if slumber else 0
|
||||||
|
if slumber_streak[code] >= slumber_days and pos.queue:
|
||||||
|
# 全仓清仓
|
||||||
|
px = float(sub["close"].iloc[-1])
|
||||||
|
cash += px * len(pos.queue) * SHARES_PER_GRID
|
||||||
|
positions[code] = Position(code, 0, [], cur_str)
|
||||||
|
slumber_streak[code] = 0
|
||||||
|
|
||||||
|
# === 资产快照 ===
|
||||||
|
mv = sum((float(kline_cache[c][kline_cache[c]["date"] <= cur_date]["close"].iloc[-1])
|
||||||
|
* len(p.queue) * SHARES_PER_GRID)
|
||||||
|
for c, p in positions.items() if p.queue)
|
||||||
|
total_asset = cash + mv
|
||||||
|
total_asset_history.append(total_asset)
|
||||||
|
|
||||||
|
# === 周度淘汰 + 补仓 ===
|
||||||
|
if is_week_end and i > 0 and weekly_elim_n > 0:
|
||||||
|
pool = score_pool(cur_date, kline_cache, models, feat_names)
|
||||||
|
top50 = set(pool.head(TOP_MODEL_N)["code6"].tolist())
|
||||||
|
for code in list(positions.keys()):
|
||||||
|
top50_history[code].append(code in top50)
|
||||||
|
|
||||||
|
# 连续 N 周不在 top 50 → 卖出
|
||||||
|
inactive = []
|
||||||
|
for code, p in positions.items():
|
||||||
|
if not p.queue:
|
||||||
|
continue
|
||||||
|
hist = top50_history.get(code, [])
|
||||||
|
if len(hist) >= weekly_elim_n and all(x is False for x in hist[-weekly_elim_n:]):
|
||||||
|
inactive.append(code)
|
||||||
|
for code in inactive[:1]: # 每月最多淘汰 1 只 (与 v6.3 一致)
|
||||||
|
pos = positions[code]
|
||||||
|
sub = kline_cache[code][kline_cache[code]["date"] <= cur_date]
|
||||||
|
px = float(sub["close"].iloc[-1])
|
||||||
|
cash += px * len(pos.queue) * SHARES_PER_GRID
|
||||||
|
positions[code] = Position(code, 0, [], cur_str)
|
||||||
|
|
||||||
|
# 补仓
|
||||||
|
positions = {c: p for c, p in positions.items() if p.queue}
|
||||||
|
refill_needed = max(0, TOP_N - len(positions))
|
||||||
|
if refill_needed > 0:
|
||||||
|
ref_pool = pool[pool["latest_close"].apply(lambda p: REFILL_MIN_PRICE < p < REFILL_MAX_PRICE)]
|
||||||
|
ref_pool = ref_pool[~ref_pool["code6"].isin(positions.keys())]
|
||||||
|
ref_pool = ref_pool.head(refill_needed)
|
||||||
|
for _, row in ref_pool.iterrows():
|
||||||
|
code = row["code6"]
|
||||||
|
actual_close = float(row["latest_close"])
|
||||||
|
base, grid_queue = compute_single_position(actual_close)
|
||||||
|
if not grid_queue:
|
||||||
|
continue
|
||||||
|
cost = actual_close * SHARES_PER_GRID * len(grid_queue)
|
||||||
|
if cash < cost:
|
||||||
|
continue
|
||||||
|
positions[code] = Position(code, base, [actual_close] * len(grid_queue), cur_str)
|
||||||
|
cash -= cost
|
||||||
|
|
||||||
|
# 计算指标
|
||||||
|
final_value = total_asset_history[-1] if total_asset_history else INITIAL_CASH
|
||||||
|
total_return = final_value / INITIAL_CASH - 1
|
||||||
|
rets = np.diff(total_asset_history) / total_asset_history[:-1]
|
||||||
|
sharpe = float(rets.mean() / rets.std() * np.sqrt(52)) if len(rets) > 1 and rets.std() > 0 else 0.0
|
||||||
|
cum_max = np.maximum.accumulate(total_asset_history)
|
||||||
|
dd = (np.array(total_asset_history) - cum_max) / cum_max
|
||||||
|
max_dd = float(dd.min())
|
||||||
|
win_rate = float((rets > 0).mean()) if len(rets) > 0 else 0.0
|
||||||
|
|
||||||
|
return {
|
||||||
|
"total_return_pct": round(total_return * 100, 2),
|
||||||
|
"annual_sharpe": round(sharpe, 4),
|
||||||
|
"max_drawdown_pct": round(max_dd * 100, 2),
|
||||||
|
"weekly_win_rate_pct": round(win_rate * 100, 2),
|
||||||
|
"final_value": round(final_value, 2),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# 7. 入口示例
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
# 1. 加载模型
|
||||||
|
models = ModelBundle("models") # 默认从当前目录的 models/ 加载
|
||||||
|
print(f"✓ 加载模型: rank {len(models.rank_feats)} 维, "
|
||||||
|
f"top {len(models.top_feats)} 维, stack {len(models.stack_feats)} 维")
|
||||||
|
|
||||||
|
# 2. 加载行情 (示例: 从 parquet 目录)
|
||||||
|
# 实际部署时, 从 market_data.kline_stock (Postgres) 或本地 parquet 加载
|
||||||
|
from pathlib import Path
|
||||||
|
parquet_dir = Path("data/market_data/share")
|
||||||
|
kline_cache = {}
|
||||||
|
for p in parquet_dir.glob("*.parquet"):
|
||||||
|
df = pd.read_parquet(p)
|
||||||
|
df["date"] = pd.to_datetime(df["date"])
|
||||||
|
kline_cache[p.stem] = df
|
||||||
|
print(f"✓ 加载行情: {len(kline_cache)} 只股")
|
||||||
|
|
||||||
|
# 3. 跑精简版回测
|
||||||
|
metrics = quick_backtest(kline_cache, models)
|
||||||
|
print("\n=== v6.7r3 三年回测结果 (精简版) ===")
|
||||||
|
for k, v in metrics.items():
|
||||||
|
print(f" {k}: {v}")
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
date,code,exit_price,realized_pnl,slumber_streak_days
|
||||||
|
2023-07-21,920870,7.53,-374.0,10
|
||||||
|
2023-07-25,920414,9.44,-1.9999999999999574,10
|
||||||
|
2024-03-27,920641,7.56,-734.0000000000001,10
|
||||||
|
2024-04-03,920001,8.93,-164.00000000000006,10
|
||||||
|
2024-05-31,603825,8.36,-130.00000000000006,10
|
||||||
|
2024-09-06,300462,8.98,-16.000000000000014,10
|
||||||
|
2024-09-11,920641,6.67,-1404.0,10
|
||||||
|
2024-12-23,920090,6.48,-1365.9999999999995,10
|
||||||
|
2024-12-25,920021,5.71,-1352.0,10
|
||||||
|
2025-01-13,920792,8.58,-107.99999999999983,10
|
||||||
|
2025-01-22,920371,6.9,-1199.9999999999998,10
|
||||||
|
2025-01-24,920339,7.86,-571.9999999999997,10
|
||||||
|
2025-01-27,920792,8.99,-85.99999999999994,10
|
||||||
|
2025-02-05,920810,8.18,-217.99999999999997,10
|
||||||
|
2025-03-13,002789,7.35,-942.0,10
|
||||||
|
2025-05-21,300052,10.3,258.00000000000017,10
|
||||||
|
2025-05-26,920639,9.54,185.9999999999996,10
|
||||||
|
2025-05-26,920553,10.17,94.00000000000013,10
|
||||||
|
2025-06-12,300052,9.94,77.99999999999976,10
|
||||||
|
2025-08-12,300798,9.11,-53.90000000000015,10
|
||||||
|
2025-09-26,000679,7.75,-501.99999999999994,10
|
||||||
|
2026-03-03,300086,8.81,-114.00000000000006,10
|
||||||
|
Binary file not shown.
@@ -0,0 +1,36 @@
|
|||||||
|
{
|
||||||
|
"rank": {
|
||||||
|
"v6.6": {
|
||||||
|
"spearman_on_val": 0.5741312551267312
|
||||||
|
},
|
||||||
|
"v6.7r2": {
|
||||||
|
"spearman_on_val": 0.5896180660523218
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"top": {
|
||||||
|
"v6.6": {
|
||||||
|
"pr_auc_val": 0.6881080916474673
|
||||||
|
},
|
||||||
|
"v6.7r2": {
|
||||||
|
"pr_auc_val": 0.6953262363660502
|
||||||
|
},
|
||||||
|
"delta": 0.007218144718582842
|
||||||
|
},
|
||||||
|
"stacking": {
|
||||||
|
"v6.6": {
|
||||||
|
"pr_auc_val": 0.6474384440874665,
|
||||||
|
"optimal_threshold": 0.32116277663299964,
|
||||||
|
"f1_at_thr": 0.6333791329260092
|
||||||
|
},
|
||||||
|
"v6.7r2": {
|
||||||
|
"pr_auc_val": 0.6640333379409579,
|
||||||
|
"optimal_threshold": 0.3311218467281351,
|
||||||
|
"f1_at_thr": 0.6402777365656805
|
||||||
|
},
|
||||||
|
"delta_pr_auc": 0.016594893853491333
|
||||||
|
},
|
||||||
|
"n_train": 254234,
|
||||||
|
"n_val": 73785,
|
||||||
|
"elite_rate_train": 0.2665890478850193,
|
||||||
|
"elite_rate_val": 0.19013349596801518
|
||||||
|
}
|
||||||
Binary file not shown.
@@ -1,31 +1,32 @@
|
|||||||
{
|
{
|
||||||
"version": "v6.6",
|
"version": "v6.7r3",
|
||||||
"feature_version": "v3.4",
|
"feature_version": "v3.4",
|
||||||
"architecture": "stacking_calibrated",
|
"architecture": "stacking_calibrated",
|
||||||
"data_source": "mysql://100.121.118.116:3306/grid_seeker_model_base",
|
"data_source": "mysql://100.121.118.116:3306/grid_seeker_model_base",
|
||||||
"training_date": "2026-05-28T14:23:23.662118",
|
"training_date": "2026-06-24T11:00:00.000000",
|
||||||
"n_stocks_total": 4358,
|
"n_stocks_total": 5378,
|
||||||
"n_stocks_after_filter": 1533,
|
"n_stocks_after_filter": 1533,
|
||||||
"n_training_samples": 205491,
|
"n_training_samples": 254234,
|
||||||
"window_days": 120,
|
"window_days": 120,
|
||||||
"future_days": 60,
|
"future_days": 60,
|
||||||
"step_days": 20,
|
"step_days": 20,
|
||||||
"y_rounds_mean": 0.1998384357465777,
|
"y_rounds_mean": 0.1998384357465777,
|
||||||
"y_rounds_median": 0.0,
|
"y_rounds_median": 0.0,
|
||||||
"y_rounds_zero_rate": 0.7108340511263267,
|
"y_rounds_zero_rate": 0.7108340511263267,
|
||||||
"elite_rate": 20.52109338121864,
|
"elite_rate": 26.66,
|
||||||
"rank": {
|
"rank": {
|
||||||
"cv_mae": 0.2053,
|
"cv_mae": 0.2053,
|
||||||
"cv_r2": 0.2258,
|
"cv_r2": 0.2258,
|
||||||
|
"spearman": 0.5896,
|
||||||
"best_params": {
|
"best_params": {
|
||||||
"num_leaves": 63,
|
"num_leaves": 63,
|
||||||
"min_child_samples": 30,
|
"min_child_samples": 30,
|
||||||
"max_depth": 7
|
"max_depth": 7
|
||||||
},
|
},
|
||||||
"n_features": 52
|
"n_features": 56
|
||||||
},
|
},
|
||||||
"top": {
|
"top": {
|
||||||
"cv_pr_auc": 0.5333,
|
"cv_pr_auc": 0.6953,
|
||||||
"best_params": {
|
"best_params": {
|
||||||
"num_leaves": 63,
|
"num_leaves": 63,
|
||||||
"min_child_samples": 30,
|
"min_child_samples": 30,
|
||||||
@@ -34,13 +35,27 @@
|
|||||||
"n_features": 53
|
"n_features": 53
|
||||||
},
|
},
|
||||||
"stacking": {
|
"stacking": {
|
||||||
"cv_pr_auc": 0.8207,
|
"cv_pr_auc": 0.6640,
|
||||||
"best_params": {
|
"best_params": {
|
||||||
"num_leaves": 31,
|
"num_leaves": 31,
|
||||||
"min_child_samples": 20,
|
"min_child_samples": 20,
|
||||||
"max_depth": -1
|
"max_depth": -1
|
||||||
},
|
},
|
||||||
"n_features": 54,
|
"n_features": 55,
|
||||||
"optimal_threshold": 0.35
|
"optimal_threshold": 0.33
|
||||||
}
|
},
|
||||||
|
"backtest": {
|
||||||
|
"start": "2023-05-04",
|
||||||
|
"end": "2026-04-30",
|
||||||
|
"total_return_pct": 151.92,
|
||||||
|
"annual_return_pct": 36.46,
|
||||||
|
"annual_sharpe": 1.7404,
|
||||||
|
"max_drawdown_pct": -12.10,
|
||||||
|
"weekly_win_rate_pct": 59.48,
|
||||||
|
"final_value": 151154,
|
||||||
|
"rebalancing_frequency": "weekly",
|
||||||
|
"elimination_window": "2_weeks"
|
||||||
|
},
|
||||||
|
"previous_version": "v6.6",
|
||||||
|
"previous_version_backup": "models_backup_20260624_110913"
|
||||||
}
|
}
|
||||||
+127
@@ -0,0 +1,127 @@
|
|||||||
|
with open('core/ui/flet/app_v2.py', 'r', encoding='utf-8') as f:
|
||||||
|
content = f.read()
|
||||||
|
|
||||||
|
# Patch 1: Add lock alongside _score_refreshing init
|
||||||
|
old_init = " self._score_refreshing = False # 刷新锁"
|
||||||
|
new_init = " self._score_refreshing = False\n self._score_lock = threading.Lock()"
|
||||||
|
content = content.replace(old_init, new_init, 1)
|
||||||
|
print('Patch 1 (init):', 'OK' if old_init not in content else 'NOT FOUND')
|
||||||
|
|
||||||
|
# Patch 2: _prev_day - use lock instead of flag guard
|
||||||
|
old_prev = ''' def _prev_day(_):
|
||||||
|
if self._score_refreshing:
|
||||||
|
return
|
||||||
|
self._score_cur_date -= timedelta(days=1)
|
||||||
|
self._score_date_label.value = str(self._score_cur_date)
|
||||||
|
self._score_refreshing = True
|
||||||
|
self._score_nav_btns[0].disabled = True
|
||||||
|
self._score_nav_btns[1].disabled = True
|
||||||
|
self._score_nav_btns[2].disabled = True
|
||||||
|
self._refresh_scoring()'''
|
||||||
|
new_prev = ''' def _prev_day(_):
|
||||||
|
if not self._score_lock.acquire(blocking=False):
|
||||||
|
return
|
||||||
|
self._score_cur_date -= timedelta(days=1)
|
||||||
|
self._score_date_label.value = str(self._score_cur_date)
|
||||||
|
self._score_date_label.update()
|
||||||
|
self._score_refreshing = True
|
||||||
|
for btn in self._score_nav_btns:
|
||||||
|
btn.disabled = True
|
||||||
|
for btn in self._score_nav_btns:
|
||||||
|
btn.update()
|
||||||
|
self._refresh_scoring()'''
|
||||||
|
content = content.replace(old_prev, new_prev, 1)
|
||||||
|
print('Patch 2 (_prev_day):', 'OK' if old_prev not in content else 'NOT FOUND')
|
||||||
|
|
||||||
|
# Patch 3: _next_day - use lock
|
||||||
|
old_next = ''' def _next_day(_):
|
||||||
|
if self._score_refreshing:
|
||||||
|
return
|
||||||
|
if self._score_cur_date >= self._score_max_date:
|
||||||
|
return
|
||||||
|
self._score_cur_date += timedelta(days=1)
|
||||||
|
self._score_date_label.value = str(self._score_cur_date)
|
||||||
|
self._score_refreshing = True
|
||||||
|
self._score_nav_btns[0].disabled = True
|
||||||
|
self._score_nav_btns[1].disabled = True
|
||||||
|
self._score_nav_btns[2].disabled = True
|
||||||
|
self._refresh_scoring()'''
|
||||||
|
new_next = ''' def _next_day(_):
|
||||||
|
if not self._score_lock.acquire(blocking=False):
|
||||||
|
return
|
||||||
|
if self._score_cur_date >= self._score_max_date:
|
||||||
|
self._score_lock.release()
|
||||||
|
return
|
||||||
|
self._score_cur_date += timedelta(days=1)
|
||||||
|
self._score_date_label.value = str(self._score_cur_date)
|
||||||
|
self._score_date_label.update()
|
||||||
|
self._score_refreshing = True
|
||||||
|
for btn in self._score_nav_btns:
|
||||||
|
btn.disabled = True
|
||||||
|
for btn in self._score_nav_btns:
|
||||||
|
btn.update()
|
||||||
|
self._refresh_scoring()'''
|
||||||
|
content = content.replace(old_next, new_next, 1)
|
||||||
|
print('Patch 3 (_next_day):', 'OK' if old_next not in content else 'NOT FOUND')
|
||||||
|
|
||||||
|
# Patch 4: _today - use lock
|
||||||
|
old_today = ''' def _today(_):
|
||||||
|
if self._score_refreshing:
|
||||||
|
return
|
||||||
|
if self._score_cur_date >= self._score_max_date:
|
||||||
|
return
|
||||||
|
self._score_cur_date = self._score_max_date
|
||||||
|
self._score_date_label.value = str(self._score_cur_date)
|
||||||
|
self._score_refreshing = True
|
||||||
|
self._score_nav_btns[0].disabled = True
|
||||||
|
self._score_nav_btns[1].disabled = True
|
||||||
|
self._score_nav_btns[2].disabled = True
|
||||||
|
self._refresh_scoring()'''
|
||||||
|
new_today = ''' def _today(_):
|
||||||
|
if not self._score_lock.acquire(blocking=False):
|
||||||
|
return
|
||||||
|
if self._score_cur_date >= self._score_max_date:
|
||||||
|
self._score_lock.release()
|
||||||
|
return
|
||||||
|
self._score_cur_date = self._score_max_date
|
||||||
|
self._score_date_label.value = str(self._score_cur_date)
|
||||||
|
self._score_date_label.update()
|
||||||
|
self._score_refreshing = True
|
||||||
|
for btn in self._score_nav_btns:
|
||||||
|
btn.disabled = True
|
||||||
|
for btn in self._score_nav_btns:
|
||||||
|
btn.update()
|
||||||
|
self._refresh_scoring()'''
|
||||||
|
content = content.replace(old_today, new_today, 1)
|
||||||
|
print('Patch 4 (_today):', 'OK' if old_today not in content else 'NOT FOUND')
|
||||||
|
|
||||||
|
# Patch 5: _revert_nav_btns - release lock at end
|
||||||
|
old_revert = ''' def _revert_nav_btns(self):
|
||||||
|
"""重新启用导航按钮并解除刷新锁;已达上限日期时禁用'明天'和'今天'按钮"""
|
||||||
|
self._score_refreshing = False
|
||||||
|
at_max = self._score_cur_date >= self._score_max_date
|
||||||
|
for btn in self._score_nav_btns:
|
||||||
|
btn.disabled = False
|
||||||
|
if at_max:
|
||||||
|
self._score_nav_btns[1].disabled = True # next
|
||||||
|
self._score_nav_btns[2].disabled = True # today
|
||||||
|
for btn in self._score_nav_btns:
|
||||||
|
btn.update()'''
|
||||||
|
new_revert = ''' def _revert_nav_btns(self):
|
||||||
|
"""重新启用导航按钮并解除刷新锁;已达上限日期时禁用'明天'和'今天'按钮"""
|
||||||
|
self._score_refreshing = False
|
||||||
|
at_max = self._score_cur_date >= self._score_max_date
|
||||||
|
for btn in self._score_nav_btns:
|
||||||
|
btn.disabled = False
|
||||||
|
if at_max:
|
||||||
|
self._score_nav_btns[1].disabled = True # next
|
||||||
|
self._score_nav_btns[2].disabled = True # today
|
||||||
|
for btn in self._score_nav_btns:
|
||||||
|
btn.update()
|
||||||
|
self._score_lock.release()'''
|
||||||
|
content = content.replace(old_revert, new_revert, 1)
|
||||||
|
print('Patch 5 (_revert_nav_btns):', 'OK' if old_revert not in content else 'NOT FOUND')
|
||||||
|
|
||||||
|
with open('core/ui/flet/app_v2.py', 'w', encoding='utf-8') as f:
|
||||||
|
f.write(content)
|
||||||
|
print('Done writing')
|
||||||
+4
-21
@@ -1,7 +1,6 @@
|
|||||||
# coding:utf-8
|
# coding:utf-8
|
||||||
"""
|
"""
|
||||||
启动入口 — 默认使用 Flet2 UI。
|
启动入口 — Flet UI
|
||||||
使用 --tk 参数切换到 Tkinter UI。
|
|
||||||
"""
|
"""
|
||||||
import sys
|
import sys
|
||||||
import os
|
import os
|
||||||
@@ -10,7 +9,6 @@ import ssl
|
|||||||
import traceback
|
import traceback
|
||||||
|
|
||||||
# 修复 Windows 上 flet_desktop 子进程弹出控制台窗口的问题
|
# 修复 Windows 上 flet_desktop 子进程弹出控制台窗口的问题
|
||||||
# 原始 Popen 不带 CREATE_NO_WINDOW 标志,会为每个子进程创建控制台窗口
|
|
||||||
_original_popen = subprocess.Popen
|
_original_popen = subprocess.Popen
|
||||||
|
|
||||||
class Popen(_original_popen):
|
class Popen(_original_popen):
|
||||||
@@ -22,14 +20,11 @@ class Popen(_original_popen):
|
|||||||
subprocess.Popen = Popen
|
subprocess.Popen = Popen
|
||||||
|
|
||||||
# PyInstaller 打包后,设置 FLET_VIEW_PATH 指向打包内的 Flet 客户端
|
# PyInstaller 打包后,设置 FLET_VIEW_PATH 指向打包内的 Flet 客户端
|
||||||
# 避免从 GitHub 下载
|
|
||||||
if getattr(sys, 'frozen', False):
|
if getattr(sys, 'frozen', False):
|
||||||
# 运行在打包后的 exe 中
|
base_path = sys._MEIPASS
|
||||||
base_path = sys._MEIPASS # PyInstaller 解压到的临时目录
|
|
||||||
flet_client_path = os.path.join(base_path, '.flet', 'client', 'flet-desktop-full-0.85.3')
|
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')
|
flet_exe = os.path.join(flet_client_path, 'flet', 'flet.exe')
|
||||||
|
|
||||||
# 写入日志便于调试
|
|
||||||
log_file = os.path.join(os.path.dirname(sys.executable), 'startup_log.txt')
|
log_file = os.path.join(os.path.dirname(sys.executable), 'startup_log.txt')
|
||||||
with open(log_file, 'w') as f:
|
with open(log_file, 'w') as f:
|
||||||
f.write(f'base_path: {base_path}\n')
|
f.write(f'base_path: {base_path}\n')
|
||||||
@@ -41,12 +36,10 @@ if getattr(sys, 'frozen', False):
|
|||||||
os.environ['FLET_VIEW_PATH'] = flet_client_path
|
os.environ['FLET_VIEW_PATH'] = flet_client_path
|
||||||
f.write(f'FLET_VIEW_PATH: {os.environ.get("FLET_VIEW_PATH")}\n')
|
f.write(f'FLET_VIEW_PATH: {os.environ.get("FLET_VIEW_PATH")}\n')
|
||||||
|
|
||||||
# 禁用 SSL 验证,避免证书问题
|
|
||||||
if hasattr(ssl, '_create_unverified_context'):
|
if hasattr(ssl, '_create_unverified_context'):
|
||||||
ssl._create_default_https_context = ssl._create_unverified_context
|
ssl._create_default_https_context = ssl._create_unverified_context
|
||||||
|
|
||||||
def excepthook(type, value, tb):
|
def excepthook(type, value, tb):
|
||||||
"""捕获未处理的异常,写入日志"""
|
|
||||||
log_file = os.path.join(os.path.dirname(sys.executable), 'error_log.txt')
|
log_file = os.path.join(os.path.dirname(sys.executable), 'error_log.txt')
|
||||||
with open(log_file, 'w') as f:
|
with open(log_file, 'w') as f:
|
||||||
f.write(''.join(traceback.format_exception(type, value, tb)))
|
f.write(''.join(traceback.format_exception(type, value, tb)))
|
||||||
@@ -55,15 +48,5 @@ def excepthook(type, value, tb):
|
|||||||
sys.excepthook = excepthook
|
sys.excepthook = excepthook
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
if '--tk' in sys.argv:
|
from core.ui.flet.app_v2 import run
|
||||||
from core.ui.tkinter.splash import SplashWindow
|
run()
|
||||||
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()
|
|
||||||
|
|||||||
Reference in New Issue
Block a user