2026年8月28日星期五

保存一个通达信的tq公式

文件是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())


Ditulis Oleh : alipada // 八月 28, 2026
Kategori:

0 comments: