137 lines
4.3 KiB
Python
137 lines
4.3 KiB
Python
#!/usr/bin/env python
|
||
# -*- coding: utf-8 -*-
|
||
|
||
import logging as log
|
||
from logging.handlers import TimedRotatingFileHandler
|
||
import os
|
||
import sys
|
||
import time
|
||
import config
|
||
from dataclasses import dataclass
|
||
import yaml
|
||
import httpx
|
||
|
||
PROJECT_ROOT = os.path.dirname(os.path.abspath(__file__))
|
||
GLOBAL_CONFIG_PATH = os.path.join(PROJECT_ROOT, "etc", "_global.yaml")
|
||
if PROJECT_ROOT not in sys.path:
|
||
sys.path.insert(0, PROJECT_ROOT)
|
||
|
||
from sdk import APIError, Client
|
||
from strategy.trend.boot import StartTrend
|
||
|
||
|
||
@dataclass(frozen=True, slots=True)
|
||
class StrategyDefinition:
|
||
mutex_scope: str
|
||
start_strategy: object
|
||
|
||
|
||
STRATEGIES = {
|
||
"trend": StrategyDefinition("Trend", StartTrend),
|
||
}
|
||
|
||
def require_windows() -> bool:
|
||
return os.name == "nt"
|
||
|
||
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:
|
||
log.error(f"无法创建互斥锁,错误代码:{ctypes.get_last_error()}")
|
||
return False
|
||
if ctypes.get_last_error() == error_already_exists:
|
||
log.error("程序已在运行中,无法启动多个实例")
|
||
kernel32.CloseHandle(handle)
|
||
return False
|
||
log.info(f"成功获取互斥锁:{mutex_name}")
|
||
return True
|
||
except Exception as exc:
|
||
log.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()
|
||
log.info(f"API 服务已连通:{config.global_config.qmt_base_url}")
|
||
client.close()
|
||
return
|
||
except (APIError, httpx.RequestError) as exc:
|
||
log.warning(
|
||
"API 服务未就绪:%s,%g 秒后重试:%s",
|
||
config.global_config.qmt_base_url,
|
||
retry_interval,
|
||
exc,
|
||
)
|
||
retry_event.wait(retry_interval)
|
||
|
||
|
||
def configure_logging(data_dir: str) -> None:
|
||
"""同时输出控制台日志和按天轮转的文本日志。"""
|
||
log_dir = os.path.join(data_dir, "logs")
|
||
os.makedirs(log_dir, exist_ok=True)
|
||
root = log.getLogger()
|
||
root.setLevel(log.INFO)
|
||
formatter = log.Formatter("%(asctime)s [%(levelname)s] %(message)s")
|
||
if not root.handlers:
|
||
console = log.StreamHandler()
|
||
console.setFormatter(formatter)
|
||
root.addHandler(console)
|
||
file_handler = TimedRotatingFileHandler(
|
||
os.path.join(log_dir, "py-client.log"),
|
||
when="midnight",
|
||
interval=1,
|
||
backupCount=30,
|
||
encoding="utf-8",
|
||
)
|
||
file_handler.setFormatter(formatter)
|
||
root.addHandler(file_handler)
|
||
|
||
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:
|
||
try:
|
||
if not require_windows():
|
||
log.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()")
|
||
configure_logging(config.global_config.qmt_data_dir)
|
||
wait_for_qmt_api()
|
||
|
||
STRATEGIES[config.account_config.strategy].start_strategy()
|
||
return 0
|
||
except (OSError, yaml.YAMLError, ValueError, RuntimeError, KeyError) as exc:
|
||
print(f"启动失败: {exc}", file=sys.stderr, flush=True)
|
||
wait_for_any_key()
|
||
return 1
|
||
|
||
|
||
if __name__ == "__main__":
|
||
sys.exit(main())
|