#!/usr/bin/env python # -*- coding: utf-8 -*- """策略客户端启动入口:加载配置、拉起后台任务、按 ``strategy`` 分派策略主循环。 一个进程只跑一个策略(``account_config.strategy``)。IPO 打新、大盘刷新、 趋势数据采集由后台调度线程承担,主线程跑策略自己的循环。 """ import logging import os import sys from datetime import datetime from apscheduler.schedulers.background import BackgroundScheduler import config from dataclasses import dataclass import yaml import httpx PROJECT_ROOT = os.path.dirname(os.path.abspath(__file__)) LOG_DIR = os.path.join(PROJECT_ROOT, "logs") os.makedirs(LOG_DIR, exist_ok=True) LOG_FILE = os.path.join(LOG_DIR, datetime.now().strftime("%Y%m%d.log")) if PROJECT_ROOT not in sys.path: sys.path.insert(0, PROJECT_ROOT) logging.basicConfig( level=logging.INFO, format='[%(levelname)s] %(asctime)s %(message)s', datefmt='%Y-%m-%d %H:%M:%S', handlers=[ logging.StreamHandler(), logging.FileHandler(LOG_FILE, encoding="utf-8"), ], ) logging.getLogger("apscheduler").setLevel(logging.WARNING) logging.getLogger("httpx").setLevel(logging.WARNING) from sdk import APIError, Client from libs.market import refresh_market from libs.collector import submit_trend_data from strategy.trend.boot import StartTrend from strategy.zt.boot import StartZT from strategy.etf.boot import StartETF from strategy.ipo import AutoBuyIpo @dataclass(slots=True) class StrategyDefinition: mutex_scope: str start_strategy: object STRATEGIES = { "trend": StrategyDefinition("Trend", StartTrend), "zt": StrategyDefinition("ZT", StartZT), "etf": StrategyDefinition("ETF", StartETF), } def require_windows() -> bool: return os.name == "nt" def describe_etf_config() -> str: """启动日志用的 ETF 配置概览:文件缺失时明确说明,不在这里报错。""" etf_cfg = getattr(config, "etf_config", None) if etf_cfg is None: return "未找到 _etf.yaml(仅 etf 策略需要)" return f"标的={len(etf_cfg.symbols)} 只,代码={'/'.join(etf_cfg.codes)}" def check_single_instance(project_root: str) -> bool: """使用 Windows 命名互斥锁保证单实例。""" try: import ctypes error_already_exists = 183 invalid_handle_value = -1 safe_path = project_root.replace(":", "_").replace("\\", "_") mutex_name = f"Global\\QMT_System_{safe_path}" kernel32 = ctypes.WinDLL("kernel32", use_last_error=True) handle = kernel32.CreateMutexW(None, True, mutex_name) if not handle or handle == invalid_handle_value: logging.error(f"无法创建互斥锁,错误代码:{ctypes.get_last_error()}") return False if ctypes.get_last_error() == error_already_exists: logging.error("程序已在运行中,无法启动多个实例") kernel32.CloseHandle(handle) return False logging.info(f"成功获取互斥锁:{mutex_name}") return True except Exception as exc: logging.error(f"单实例检测失败:{exc}", exc_info=True) return False def wait_for_qmt_api(retry_interval: float = 5.0) -> None: """循环检查 API 地址,连通后才返回。""" client = Client(config.global_config.qmt_base_url, config.global_config.qmt_token, config.HTTP_TIMEOUT) retry_event = __import__("threading").Event() while not retry_event.is_set(): try: client.assets() logging.info(f"API 服务已连通:{config.global_config.qmt_base_url}") client.close() return except (APIError, httpx.RequestError) as exc: logging.warning( "API 服务未就绪:%s,%g 秒后重试:%s", config.global_config.qmt_base_url, retry_interval, exc, ) retry_event.wait(retry_interval) def wait_for_any_key() -> None: print("按任意键退出...", flush=True) if os.name == "nt": import msvcrt msvcrt.getch() elif sys.stdin.isatty(): sys.stdin.read(1) def main() -> int: scheduler = None try: if not require_windows(): logging.error("本程序仅支持 Windows 环境运行") return 1 if not check_single_instance(PROJECT_ROOT): return 1 config.load() if config.global_config is None or config.account_config is None: raise RuntimeError("配置尚未加载,请先调用 config.load()") strategy = config.account_config.strategy start = STRATEGIES.get(strategy) if start is None: raise ValueError( f"未知策略 strategy={strategy!r},可选: {', '.join(sorted(STRATEGIES))}" ) logging.info( "配置已加载:主机=%s,账户=%s,策略=%s,ETF配置=%s", getattr(config.account_config, "host_key", "-"), getattr(config.account_config, "account_id", "-"), strategy, describe_etf_config(), ) wait_for_qmt_api() # 后台调度不受趋势策略永久循环阻塞;同一时刻最多执行一个实例。 scheduler = BackgroundScheduler( timezone="Asia/Shanghai", job_defaults={"coalesce": True, "max_instances": 1}, ) scheduler.add_job( AutoBuyIpo, trigger="cron", hour="10,14", minute=0, id="auto_buy_ipo", replace_existing=True, ) scheduler.add_job( refresh_market, trigger="interval", minutes=1, args=[config.global_config.api_host], id="market_refresh", replace_existing=True, next_run_time=datetime.now(), ) scheduler.add_job( submit_trend_data, trigger="interval", minutes=5, id="trend_collector", replace_existing=True, ) logging.info("趋势策略数据提交任务已注册:每5分钟读取缓存提交") scheduler.start() logging.info("IPO 自动打新定时任务已启动:每日 10:00、14:00") logging.info("大盘信号后台刷新已启动:每分钟一次") STRATEGIES[strategy].start_strategy() logging.info("%s 策略主循环已结束", strategy) return 0 except (OSError, yaml.YAMLError, ValueError, RuntimeError, KeyError) as e: print(f"启动失败: {e}", file=sys.stderr, flush=True) logging.exception("启动失败") wait_for_any_key() return 1 finally: if scheduler is not None and scheduler.running: scheduler.shutdown(wait=True) if __name__ == "__main__": sys.exit(main())