文件是python,以下是内容:
# -*- coding: utf-8 -*-
"""
====================================================================
放量突破 + G点买 组合选股策略(TQ策略)
====================================================================
【功能】
把下面两套通达信公式合并为一个选股策略:
(一)放量突破选股公式
MA5V:=MA(VOL, 5);
MA60:=MA(CLOSE, 60);
LR_N:=IF(哪天=0, DYNAINFO(17), REF(VOL, 哪天) / REF(MA5V, 哪天+1));
ZG_N:=(REF(CLOSE, 哪天) - REF(CLOSE, 哪天+1)) / REF(CLOSE, 哪天+1) * 100;
ZG_PRE:=(REF(CLOSE, 哪天+1) - REF(CLOSE, 哪天+2)) / REF(CLOSE, 哪天+2) * 100;
LT:=FINANCE(40)/100000000;
NONST:=NOT(NAMELIKE('ST') OR NAMELIKE('*ST'));
NONKC:=NOT(CODELIKE('688'));
NONBJ:=NOT(CODELIKE('43') OR CODELIKE('82') OR CODELIKE('83')
OR CODELIKE('87') OR CODELIKE('92') OR CODELIKE('88'));
{ 原公式量为比条件:COND1:=IF(量倍=3, LR_N>=3, LR_N>=2 AND LR_N<3) }
{ 本策略已改为:量比 >= 所填值(量倍),不再限制 <3(见下方 check_stock 的 cond1) }
COND1:=LR_N >= 量倍;
COND2:=ZG_PRE < 5;
COND3:=IF(哪天=0,
DYNAINFO(7) > REF(MA60, 0) * 1.02,
REF(CLOSE, 哪天) > REF(MA60, 哪天) * 1.02);
XG:=COND1 AND ZG_N>2 AND ZG_N<涨顶 AND ZG_PRE<20 AND COND2
AND COND3 AND NONST AND NONKC AND NONBJ AND LT<100
AND REF(VOL, 哪天)>0 AND ISVALID(REF(CLOSE, 哪天+2));
(二)G点买选股公式(增加量比+总市值+市净率条件)
ZT:=C/REF(C,1)>1.09; ZT_NUM:=COUNT(ZT,60);
BB0:=(MA(C,3)+MA(C,7)+MA(C,13)+MA(C,27))/4;
BB1:=EMA(C,5);
BB:=IF(BB0=0 OR BB0=REF(BB0,1), BB1, BB0);
TK:=(C<O OR (C<REF(H,1) AND C>O) OR (C>=O AND (H-C)>=(C-O) AND C/REF(C,1)<1.02)
OR (C=O AND (H-C)>=(C-L) AND C/REF(C,1)<1.05));
TP:=((C>O AND C/REF(C,1)>0.97) OR (C>REF(L,1) AND C<O)
OR (C<=O AND (C-L)>=(O-C) AND C/REF(C,1)>0.98)
OR (C=O AND (C-L)>=(H-C) AND C/REF(C,1)>0.95));
A0:=(H+L+2*O+6*C)/10;
{ 9层迭代平滑:A1..A9,每层 CROSS(Ak-1,BB) 上穿取 BB*0.98,下穿取 BB*1.02 }
A:=A9;
VOL_FILTER:=DYNAINFO(10)>1.2; { 现量>1.2手 }
LB_FILTER:=DYNAINFO(17)>=LB; { 量比>=LB }
ZSZ_FILTER:=FINANCE(41)/100000000<=ZSZ; { 总市值(亿)<=ZSZ }
SJL_FILTER:=C/FINANCE(34)<=SJL; { 市净率<=SJL }
G点选股:=CROSS(A,BB) AND VOL_FILTER AND LB_FILTER
AND ZSZ_FILTER AND SJL_FILTER AND ZT_NUM>=ZTCS;
【最终选股】 = 放量突破_XG AND G点选股
【可调参数】(见下方 PARAMS 字典,已集中、已备注)
放量突破部分:
量倍 : 默认 3 (量比 >= 所填值;如填 2 则条件为 量比>=2,不再限制 <3)
涨顶 : 默认 11 (涨幅上限 %)
哪天 : 默认 0 (0=当天实时;1=昨天;2=前天...;同时统一偏移 G点 的K线信号)
G点部分:
ZTCS: 默认 0 (近60天大涨(>9%)次数下限,0=不限制)
LB : 默认 0 (量比下限,0=不限制)
ZSZ : 默认 10000 (总市值上限 亿,10000=不限制)
SJL : 默认 100 (市净率上限,100=不限制)
【关于 哪天 与 G点 的交互说明】
“哪天”会把【放量突破】和【G点】里由K线派生的信号(涨幅、量比、现量、
CROSS(A,BB)、ZT_NUM)统一回移到第 n 天,实现“回看 n 天前是否同时触发”
的扫描。而【总市值】【市净率】属于 FINANCE() 财务值,只有当前值,
因此不论哪天都使用当前值(与原公式语义一致)。
【运行方式】
在通达信 TQ策略管理器中新建策略,文件类型选“用户”,
指向本文件,手动启动即可。选股结果会发送到通达信选股列表。
====================================================================
"""
import sys
import os
import json
import time
import traceback
import logging
from datetime import datetime
import pandas as pd
import numpy as np
# ================================================================
# 一、环境路径与 TQ 初始化
# ================================================================
CURRENT_DIR = os.path.dirname(os.path.abspath(__file__))
TDX_ROOT = os.path.dirname(os.path.dirname(CURRENT_DIR))
# 把通达信 PYPlugins\user 加入 Python 路径,确保能导入 tqcenter
sys.path.insert(0, os.path.join(TDX_ROOT, "PYPlugins", "user"))
from tqcenter import tq
try:
tq.initialize(__file__)
except Exception as e:
print(f"[ERROR] TQ 初始化失败: {e}")
print(traceback.format_exc())
print("=" * 60)
print("排查指引(真实原因看第一行 DLL 报错,'连接路径为空'是误导):")
print(" 1. 是否已打开并登录通达信客户端?本策略依赖客户端进程取数据。")
print(" 2. 建议在客户端的 TQ策略管理器 中启动本策略,不要在 cmd 里直接跑。")
print(" 3. 若提示'已有同名策略运行':本策略是循环模式不会自己退出,")
print(" 请先在 TQ策略管理器 里停止旧的运行实例,再重新启动。")
print("=" * 60)
sys.exit(1)
# ================================================================
# 二、日志配置
# ================================================================
LOG_DIR = os.path.join(TDX_ROOT, "TQLogs")
os.makedirs(LOG_DIR, exist_ok=True)
LOG_FILE = os.path.join(LOG_DIR, f"放量突破选股_{datetime.now().strftime('%Y%m%d')}.log")
# 网页输出目录(桌面)与上轮状态文件(用于跨轮对比、置灰备选观察股)
DESKTOP_DIR = r"D:\user\Desktop"
STATE_FILE = os.path.join(DESKTOP_DIR, "stock_selection_latest.json")
logger = logging.getLogger("放量突破选股")
logger.setLevel(logging.INFO)
if logger.hasHandlers():
logger.handlers.clear()
formatter = logging.Formatter("%(asctime)s.%(msecs)03d - %(levelname)s - %(message)s", datefmt="%H:%M:%S")
fh = logging.FileHandler(LOG_FILE, encoding="utf-8")
fh.setFormatter(formatter)
logger.addHandler(fh)
ch = logging.StreamHandler(sys.stdout)
ch.setFormatter(formatter)
logger.addHandler(ch)
def log_info(msg):
logger.info(msg)
def log_warning(msg):
logger.warning(msg)
def log_error(msg):
logger.error(msg)
def print_progress_bar(current, total, passed, run_tag=""):
"""控制台单行进度条(原地刷新、不换行、不写日志文件)。"""
try:
bar_len = 28
filled = int(bar_len * current / total) if total else bar_len
bar = "#" * filled + "-" * (bar_len - filled)
pct = (current / total * 100.0) if total else 100.0
line = "\r%s[%s] %5.1f%% %5d/%5d 通过 %d 只" % (
run_tag, bar, pct, current, total, passed)
sys.stdout.write(line.ljust(110))
sys.stdout.flush()
except Exception:
pass
def finish_progress_bar(current, total, passed, run_tag=""):
"""进度条收尾:换行并固定显示最终状态。"""
try:
bar_len = 28
filled = int(bar_len * current / total) if total else bar_len
bar = "#" * filled + "-" * (bar_len - filled)
pct = (current / total * 100.0) if total else 100.0
line = "\r%s[%s] %5.1f%% %5d/%5d 通过 %d 只\n" % (
run_tag, bar, pct, current, total, passed)
sys.stdout.write(line.ljust(110))
sys.stdout.flush()
except Exception:
pass
log_info("=" * 80)
log_info(">>> 放量突破 + G点买 组合选股策略启动")
log_info(f">>> 日志文件: {LOG_FILE}")
log_info("=" * 80)
# ================================================================
# 三、策略参数(用户可自定义默认值 —— 集中在此处,已逐项备注)
# ================================================================
PARAMS = {
# ---------------- 放量突破部分 ----------------
"量倍": 2, # 放量突破量比条件:量比 >= 所填值(如填 2 则 量比>=2;不再限制 <3)
"涨顶": 11, # 当日涨幅上限 %(ZG_N < 涨顶)
"哪天": 0, # 偏移天数:0=当天实时;1=昨天;2=前天……(同时统一偏移 G点的K线信号)
# ---------------- G点买部分 ----------------
"ZTCS": 0, # 近60天大涨(单日涨幅>9%)次数下限;0=不限制
"LB": 0, # 量比下限;0=不限制(与放量突破的量比不冲突,取各自定义)
"ZSZ": 10000, # 总市值上限(亿元);10000=不限制
"SJL": 100, # 市净率上限;100=不限制
# ---------------- 结果输出部分 ----------------
"清空临时条件股": True, # 发送前是否先清空【临时条件股】(True=每次运行全量替换旧结果;False=在原有基础上累加)
# ---------------- 运行控制部分 ----------------
"每批数量": 200, # 单次 get_market_data 批量拉取的股票数;越大 API 调用越少但单批内存/处理略增(50 偏保守,200 较均衡,可试 300~500)
"循环运行": True, # True=选股完成后暂停“循环间隔秒”再重复运行(适合盘中持续监控);False=只跑一次
"循环间隔秒": 10, # 每两轮之间的暂停秒数(0=不暂停,紧挨着跑)
"最大循环次数": 0, # 0=不限次数一直循环;>0=最多跑 N 轮后自动停止
}
# K线取值根数(需覆盖 MA27 + 60日统计 + 偏移 + 缓冲)
KLINE_COUNT = 90
# G点 9层平滑迭代层数(原公式为 A1..A9)
G_LAYERS = 9
# ================================================================
# 四、工具函数
# ================================================================
def safe_float(val, default=0.0):
"""安全转 float"""
try:
if val is None or val == "":
return default
return float(val)
except Exception:
return default
def get_all_stocks():
"""获取全部 A 股列表"""
try:
stocks = tq.get_stock_list(market="5")
log_info(f"全市场股票总数: {len(stocks)}")
return stocks
except Exception as e:
log_error(f"获取股票列表失败: {e}")
return []
def code_prefix_filter(stock_list):
"""
过滤掉北交所(43/82/83/87/92/88 开头)。
科创板(688)保留——即允许科创板股票进入结果。
仅等价于公式中的 NONBJ;原 NONKC(排除 688)按需求已取消。
"""
passed = []
for code in stock_list:
if "." not in code:
continue
code_num = code.split(".")[0]
# 科创板 688 允许(不再排除)
if any(code_num.startswith(p) for p in ("43", "82", "83", "87", "92", "88")):
continue
passed.append(code)
log_info(f"代码前缀过滤后: {len(passed)} 只(已排除北交所 43/82/83/87/92/88,科创板 688 保留)")
return passed
def get_kline_data(stock_list, count=KLINE_COUNT):
"""批量获取日线数据(开高低收 + 成交量)"""
if not stock_list:
return None
try:
raw = tq.get_market_data(
field_list=["Open", "High", "Low", "Close", "Volume"],
stock_list=stock_list,
period="1d",
count=count,
dividend_type="front", # 前复权,与通达信默认一致
fill_data=True,
)
return raw
except Exception as e:
log_error(f"获取K线数据失败: {e}")
return None
def get_stock_info_single(code):
"""获取单只股票基础信息(名称、是否ST、每股净资产等)"""
try:
return tq.get_stock_info(stock_code=code, field_list=[])
except Exception as e:
log_error(f"获取 {code} 基础信息失败: {e}")
return {}
def get_more_info_single(code):
"""获取单只股票扩展信息(量比、涨幅、总市值、流通市值等)"""
try:
return tq.get_more_info(stock_code=code, field_list=[])
except Exception as e:
log_error(f"获取 {code} 扩展信息失败: {e}")
return {}
def get_snapshot_single(code):
"""获取实时快照(备用:用于取现量)"""
try:
return tq.get_market_snapshot(stock_code=code, field_list=[])
except Exception as e:
log_error(f"获取 {code} 快照失败: {e}")
return {}
def is_st_stock(info):
"""判断是否 ST/*ST"""
if not info:
return False
if info.get("IsSTGP") == "1":
return True
name = str(info.get("Name", ""))
if "ST" in name:
return True
return False
def calc_lt_size(code, close_price, info=None, more_info=None):
"""
计算流通市值(亿元)。
优先使用 get_more_info 返回的 Ltsz;失败则用 ActiveCapital * Close / 10000。
"""
if more_info and "Ltsz" in more_info:
return safe_float(more_info.get("Ltsz"), 9999.0)
if info is None:
info = get_stock_info_single(code)
active_cap = safe_float(info.get("ActiveCapital"), 0.0) # 万股
if active_cap > 0 and close_price > 0:
return active_cap * close_price / 10000.0 # 亿
return 9999.0
def estimate_amount_ratio_from_snapshot(code, vol_series):
"""
备用:当 get_more_info 拿不到量比时,用快照估算实时量比。
量比 = 当日累计成交量 / 过去5日平均成交量 * (240 / 已交易分钟数)
"""
try:
snap = get_snapshot_single(code)
today_vol = safe_float(snap.get("Volume"), 0.0) # 总手
if today_vol <= 0:
return 0.0
ma5_vol = vol_series.iloc[-5:].mean()
if ma5_vol <= 0:
return 0.0
now = datetime.now()
start_am = now.replace(hour=9, minute=30, second=0, microsecond=0)
end_am = now.replace(hour=11, minute=30, second=0, microsecond=0)
start_pm = now.replace(hour=13, minute=0, second=0, microsecond=0)
end_pm = now.replace(hour=15, minute=0, second=0, microsecond=0)
if now < start_am:
traded_minutes = 0
elif now <= end_am:
traded_minutes = int((now - start_am).total_seconds() // 60)
elif now < start_pm:
traded_minutes = 120
elif now <= end_pm:
traded_minutes = 120 + int((now - start_pm).total_seconds() // 60)
else:
traded_minutes = 240
if traded_minutes <= 0:
traded_minutes = 1
ratio = (today_vol / ma5_vol) * (240.0 / traded_minutes)
return ratio
except Exception as e:
log_error(f"估算 {code} 量比失败: {e}")
return 0.0
def get_realtime_xianliang(code):
"""
现量 DYNAINFO(10):最新一笔成交量(手)。
优先从快照取现量字段;取不到则回退到总成交量(Volume)判断。
返回 (value_or_None, found_flag)。
"""
try:
snap = get_snapshot_single(code)
if not snap:
return None, False
for k in ("VolNow", "NowVolume", "CurVol", "XianLiang", "现量"):
if k in snap and snap[k] not in (None, ""):
return safe_float(snap[k], 0.0), True
# 回退:总成交量(手) > 0 即视为有成交
v = safe_float(snap.get("Volume"), 0.0)
if v > 0:
return v, False
return None, False
except Exception:
return None, False
# ================================================================
# 五、G点指标计算(9层平滑 AFR + 趋势过滤 + 大涨次数)
# ================================================================
def compute_gpoint_series(O, H, L, C):
"""
计算 G点 指标的完整序列。
输入:Open/High/Low/Close 的 numpy 数组(已对齐、已 dropna)。
返回:(A, BB, ZT_NUM) 三个等长 numpy 数组。
A = 9层迭代平滑后的最终价格重心
BB = 趋势基线
ZT_NUM = 近60日大涨(>9%)次数(滚动)
"""
sC = pd.Series(C)
n = len(C)
# ---- 趋势均线 ----
ma3 = sC.rolling(3).mean().values
ma7 = sC.rolling(7).mean().values
ma13 = sC.rolling(13).mean().values
ma27 = sC.rolling(27).mean().values
bb0 = (ma3 + ma7 + ma13 + ma27) / 4.0
bb1 = sC.ewm(span=5, adjust=False).mean().values # EMA(C,5)
# BB = IF(BB0==0 OR BB0==REF(BB0,1), BB1, BB0)
bb0_ref = np.roll(bb0, 1)
bb0_ref[0] = np.nan
bb = np.where((bb0 == 0) | (bb0 == bb0_ref), bb1, bb0)
# ---- 价格重心 A0 ----
a0 = (H + L + 2 * O + 6 * C) / 10.0
# ---- 趋势过滤 TK / TP(需要 REF) ----
c_ref = np.roll(C, 1); c_ref[0] = np.nan
h_ref = np.roll(H, 1); h_ref[0] = np.nan
l_ref = np.roll(L, 1); l_ref[0] = np.nan
# TK:弱势/回调过滤
tk = (
(C < O)
| ((C < h_ref) & (C > O))
| ((C >= O) & ((H - C) >= (C - O)) & (C / c_ref < 1.02))
| ((C == O) & ((H - C) >= (C - L)) & (C / c_ref < 1.05))
)
# TP:强势/上攻过滤
tp = (
((C > O) & (C / c_ref > 0.97))
| ((C > l_ref) & (C < O))
| ((C <= O) & ((C - L) >= (O - C)) & (C / c_ref > 0.98))
| ((C == O) & ((C - L) >= (H - C)) & (C / c_ref > 0.95))
)
# ---- 9层迭代平滑 ----
a_prev = a0.copy()
for _ in range(G_LAYERS):
a_prev_ref = np.roll(a_prev, 1); a_prev_ref[0] = np.nan
bb_ref = np.roll(bb, 1); bb_ref[0] = np.nan
# C1 = CROSS(a_prev, BB) AND TK → 上穿取 BB*0.98
c1 = (a_prev > bb) & (a_prev_ref <= bb_ref) & tk
# C2 = CROSS(BB, a_prev) AND TP → 下穿取 BB*1.02
c2 = (bb > a_prev) & (bb_ref <= a_prev_ref) & tp
a_next = np.where(c1, bb * 0.98, np.where(c2, bb * 1.02, a_prev))
a_prev = a_next
A = a_prev
# ---- 近60日大涨次数 ----
zt = (C / c_ref > 1.09) # 单日涨幅 > 9%
zt_num = pd.Series(zt).rolling(60).sum().values
return A, bb, zt_num
# ================================================================
# 六、核心选股逻辑(放量突破 + G点)
# ================================================================
def check_stock(code, kline, params):
"""
对单只股票执行【放量突破 + G点】组合条件判断。
kline: dict,含 'Open','High','Low','Close','Volume' 的 pandas Series(同索引)。
返回: (bool 是否选中, dict 详情)
"""
n = params["哪天"]
liangbei = params["量倍"]
zhangding = params["涨顶"]
ztcs = params["ZTCS"]
lb = params["LB"]
zsz = params["ZSZ"]
sjl = params["SJL"]
# ---- 组装对齐的 DataFrame ----
try:
df = pd.DataFrame({
"Open": kline["Open"],
"High": kline["High"],
"Low": kline["Low"],
"Close": kline["Close"],
"Volume": kline["Volume"],
}).dropna()
except Exception:
return False, {}
length = len(df)
if length < 80:
return False, {}
O = df["Open"].values.astype(float)
H = df["High"].values.astype(float)
L = df["Low"].values.astype(float)
C = df["Close"].values.astype(float)
V = df["Volume"].values.astype(float)
# 目标 bar 索引:哪天=0 取最后一根;否则回移 (n+1) 根
bar_idx = (length - 1) if n == 0 else (length - 1 - n)
if bar_idx < 1:
return False, {}
ma60 = pd.Series(C).rolling(60).mean().values
# ============================================================
# A. 放量突破 —— K线预筛(精确等价于公式偏移,先淘汰以减少接口调用)
# ============================================================
ref_c_n1 = C[bar_idx - 1] # REF(CLOSE, n+1)
ref_c_n2 = C[bar_idx - 2] # REF(CLOSE, n+2)
if ref_c_n1 <= 0 or ref_c_n2 <= 0:
return False, {}
zg_pre_hist = (ref_c_n1 - ref_c_n2) / ref_c_n2 * 100.0
cond2 = zg_pre_hist < 5.0 # COND2: ZG_PRE < 5
price_ref = C[bar_idx] # REF(CLOSE, n)
ma60_ref = ma60[bar_idx]
if ma60_ref <= 0:
return False, {}
cond3_approx = price_ref > ma60_ref * 1.02 # COND3 价>MA60*1.02
if not (cond2 and cond3_approx):
return False, {}
# ============================================================
# D-early. 先算 G点 序列(纯K线,零API开销),提前淘汰
# —— 仅在 g_cross 通过与大涨次数达标后才去调信息类接口,
# 可省掉绝大多数单股 get_more_info / get_stock_info 调用(真正的提速点)
# ============================================================
A, bb, zt_num = compute_gpoint_series(O, H, L, C)
a_bar = A[bar_idx]; bb_bar = bb[bar_idx]
a_prev_bar = A[bar_idx - 1]; bb_prev_bar = bb[bar_idx - 1]
g_cross = (a_bar > bb_bar) and (a_prev_bar <= bb_prev_bar) # CROSS(A, BB)
if not g_cross:
return False, {} # 提前退出,省去本股票所有 信息类 API 调用
zt_num_val = zt_num[bar_idx]
if np.isnan(zt_num_val):
zt_num_val = 0.0
zt_num_val = float(zt_num_val)
zt_num_ok = zt_num_val >= ztcs # 大涨次数>=ZTCS(ZTCS=0 时恒真)
if not zt_num_ok:
return False, {} # 纯K线即可判定,省去信息类 API 调用
# ============================================================
# B. 取扩展信息(量比/涨幅/总市值/流通市值)—— 仅在上面K线信号通过后调用
# ============================================================
mi = get_more_info_single(code)
fLianB = safe_float(mi.get("fLianB"), 0.0) # 实时量比 DYNAINFO(17)
Zsz = safe_float(mi.get("Zsz"), 0.0) # 总市值(亿) FINANCE(41)/1e8
Ltsz = safe_float(mi.get("Ltsz"), 0.0) # 流通市值(亿) FINANCE(40)/1e8
zaf = safe_float(mi.get("ZAF"), 0.0) # 今日涨幅%
zaf_yest = safe_float(mi.get("ZAFYesterday"), 0.0) # 昨日涨幅%
if n == 0:
# 当天实时模式
lr_n = fLianB if fLianB > 0 else estimate_amount_ratio_from_snapshot(code, pd.Series(V))
zg_n = zaf # 今日涨幅
zg_pre = zaf_yest # 昨日涨幅
ref_vol_n = V[-1]
g_liangbi = fLianB if fLianB > 0 else lr_n # G点 LB 用实时量比
xianliang, xl_found = get_realtime_xianliang(code)
vol_filter = True if not xl_found else (xianliang > 1.2) # VOL_FILTER 现量>1.2
eval_price = C[-1]
else:
# 历史日期模式(量比/现量用第 n 天的等价量)
ma5v = pd.Series(V).rolling(5).mean().values
ref_ma5v_n1 = ma5v[bar_idx - 1] # REF(MA5V, n+1)
if ref_ma5v_n1 <= 0:
return False, {}
ref_vol_n = V[bar_idx]
lr_n = ref_vol_n / ref_ma5v_n1 # LR_N
ref_c_n = C[bar_idx] # REF(CLOSE, n)
zg_n = (ref_c_n - ref_c_n1) / ref_c_n1 * 100.0
zg_pre = zg_pre_hist
g_liangbi = lr_n # G点 LB 用第 n 天量比
vol_filter = ref_vol_n > 0 # VOL_FILTER 历史模式:当日有量
eval_price = C[bar_idx]
# ============================================================
# C. 放量突破 公共条件
# ============================================================
# 量比条件:量比 >= 所填值(量倍);不再做 <3 的上限限制。
# 例如 量倍=2 → 量比>=2;量倍=3 → 量比>=3。
cond1 = lr_n >= liangbei
vol_valid = ref_vol_n > 0
ft_selected = (
cond1
and (zg_n > 2.0)
and (zg_n < zhangding)
and (zg_pre < 20.0)
and cond2
and cond3_approx
and vol_valid
)
# ============================================================
# D. G点 其余过滤(量比/总市值)—— 依赖扩展信息,已在上面计算
# ============================================================
lb_filter = g_liangbi >= lb # 量比>=LB
zsz_filter = (Zsz <= 0) or (Zsz <= zsz) # 总市值<=ZSZ(取不到则放宽)
# 放量突破 与 G点量比/总市值 任一不满足即可提前退出,省去 get_stock_info 调用
if not (ft_selected and lb_filter and zsz_filter):
return False, {}
# ---- 市净率 + ST 过滤(需 get_stock_info)----
info = get_stock_info_single(code)
if is_st_stock(info): # NONST
return False, {}
mgjzc = safe_float(info.get("J_mgjzc"), 0.0) # 每股净资产
if mgjzc > 0:
shi_jing_lv = C[bar_idx] / mgjzc
sjl_filter = shi_jing_lv <= sjl
else:
shi_jing_lv = 0.0
sjl_filter = False
# 流通市值(放量突破 LT<100 亿)——优先用 Ltsz,缺失时用已取得的 info 计算(避免二次调用)
if Ltsz <= 0:
lt_size = calc_lt_size(code, eval_price, info=info, more_info=None)
else:
lt_size = Ltsz
lt_filter = lt_size < 100.0
g_selected = (
g_cross
and vol_filter
and lb_filter
and zsz_filter
and sjl_filter
and zt_num_ok
and lt_filter
)
# ============================================================
# E. 组合结果
# ============================================================
selected = ft_selected and g_selected
# 现价相对 BB 的超出幅度 %(过线%):(现价 - BB)/BB * 100
if bb_bar and bb_bar > 0:
over_bb_pct = (eval_price - bb_bar) / bb_bar * 100.0
else:
over_bb_pct = 0.0
detail = {
"code": code,
"名称": info.get("Name", ""),
"量比": lr_n,
"涨幅": zg_n,
"昨涨": zg_pre,
"现价": eval_price,
"bb值": bb_bar,
"过线%": over_bb_pct,
"大涨次数": zt_num_val,
"总市值_亿": Zsz,
"市净率": shi_jing_lv,
"流通_亿": lt_size,
}
return selected, detail
def select_stocks(params=None, run_index=None):
"""
主选股函数。
返回: [(code, detail_dict), ...]
"""
if params is None:
params = PARAMS.copy()
run_tag = f"[第{run_index}轮] " if run_index else ""
log_info("=" * 80)
log_info(f"{run_tag}>>> 开始组合选股(放量突破 + G点买)")
log_info(
f"参数: 量倍={params['量倍']}, 涨顶={params['涨顶']}, 哪天={params['哪天']} | "
f"ZTCS={params['ZTCS']}, LB={params['LB']}, ZSZ={params['ZSZ']}, SJL={params['SJL']} | "
f"每批={params.get('每批数量', 200)}"
)
log_info("=" * 80)
# 1. 获取全市场并做代码前缀过滤
all_stocks = get_all_stocks()
stocks = code_prefix_filter(all_stocks)
# 2. 分批获取 K 线数据并做全条件筛选
batch_size = params.get("每批数量", 200)
results = []
total = len(stocks)
done = 0
for i in range(0, total, batch_size):
batch = stocks[i: i + batch_size]
raw = get_kline_data(batch, count=KLINE_COUNT)
if raw is None or "Close" not in raw:
log_warning(f"{run_tag}本批 K 线数据异常,跳过")
done += len(batch)
print_progress_bar(done, total, len(results), run_tag=run_tag)
continue
for code in batch:
if not all(f in raw and code in raw[f].columns for f in ("Open", "High", "Low", "Close", "Volume")):
continue
kline = {f: raw[f][code] for f in ("Open", "High", "Low", "Close", "Volume")}
try:
selected, detail = check_stock(code, kline, params)
except Exception as e:
log_error(f"{run_tag}处理 {code} 异常: {e}")
continue
if selected:
results.append((code, detail))
done += len(batch)
print_progress_bar(done, total, len(results), run_tag=run_tag)
# 进度条收尾:换行,固定为本轮最终状态
finish_progress_bar(done, total, len(results), run_tag=run_tag)
log_info(f"{run_tag}组合选股完成, 共 {len(results)} 只")
# 3. 结果明细不再在日志中打印(由网页输出负责),仅保留汇总
log_info(f"{run_tag}组合选股完成, 共 {len(results)} 只")
return results
# ================================================================
# 七、结果发送(容错链:send_result → send_user_block → send_message)
# ================================================================
def _safe_send_text(msg):
"""发送文本消息到通达信(选股结果提示),优先 send_message,其次 send_warn。"""
for attr in ("send_message", "send_warn"):
if hasattr(tq, attr):
try:
getattr(tq, attr)(msg)
return True
except Exception as e:
log_error(f"{attr} 发送失败: {e}")
return False
def _tdx_root():
"""推断通达信根目录(策略文件位于 <根>/PYPlugins/user/ 下)。"""
p = os.path.dirname(os.path.abspath(__file__))
# .../PYPlugins/user -> user -> PYPlugins -> 根
return os.path.dirname(os.path.dirname(p))
def _code_to_blk_format(code):
"""
将标准格式代码(如 '600000.SH' / '000001.SZ')转为 .blk 文件的 7 位格式:
首位为市场标志(沪市/9/5 开头=1,深市/0/3/2 开头=0),后接 6 位代码。
例:'600000.SH' -> '1600000','000001.SZ' -> '0000001'
"""
if "." not in code:
return code
num, market = code.split(".", 1)
num = num.zfill(6)
flag = "1" if market.upper() == "SH" else "0"
return flag + num
def clear_tjg_block():
"""
清空【临时条件股】。
官方示例 tdxdata_test.py 第 295 行:send_user_block 的 stock_list 传空列表即清空该板块
(block_code='' 对应临时条件股)。无需逐只删除,一次调用即可。
"""
try:
tq.send_user_block(block_code="", stock_list=[], show=False)
log_info("已清空【临时条件股】(旧结果已移除)")
return True
except Exception as e:
log_error(f"清空临时条件股失败: {e}")
return False
def verify_tjg(codes):
"""发送后读取 tjg.blk,确认选股结果已写入【临时条件股】。仅做日志核对,不影响主流程。"""
try:
blk_path = os.path.join(_tdx_root(), "T0002", "blocknew", "tjg.blk")
if not os.path.exists(blk_path):
log_warning(f"未找到临时条件股文件: {blk_path},无法校验(可能客户端尚未刷新)")
return
with open(blk_path, "r", encoding="utf-8", errors="ignore") as f:
content = f.read()
blk_codes = set(line.strip() for line in content.splitlines() if line.strip())
want = {_code_to_blk_format(c) for c in codes}
hit = want & blk_codes
miss = want - blk_codes
log_info(f"【临时条件股】校验:tjg.blk 共 {len(blk_codes)} 只;本次应写入 {len(want)} 只,"
f"命中 {len(hit)} 只" + (f",未命中 {len(miss)} 只(客户端可能延迟刷新)" if miss else ",全部命中 ✓"))
except Exception as e:
log_warning(f"读取 tjg.blk 校验失败(不影响选股结果): {e}")
def send_result_to_tdx(results):
"""
将选股结果发送到通达信客户端。
发送方式优先级(按当前 tq 版本可用情况自动选择):
1. tq.send_result("XG,1#代码|...") —— 直接进选股结果列表(本版本无此接口,跳过)
2. tq.send_user_block(block_code="", stock_list=[...]) —— 写入【临时条件股】列表
(官方示例确认:block_code 留空=临时条件股;'ZXG'=自选股)
3. tq.send_message(...) —— 文本提示,结果以日志为准
无论哪种方式,选股明细都会写入日志摘要,完整明细改由网页输出
(桌面 stock_selection_YYYYMMDD.html,见 generate_and_save_html)。
"""
if not results:
_safe_send_text("放量突破+G点买 选股:未选出股票")
log_info("未选出股票,已发送提示")
return
codes = [code for code, _ in results]
# 发送前是否清空【临时条件股】(PARAMS["清空临时条件股"],默认 True=全量替换旧结果)
clear_first = PARAMS.get("清空临时条件股", True)
# 方式1:send_result(XG 选股结果格式)
if hasattr(tq, "send_result"):
try:
parts = [f"1#{code}" for code in codes]
result_str = "XG," + "|".join(parts)
tq.send_result(result_str)
log_info(f"已通过 send_result 发送选股结果, 共 {len(codes)} 只")
return
except Exception as e:
log_error(f"send_result 失败: {e}")
# 方式2:写入通达信【临时条件股】列表
# 官方示例 tdxdata_test.py 明确:send_user_block 的 block_code 留空即写入【临时条件股】;
# block_code='ZXG' 才是自选股。源码签名:block_code='', stock_list=[], show=False。
# stock_list 必须是标准格式(6位代码+市场后缀,如 '600000.SH' / '000001.SZ'),
# 本策略的 code 已是该格式,可直接传入。
if hasattr(tq, "send_user_block"):
try:
if clear_first:
clear_tjg_block()
tq.send_user_block(block_code="", stock_list=codes, show=False)
log_info(f"已将 {len(codes)} 只股票写入通达信【临时条件股】列表")
# ---- 发送后验证:直接读取 tjg.blk 确认是否落盘 ----
verify_tjg(codes)
return
except Exception as e:
log_error(f"send_user_block 失败: {e}")
# 方式3:文本提示
ok = _safe_send_text("放量突破+G点买 选出 " + ",".join(codes))
if ok:
log_info("已通过消息发送选股结果(通达信客户端查看)")
else:
log_warning("当前 tq 版本无可用发送接口,选股结果请以日志为准")
# ================================================================
# 八、网页结果生成(替代日志明细打印,纯本地 HTML 双击即可打开)
# ================================================================
def _np_default(o):
"""json 序列化时把 numpy 类型转成原生 python 类型。"""
if isinstance(o, np.floating):
return float(o)
if isinstance(o, np.integer):
return int(o)
if isinstance(o, np.ndarray):
return o.tolist()
return str(o)
def load_state():
"""
读取累计选股状态(用于跨轮/跨天对比)。
返回 {"stocks": {code: {...}}};不存在/损坏/旧格式/非当天数据时返回空的累计结构。
跨天处理:状态文件只服务于“当天”,不保留多天数据。若读到的是昨天或更早的
旧数据(date != 今天),直接当空状态返回(即清空重新开始累计),避免隔夜的
历史候选残留为灰色“备选观察”,污染新一轮的对比。
"""
try:
if not os.path.exists(STATE_FILE):
return {"stocks": {}}
with open(STATE_FILE, "r", encoding="utf-8") as f:
obj = json.load(f)
if not isinstance(obj, dict) or "stocks" not in obj:
# 旧版格式(仅存上轮 results)不兼容累计语义 -> 重新开始累计
return {"stocks": {}}
# ---- 跨天判断:不是当天的旧数据,直接清空 ----
today = datetime.now().strftime("%Y-%m-%d")
if obj.get("date") != today:
log_info(f"状态文件非当天数据({obj.get('date')} ≠ {today}),已清空重新开始累计")
return {"stocks": {}}
return obj
except Exception:
return {"stocks": {}}
def save_state(state, round_dt):
"""保存累计状态(含每只股票的加入时间/状态)到状态文件。state: {"stocks": {...}}"""
try:
state["date"] = round_dt.strftime("%Y-%m-%d") # 跨天判断用
state["updated"] = round_dt.strftime("%Y-%m-%d %H:%M")
os.makedirs(os.path.dirname(STATE_FILE), exist_ok=True)
with open(STATE_FILE, "w", encoding="utf-8") as f:
json.dump(state, f, ensure_ascii=False, default=_np_default)
except Exception as e:
log_warning(f"保存状态失败(不影响本次选股): {e}")
def to_row(detail, code, dim, time_str):
"""
把 detail 字典转成网页表格的一行数据。
dim=True 表示备选观察(曾入选·本轮落选)。
time_str 为加入时间:选中行=首次入选时间,备选行=加入备选观察的时间。
"""
def f2(k):
try:
return round(float(detail[k]), 2)
except Exception:
return 0.0
return {
"time": time_str or "",
"code": code,
"name": detail.get("名称", "") or "-",
"lb": f2("量比"),
"zf": f2("涨幅"),
"yz": f2("昨涨"),
"price": f2("现价"),
"bb": f2("bb值"),
"over": f2("过线%"),
"zt": int(round(float(detail.get("大涨次数", 0) or 0))),
"cap": round(float(detail.get("总市值_亿", 0) or 0), 1),
"pb": f2("市净率"),
"float": round(float(detail.get("流通_亿", 0) or 0), 1),
"dim": dim,
}
def build_html(rows, round_dt, run_index, n_curr, n_dim):
"""根据行数据构造完整 HTML 字符串(内联 CSS/JS,无外部依赖)。"""
date_str = round_dt.strftime("%Y-%m-%d")
time_str = round_dt.strftime("%H:%M")
data_json = json.dumps(rows, ensure_ascii=False)
return f"""<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>组合选股结果 - {date_str}</title>
<style>
:root {{
--up: #e23a3a;
--down: #1aa260;
--bg: #f6f7f9;
--border: #e3e6ea;
--text: #1d2129;
--muted: #86909c;
--head: #f2f3f5;
--accent: #3370ff;
}}
* {{ box-sizing: border-box; }}
body {{
margin: 0;
font-family: "Segoe UI", "Microsoft YaHei", -apple-system, sans-serif;
background: var(--bg);
color: var(--text);
padding: 24px;
}}
h1 {{ font-size: 20px; margin: 0 0 4px; }}
.sub {{ color: var(--muted); font-size: 13px; margin-bottom: 16px; }}
.legend {{ display: inline-block; margin-left: 12px; font-size: 12px; color: var(--muted); }}
.legend .dot {{ display: inline-block; width: 9px; height: 9px; border-radius: 2px; background: #9aa0a6; margin: 0 3px 0 8px; vertical-align: middle; }}
.table-wrap {{
background: #fff;
border: 1px solid var(--border);
border-radius: 10px;
overflow: hidden;
box-shadow: 0 1px 3px rgba(0,0,0,.04);
max-width: 1100px;
}}
table {{ border-collapse: collapse; width: 100%; font-size: 13px; }}
thead th {{
background: var(--head);
text-align: right;
padding: 9px 9px;
font-weight: 600;
color: #4e5969;
border-bottom: 1px solid var(--border);
cursor: pointer;
user-select: none;
white-space: nowrap;
position: sticky;
top: 0;
}}
thead th.nosort {{ cursor: default; }}
thead th:first-child, tbody td:first-child {{ text-align: center; color: var(--muted); }}
thead th:nth-child(2), tbody td:nth-child(2),
thead th:nth-child(3), tbody td:nth-child(3),
thead th:nth-child(8), tbody td:nth-child(8) {{ text-align: left; }}
thead th .arrow {{ font-size: 10px; color: var(--accent); margin-left: 3px; opacity: 0; }}
thead th.sorted .arrow {{ opacity: 1; }}
tbody td {{
padding: 7px 9px;
text-align: right;
border-bottom: 1px solid #f2f3f5;
white-space: nowrap;
}}
tbody tr:hover {{ background: #f7faff; }}
tbody tr:last-child td {{ border-bottom: none; }}
tbody tr.dim td {{ color: #9aa0a6; background: #fafbfc; }}
.code {{ font-family: "Consolas", monospace; color: var(--muted); }}
.code a {{ color: var(--muted); text-decoration: none; }}
.code a:hover {{ color: var(--accent); text-decoration: underline; }}
.name {{ font-weight: 600; }}
.up {{ color: var(--up); }}
.down {{ color: var(--down); }}
thead th:nth-child(14), tbody td:nth-child(14) {{ text-align: center; }}
td.review-td {{ cursor: pointer; }}
.rv-focus {{ color: var(--up); font-weight: 600; }}
.rv-drop {{ color: var(--muted); text-decoration: line-through; }}
.rv-none {{ color: #c9cdd4; }}
.foot {{ margin-top: 12px; color: var(--muted); font-size: 12px; }}
</style>
</head>
<body>
<h1>组合选股结果<span class="legend"><span class="dot"></span>灰色 = 曾入选·本轮落选(备选观察,长期保留)</span></h1>
<div class="sub">第{run_index}轮 · {date_str} {time_str} · 当前 {n_curr} 只 · 备选 {n_dim} 只 · 点击表头排序(代码 / 简称 / BB值 不可排序)</div>
<div class="table-wrap">
<table id="stockTable">
<thead>
<tr>
<th data-key="time" data-type="str">时间<span class="arrow"></span></th>
<th data-key="code" data-type="str" class="nosort">代码</th>
<th data-key="name" data-type="str" class="nosort">简称</th>
<th data-key="lb" data-type="num">量比<span class="arrow"></span></th>
<th data-key="zf" data-type="num">涨幅%<span class="arrow"></span></th>
<th data-key="yz" data-type="num">昨涨%<span class="arrow"></span></th>
<th data-key="price" data-type="num">现价<span class="arrow"></span></th>
<th data-key="bb" data-type="num" class="nosort">BB值</th>
<th data-key="over" data-type="num">过线%<span class="arrow"></span></th>
<th data-key="zt" data-type="num">大涨<span class="arrow"></span></th>
<th data-key="cap" data-type="num">总市值(亿)<span class="arrow"></span></th>
<th data-key="pb" data-type="num">市净率<span class="arrow"></span></th>
<th data-key="float" data-type="num">流通(亿)<span class="arrow"></span></th>
<th data-key="review" data-type="review">审核<span class="arrow"></span></th>
</tr>
</thead>
<tbody></tbody>
</table>
</div>
<div class="foot">纯本地 HTML,无需服务器 / PHP,双击即可打开。涨幅为正显示红色(A股习惯)、为负显示绿色;灰色行为曾经入选但本轮未再入选的备选观察股(一旦入选即长期保留观察)。时间列:选中股=首次入选时间,备选股=加入备选观察的时间。点击"代码"可在新标签页打开同花顺网页版个股行情。审核列:点击单元格循环切换 空→关注→放弃→空,当天保存在本浏览器(localStorage,过期自动清理),刷新/重开不丢。</div>
<script>
const data = {data_json};
// ===== 审核列(localStorage,按 日期+代码 存储,当天有效)=====
const PAGE_DATE = "{date_str}";
const RV_CYCLE = ["", "关注", "放弃"];
const RV_RANK = {{"关注": 0, "放弃": 1, "": 2}};
const rvKey = code => "ssrv_" + PAGE_DATE + "_" + code;
const rvClass = v => v === "关注" ? "rv-focus" : (v === "放弃" ? "rv-drop" : "rv-none");
// 读取每只股票的审核结果(浏览器关闭/刷新后仍在)
data.forEach(r => {{
let v = "";
try {{ v = localStorage.getItem(rvKey(r.code)) || ""; }} catch (e) {{}}
r.review = RV_CYCLE.indexOf(v) >= 0 ? v : "";
}});
// 顺手清理非当天的旧审核数据,避免长期堆积
try {{
const keep = "ssrv_" + PAGE_DATE + "_";
Object.keys(localStorage).forEach(k => {{
if (k.indexOf("ssrv_") === 0 && k.indexOf(keep) !== 0) localStorage.removeItem(k);
}});
}} catch (e) {{}}
const tbody = document.querySelector("#stockTable tbody");
const headers = document.querySelectorAll("#stockTable thead th");
let currentKey = null;
let ascending = true;
function sign(v) {{ return v >= 0 ? "+" : ""; }}
function render(rows) {{
tbody.innerHTML = rows.map(r => {{
const zfCls = r.dim ? "" : (r.zf >= 0 ? "up" : "down");
const yzCls = r.dim ? "" : (r.yz >= 0 ? "up" : "down");
const overCls = r.dim ? "" : (r.over >= 0 ? "up" : "down");
return `<tr ${{r.dim ? 'class="dim"' : ''}}>
<td>${{r.time}}</td>
<td class="code"><a href="https://stockpage.10jqka.com.cn/${{r.code.split(".")[0]}}/" target="_blank" rel="noopener" title="在同花顺网页版打开个股行情">${{r.code}}</a></td>
<td class="name">${{r.name}}</td>
<td>${{r.lb.toFixed(2)}}</td>
<td class="${{zfCls}}">${{sign(r.zf)}}${{r.zf.toFixed(2)}}</td>
<td class="${{yzCls}}">${{sign(r.yz)}}${{r.yz.toFixed(2)}}</td>
<td>${{r.price.toFixed(2)}}</td>
<td>${{r.bb.toFixed(2)}}</td>
<td class="${{overCls}}">${{sign(r.over)}}${{r.over.toFixed(2)}}</td>
<td>${{r.zt}}</td>
<td>${{r.cap.toFixed(1)}}</td>
<td>${{r.pb.toFixed(2)}}</td>
<td>${{r.float.toFixed(1)}}</td>
<td class="review-td ${{rvClass(r.review)}}" data-code="${{r.code}}" title="点击切换:空 → 关注 → 放弃 → 空">${{r.review || "—"}}</td>
</tr>`;
}}).join("");
}}
function sortBy(key, type) {{
const dim = data.filter(r => r.dim);
const norm = data.filter(r => !r.dim);
const cmp = (a, b) => {{
let av = a[key], bv = b[key];
if (type === "num") {{ return ascending ? av - bv : bv - av; }}
if (type === "review") {{
const ra = RV_RANK[a.review || ""], rb = RV_RANK[b.review || ""];
return ascending ? ra - rb : rb - ra;
}}
return ascending ? String(av).localeCompare(String(bv), "zh")
: String(bv).localeCompare(String(av), "zh");
}};
norm.sort(cmp);
dim.sort(cmp); // 备选观察股始终置底
render(norm.concat(dim));
}}
headers.forEach(th => {{
if (th.classList.contains("nosort")) return; // 代码/简称/BB值 不参与排序
th.addEventListener("click", () => {{
const key = th.dataset.key;
const type = th.dataset.type;
if (currentKey === key) {{ ascending = !ascending; }}
else {{ currentKey = key; ascending = true; }}
headers.forEach(h => {{
h.classList.remove("sorted");
const ar = h.querySelector(".arrow");
if (ar) ar.textContent = "";
}});
th.classList.add("sorted");
const ar2 = th.querySelector(".arrow");
if (ar2) ar2.textContent = ascending ? "▲" : "▼";
sortBy(key, type);
}});
}});
// 点击审核单元格:循环切换 空→关注→放弃→空,立即写入 localStorage
tbody.addEventListener("click", ev => {{
const td = ev.target.closest("td.review-td");
if (!td) return;
const code = td.dataset.code;
const row = data.find(r => r.code === code);
if (!row) return;
row.review = RV_CYCLE[(RV_CYCLE.indexOf(row.review) + 1) % RV_CYCLE.length];
try {{ localStorage.setItem(rvKey(code), row.review); }} catch (e) {{}}
td.className = "review-td " + rvClass(row.review);
td.textContent = row.review || "—";
}});
// 默认排序:打开/刷新即按“时间”倒序(最新选出的排最上方);备选观察行仍置底
(function defaultSort() {{
currentKey = "time";
ascending = false;
const th = document.querySelector('#stockTable thead th[data-key="time"]');
if (th) {{
th.classList.add("sorted");
const ar = th.querySelector(".arrow");
if (ar) ar.textContent = "▼";
}}
sortBy("time", "str");
}})();
</script>
</body>
</html>
"""
def generate_and_save_html(results, round_dt, run_index):
"""
生成本轮选股网页(替代日志明细打印)。
对比逻辑:累计状态中【曾经入选、本轮落选】的股票 → 灰色、置底(备选观察),
且一旦入选便长期保留(下次若再次入选则恢复为正常行)。
- 时间语义:选中行显示"首次入选时间";备选行显示"加入备选观察的时间"。
同轮内新入选股票的时间一致为 round_dt 的 HH:MM;历史股的加入时间沿用各自记录。
- 文件按日期命名放到桌面:stock_selection_YYYYMMDD.html。
返回: (网页路径, 备选数量)
"""
round_time = round_dt.strftime("%H:%M")
state = load_state()
stocks = state.setdefault("stocks", {})
curr = {code: detail for code, detail in results}
curr_codes = set(curr.keys())
rows = []
# 1) 当前选中:新增或刷新累计状态;曾为备选的回到正常
for code, detail in results:
st = stocks.get(code)
if st is None:
st = {"detail": dict(detail), "first_join": round_time,
"status": "sel", "res_join": None}
stocks[code] = st
else:
st["detail"] = dict(detail) # 刷新最新快照
if not st.get("first_join"):
st["first_join"] = round_time
if st.get("status") == "res": # 从备选回到选中
st["status"] = "sel"
st["res_join"] = None
# 选中行时间 = 首次入选时间(保持恒定,便于识别新加入的票)
rows.append(to_row(st["detail"], code, False, st["first_join"]))
# 2) 备选观察:累计中曾入选、但本轮未再入选(status 切到 res,记录加入备选时间)
for code, st in stocks.items():
if code in curr_codes:
continue
if st.get("status") != "res":
st["status"] = "res"
st["res_join"] = round_time
rows.append(to_row(st["detail"], code, True, st["res_join"]))
n_curr = len(results)
n_dim = sum(1 for code in stocks if code not in curr_codes)
html = build_html(rows, round_dt, run_index, n_curr, n_dim)
try:
os.makedirs(DESKTOP_DIR, exist_ok=True)
html_path = os.path.join(DESKTOP_DIR, f"stock_selection_{round_dt.strftime('%Y%m%d')}.html")
with open(html_path, "w", encoding="utf-8") as f:
f.write(html)
log_info(f"已生成网页: {html_path}(本轮 {n_curr} 只,备选 {n_dim} 只)")
except Exception as e:
log_error(f"生成网页失败: {e}")
# 保存累计状态(供后续轮次/次日继续累计对比)
save_state(state, round_dt)
return html_path, n_dim
# ================================================================
# 九、程序入口
# ================================================================
if __name__ == "__main__":
loop = PARAMS.get("循环运行", True)
interval = max(0, int(PARAMS.get("循环间隔秒", 10)))
max_runs = int(PARAMS.get("最大循环次数", 0))
run_idx = 0
try:
while True:
run_idx += 1
if max_runs > 0 and run_idx > max_runs:
log_info(f">>> 已达到最大循环次数 {max_runs},策略停止")
break
if run_idx > 1:
log_info("=" * 60)
log_info(f">>> 第 {run_idx} 轮开始(距上轮 {interval}s)")
round_dt = datetime.now() # 本轮统一时间(同轮所有股票一致,精确到分钟)
results = select_stocks(params=PARAMS, run_index=run_idx)
# 生成本轮选股网页(含累计对比的备选观察股),并写临时条件股
generate_and_save_html(results, round_dt, run_idx)
send_result_to_tdx(results)
if not loop:
break
log_info(f">>> 本轮结束,{interval}s 后开始下一轮(盘中持续监控;Ctrl+C 可停止)")
time.sleep(interval)
except KeyboardInterrupt:
log_info(">>> 收到 Ctrl+C,策略已停止")
except Exception as e:
log_error(f"策略运行异常: {e}")
log_error(traceback.format_exc())

0 comments:
发表评论