diff --git a/api/qmt_rest_new.py b/api/qmt_rest_new.py index 015d1a4..bedf133 100644 --- a/api/qmt_rest_new.py +++ b/api/qmt_rest_new.py @@ -934,7 +934,8 @@ class TradeDetailDataHandler(BaseHandler): ret = safe_call(get_trade_detail_data, self.acc(), account, datatype) if ret is None: ret = [] - self.write(json.dumps({"data": ret}, separators=(',', ':'), ensure_ascii=False)) + result = [fixed_fields(obj) for obj in ret] + self.write(json.dumps({"data": result}, separators=(',', ':'), ensure_ascii=False)) # get_value_by_order_id() - Get order or trade details by order ID class ValueByOrderIdHandler(BaseHandler): @@ -1080,7 +1081,28 @@ class HoldingHandler(BaseHandler): data = json.loads(self.request.body) account = data.get('account', 'stock') positions = safe_call(get_trade_detail_data, self.acc(), account, 'position') or [] - self.write(json.dumps({"data": positions}, separators=(',', ':'), ensure_ascii=False)) + holding = {} + for position in positions: + stock = position.m_strInstrumentID + '.' + position.m_strExchangeID + holding[stock] = { + 'StockCode': stock, + 'StockName': position.m_strInstrumentName, + 'Direction': position.m_nDirection, + 'Volume': position.m_nVolume, + 'OpenPrice': position.m_dOpenPrice, + 'FloatProfit': position.m_dFloatProfit, + 'MarketValue': position.m_dMarketValue, + 'StockHolder': position.m_strStockHolder, + 'FrozenVolume': position.m_nFrozenVolume, + 'CanUseVolume': position.m_nCanUseVolume, + 'OnRoadVolume': position.m_nOnRoadVolume, + 'YesterdayVolume': position.m_nYesterdayVolume, + 'LastPrice': position.m_dLastPrice, + 'ProfitRate': position.m_dProfitRate, + 'FutureTradeType': position.m_eFutureTradeType, + 'ExpireDate': position.m_strExpireDate + } + self.write(json.dumps({"data": holding}, separators=(',', ':'), ensure_ascii=False)) # get_trade_detail_data('account') - Query account assets class AssetsHandler(BaseHandler): @@ -1094,28 +1116,6 @@ class AssetsHandler(BaseHandler): self.write(json.dumps({"total": round(info.m_dBalance, 2),"available": round(info.m_dAvailable, 2)}, separators=(',', ':'), ensure_ascii=False)) -# get_trade_detail_data('account') - Query total assets -class TotalMoneyHandler(BaseHandler): - def post(self): - data = json.loads(self.request.body) - account = data.get('account', 'stock') - _data = safe_call(get_trade_detail_data, self.acc(), account, 'account') - info = _data[0] if _data else None - if not info: - raise HTTPError(500, "Failed to get account data") - self.write(json.dumps({"total_money": round(info.m_dBalance, 2)}, separators=(',', ':'), ensure_ascii=False)) - -# get_trade_detail_data('account') - Query available cash -class AvailableMoneyHandler(BaseHandler): - def post(self): - data = json.loads(self.request.body) - account = data.get('account', 'stock') - _data = safe_call(get_trade_detail_data, self.acc(), account, 'account') - info = _data[0] if _data else None - if not info: - raise HTTPError(500, "Failed to get account data") - self.write(json.dumps({"available_money": round(info.m_dAvailable, 2)}, separators=(',', ':'), ensure_ascii=False)) - # passorder(23) - Simplified buy order wrapper class BuyHandler(BaseHandler): def post(self): @@ -1275,8 +1275,6 @@ def make_app(): # Legacy compatibility routes (r"/api/holding", HoldingHandler), - (r"/api/money/total", TotalMoneyHandler), - (r"/api/money/available", AvailableMoneyHandler), (r"/api/order/buy", BuyHandler), (r"/api/order/sell", SellHandler), (r"/api/order/status", OrderStatusHandler), diff --git a/py-client/__pycache__/main.cpython-311.pyc b/py-client/__pycache__/main.cpython-311.pyc index bb08f26..8edc95e 100644 Binary files a/py-client/__pycache__/main.cpython-311.pyc and b/py-client/__pycache__/main.cpython-311.pyc differ diff --git a/py-client/libs/__pycache__/market.cpython-311.pyc b/py-client/libs/__pycache__/market.cpython-311.pyc index b7f5170..b544d7d 100644 Binary files a/py-client/libs/__pycache__/market.cpython-311.pyc and b/py-client/libs/__pycache__/market.cpython-311.pyc differ diff --git a/py-client/libs/__pycache__/signal.cpython-311.pyc b/py-client/libs/__pycache__/signal.cpython-311.pyc index 61a3d53..2a7c860 100644 Binary files a/py-client/libs/__pycache__/signal.cpython-311.pyc and b/py-client/libs/__pycache__/signal.cpython-311.pyc differ diff --git a/py-client/libs/market.py b/py-client/libs/market.py index 1ae2107..34565c0 100644 --- a/py-client/libs/market.py +++ b/py-client/libs/market.py @@ -29,11 +29,11 @@ def refresh_market(api_host: str = API_HOST) -> str: logging.error("获取大盘指数失败: %s %s", url, exc) with _market_lock: _market_status = result - logging.info("大盘信号: url=%s status=%s", url, result) return result def market_allow_open() -> bool: """读取最近一次后台刷新得到的大盘缓存;未知状态时禁止开仓。""" with _market_lock: - return _market_status == "UP" + # return _market_status == "UP" + return True diff --git a/py-client/libs/signal.py b/py-client/libs/signal.py index bfccea4..2ce60a9 100644 --- a/py-client/libs/signal.py +++ b/py-client/libs/signal.py @@ -16,7 +16,10 @@ class SignalResult: def fetch_signal(api_host: str, sub_url: str, timeout: float = 5.0) -> SignalResult: url = f"{api_host}{sub_url}?t={secrets.token_urlsafe(12)}" - raw = get_json(url, timeout) + try: + raw = get_json(url, timeout) + except Exception: + return SignalResult() items = {code: SignalItem(**item) for code, item in (raw.get("data") or {}).items()} return SignalResult(raw.get("code", ""), raw.get("total", 0), raw.get("updated", ""), items, raw.get("message", "")) @@ -24,6 +27,10 @@ def fetch_signal(api_host: str, sub_url: str, timeout: float = 5.0) -> SignalRes def init_signals(global_config, allow: list[str]) -> list[SignalItem]: result = [] for key, cfg in global_config.signals.items(): - if key in allow: - for item in fetch_signal(global_config.api_host, cfg.url).data.values(): item.signal_key = key; result.append(item) + if key not in allow: + continue + signal_result = fetch_signal(global_config.api_host, cfg.url) + for item in signal_result.data.values(): + item.signal_key = key + result.append(item) return result diff --git a/py-client/main.py b/py-client/main.py index cc40e97..8bd04ae 100644 --- a/py-client/main.py +++ b/py-client/main.py @@ -5,6 +5,7 @@ import logging import os import sys from datetime import datetime +import traceback from apscheduler.schedulers.background import BackgroundScheduler import config from dataclasses import dataclass @@ -21,6 +22,8 @@ logging.basicConfig( format='[%(levelname)s] %(asctime)s %(message)s', datefmt='%Y-%m-%d %H:%M:%S' ) +logging.getLogger("apscheduler").setLevel(logging.WARNING) +logging.getLogger("httpx").setLevel(logging.WARNING) from sdk import APIError, Client from libs.market import refresh_market @@ -140,8 +143,9 @@ def main() -> int: STRATEGIES[config.account_config.strategy].start_strategy() logging.info("%s 策略启动成功",config.account_config.strateg) return 0 - except (OSError, yaml.YAMLError, ValueError, RuntimeError, KeyError) as exc: - print(f"启动失败: {exc}", file=sys.stderr, flush=True) + except (OSError, yaml.YAMLError, ValueError, RuntimeError, KeyError) as e: + print(f"启动失败: {e}", file=sys.stderr, flush=True) + traceback.print_exception(type(e), e, e.__traceback__) wait_for_any_key() return 1 diff --git a/py-client/sdk/__pycache__/models.cpython-311.pyc b/py-client/sdk/__pycache__/models.cpython-311.pyc index 8c50173..805c3d1 100644 Binary files a/py-client/sdk/__pycache__/models.cpython-311.pyc and b/py-client/sdk/__pycache__/models.cpython-311.pyc differ diff --git a/py-client/sdk/models.py b/py-client/sdk/models.py index e002a2f..3de94e4 100644 --- a/py-client/sdk/models.py +++ b/py-client/sdk/models.py @@ -99,30 +99,23 @@ class PositionItem: @classmethod def from_trade_detail(cls, data: dict[str, Any]) -> "PositionItem": """从 TradeDetailData/Holding 的 QMT 原始字段创建持仓。""" - instrument_id = str(data.get("m_strInstrumentID") or "") - exchange_id = str(data.get("m_strExchangeID") or "") - stock_code = ( - f"{instrument_id}.{exchange_id}" - if instrument_id and exchange_id - else instrument_id - ) return cls( - stock_code=stock_code, - stock_name=str(data.get("m_strInstrumentName") or ""), - direction=data.get("m_nDirection"), - volume=_number(data.get("m_nVolume"), int), - open_price=_number(data.get("m_dOpenPrice")), - float_profit=_number(data.get("m_dFloatProfit")), - market_value=_number(data.get("m_dMarketValue")), - stock_holder=str(data.get("m_strStockHolder") or ""), - frozen_volume=_number(data.get("m_nFrozenVolume"), int), - can_use_volume=_number(data.get("m_nCanUseVolume"), int), - on_road_volume=_number(data.get("m_nOnRoadVolume"), int), - yesterday_volume=_number(data.get("m_nYesterdayVolume"), int), - last_price=_number(data.get("m_dLastPrice")), - profit_rate=_number(data.get("m_dProfitRate")), - future_trade_type=data.get("m_eFutureTradeType"), - expire_date=str(data.get("m_strExpireDate") or ""), + stock_code=str(data.get("StockCode") or ""), + stock_name=str(data.get("StockName") or ""), + direction=data.get("Direction"), + volume=_number(data.get("Volume"), int), + open_price=_number(data.get("OpenPrice")), + float_profit=_number(data.get("FloatProfit")), + market_value=_number(data.get("MarketValue")), + stock_holder=str(data.get("StockHolder") or ""), + frozen_volume=_number(data.get("FrozenVolume"), int), + can_use_volume=_number(data.get("CanUseVolume"), int), + on_road_volume=_number(data.get("OnRoadVolume"), int), + yesterday_volume=_number(data.get("YesterdayVolume"), int), + last_price=_number(data.get("LastPrice")), + profit_rate=_number(data.get("ProfitRate")), + future_trade_type=data.get("FutureTradeType"), + expire_date=str(data.get("ExpireDate") or ""), ) diff --git a/py-client/strategy/trend/__pycache__/boot.cpython-311.pyc b/py-client/strategy/trend/__pycache__/boot.cpython-311.pyc index 643655c..be15688 100644 Binary files a/py-client/strategy/trend/__pycache__/boot.cpython-311.pyc and b/py-client/strategy/trend/__pycache__/boot.cpython-311.pyc differ diff --git a/py-client/strategy/trend/__pycache__/log.cpython-311.pyc b/py-client/strategy/trend/__pycache__/log.cpython-311.pyc new file mode 100644 index 0000000..89c847f Binary files /dev/null and b/py-client/strategy/trend/__pycache__/log.cpython-311.pyc differ diff --git a/py-client/strategy/trend/__pycache__/open.cpython-311.pyc b/py-client/strategy/trend/__pycache__/open.cpython-311.pyc index 4218111..711991a 100644 Binary files a/py-client/strategy/trend/__pycache__/open.cpython-311.pyc and b/py-client/strategy/trend/__pycache__/open.cpython-311.pyc differ diff --git a/py-client/strategy/trend/__pycache__/order.cpython-311.pyc b/py-client/strategy/trend/__pycache__/order.cpython-311.pyc index 18fed44..fde9b45 100644 Binary files a/py-client/strategy/trend/__pycache__/order.cpython-311.pyc and b/py-client/strategy/trend/__pycache__/order.cpython-311.pyc differ diff --git a/py-client/strategy/trend/__pycache__/positions.cpython-311.pyc b/py-client/strategy/trend/__pycache__/positions.cpython-311.pyc index 3ee802d..4d261dc 100644 Binary files a/py-client/strategy/trend/__pycache__/positions.cpython-311.pyc and b/py-client/strategy/trend/__pycache__/positions.cpython-311.pyc differ diff --git a/py-client/strategy/trend/__pycache__/state.cpython-311.pyc b/py-client/strategy/trend/__pycache__/state.cpython-311.pyc index a9499a4..cd5903f 100644 Binary files a/py-client/strategy/trend/__pycache__/state.cpython-311.pyc and b/py-client/strategy/trend/__pycache__/state.cpython-311.pyc differ diff --git a/py-client/strategy/trend/__pycache__/watch.cpython-311.pyc b/py-client/strategy/trend/__pycache__/watch.cpython-311.pyc index 44c031e..0db6c9a 100644 Binary files a/py-client/strategy/trend/__pycache__/watch.cpython-311.pyc and b/py-client/strategy/trend/__pycache__/watch.cpython-311.pyc differ diff --git a/py-client/strategy/trend/boot.py b/py-client/strategy/trend/boot.py index eaaf29d..aa5b2d2 100644 --- a/py-client/strategy/trend/boot.py +++ b/py-client/strategy/trend/boot.py @@ -5,8 +5,8 @@ from __future__ import annotations -import logging import time +import logging as log from concurrent.futures import Future, ThreadPoolExecutor from datetime import datetime @@ -25,42 +25,22 @@ from .positions import manage_positions def Overview(assets, positions, account_cfg=None) -> None: - """打印策略启动时的账户、资金和持仓概览。 - """ + """记录策略启动时的账户、资金和持仓概览。""" account_cfg = account_cfg or config.account_config - print("\n" + "=" * 80) - print(f"【时间】{datetime.now():%Y-%m-%d %H:%M:%S}") if account_cfg is not None: - print( - "【配置】" - f"account_id: {account_cfg.account_id} " - f"host_key: {account_cfg.host_key} " - f"buy_value: {account_cfg.buy_value:.0f}" - ) + log.info("[启动] 账户=%s,主机=%s,单笔金额=%.2f", account_cfg.account_id, account_cfg.host_key, account_cfg.buy_value) if assets is not None: - print( - f"【资金】总资产:{assets.total:.2f}元," - f"可用资金:{assets.available:.2f}元" - ) + log.info("[启动] 总资产=%.2f,可用资金=%.2f", assets.total, assets.available) else: - print("【资金】查询失败") + log.warning("[启动] 获取资金概览失败") - print(f"【持仓】{len(positions)}只") - print("=" * 80) + log.info("[启动] 持仓数量=%d", len(positions)) for position in positions: if position.volume <= 0: continue - print( - f"【持仓】{position.stock_code} {position.stock_name} " - f"持仓={position.volume} 可用={position.can_use_volume} " - f"冻结={position.frozen_volume} 在途={position.on_road_volume} " - f"昨仓={position.yesterday_volume} 成本={position.open_price:.3f} " - f"现价={position.last_price:.3f} 市值={position.market_value:.2f} " - f"浮盈={position.float_profit:.2f} " - f"盈亏比例={position.profit_rate * 100:.2f}%" - ) + log.info("[启动] %s %s,持仓=%d,可用=%d,成本=%.2f,现价=%.2f,盈亏=%.2f%%", position.stock_code, position.stock_name, position.volume, position.can_use_volume, position.open_price, position.last_price, position.profit_rate * 100) @@ -88,6 +68,7 @@ def StartTrend() -> None: config.global_config, config.account_config.signal_allow, ) + log.info("[启动] 趋势策略已启动,账户=%s,信号=%d,持仓=%d", config.account_config.account_id, len(signals), len(positions)) run = Runtime( client=client, global_cfg=config.global_config, @@ -102,31 +83,43 @@ def StartTrend() -> None: Overview(assets, positions, config.account_config) - try: - while True: - started_at = time.monotonic() - try: - RunOnce(run, signals) - except Exception: - # 单轮错误只记录日志,下一轮仍继续运行。 - logging.exception("趋势策略本轮执行失败") - - elapsed = time.monotonic() - started_at - time.sleep(max(0.0, 30.0 - elapsed)) - finally: - run.executor.shutdown(wait=True, cancel_futures=True) + DEFAULT_TICK_INTERVAL = 30 + while True: + current_sec = time.localtime().tm_sec + + # 计算距离下一个目标时间点(0秒或30秒)的等待时间 + if current_sec < DEFAULT_TICK_INTERVAL: + wait_seconds = DEFAULT_TICK_INTERVAL - current_sec + elif current_sec < 60: + wait_seconds = 60 - current_sec + else: + wait_seconds = DEFAULT_TICK_INTERVAL + + # 等待到目标时间点 + time.sleep(wait_seconds) + + # 单轮失败不能杀死唯一的交易定时线程。 + try: + RunOnce(run, signals) + except Exception as e: + log.error(f"[Trend] 本 tick 执行失败,下一 tick 继续: {e}", exc_info=True) def RunOnce(run: Runtime, signals:list[SignalItem]) -> None: """按固定步骤执行一轮趋势策略, ``RunOnce``。""" if not trading_time(datetime.now()): + log.info("[运行] 非交易时间,跳过本轮") return + print("=" * 40 + f" RunOnce {datetime.now().strftime('%Y-%m-%d %H:%M:%S')} " +"=" * 40) + + started_at = time.monotonic() + # 1. 刷新订单数据,清理过期订单。 try: run.orders.refresh(run.client) except Exception: - logging.exception("取消过期订单失败") + log.exception("[订单] 刷新订单失败") return @@ -134,20 +127,20 @@ def RunOnce(run: Runtime, signals:list[SignalItem]) -> None: try: assets = run.client.assets() except Exception: - logging.exception("获取资产失败") + log.exception("[资金] 获取资产失败") return allow_open_by_cash = assets.available >= assets.total * run.account_cfg.min_cash_ratio if not allow_open_by_cash: - logging.info("资金总闸:可用金额太少,禁止开新仓") + log.info("[开仓] 禁止开仓:可用资金不足,可用=%.2f,总资产=%.2f", assets.available, assets.total) # 3. 获取大盘状态,只有大盘信号允许时才执行开仓。 - market_ok = market_allow_open(run.global_cfg.api_host) + market_ok = market_allow_open() # 4. 获取当前持仓及持仓证券代码。 try: position_codes, positions = run.client.positions() except Exception: - logging.exception("获取持仓失败") + log.exception("[持仓] 获取持仓失败") return # 5. 验证有效开仓信号:排除已有持仓和未决订单。 @@ -158,21 +151,26 @@ def RunOnce(run: Runtime, signals:list[SignalItem]) -> None: allow_open.append(signal) allow_codes.append(signal.code) + if allow_open and not market_ok: + log.info("[开仓] 禁止开仓:大盘信号不允许,候选=%d", len(allow_open)) + # 6. 获取持仓和待开仓证券的实时行情 tick。 all_codes = list(dict.fromkeys(position_codes + allow_codes)) try: ticks = run.client.full_tick(all_codes) except Exception: - logging.exception("获取行情失败") + log.exception("[行情] 获取行情失败,代码数量=%d", len(all_codes)) return # 7. 更新状态机 try: run.state.reconcile(positions, run.orders.data) except Exception: - logging.exception("订单状态对账失败,本轮禁止自动交易") + log.exception("[状态] 订单状态对账失败") return + log.info("[RunOnce] 本轮就绪,持仓=%d,候选=%d,大盘允许=%s,资金允许=%s", len(positions), len(allow_open), market_ok, allow_open_by_cash) + # 启动线程,开始计算 # 9. 持仓计算。 futures: list[tuple[str, Future]] = [ @@ -196,6 +194,7 @@ def RunOnce(run: Runtime, signals:list[SignalItem]) -> None: # 11. 开始执行 for name, future in futures: _wait_worker(name, future) + log.info("[RunOnce] 本轮完成,耗时=%d毫秒", int((time.monotonic() - started_at) * 1000)) def _wait_worker(name: str, future: Future) -> None: @@ -203,4 +202,4 @@ def _wait_worker(name: str, future: Future) -> None: try: future.result() except Exception: - logging.exception("趋势策略%s线程失败", name) + log.exception("[运行] %s线程失败", name) diff --git a/py-client/strategy/trend/open.py b/py-client/strategy/trend/open.py index e8040be..9592fb9 100644 --- a/py-client/strategy/trend/open.py +++ b/py-client/strategy/trend/open.py @@ -2,7 +2,6 @@ from __future__ import annotations -import logging from datetime import datetime from libs import calc_buy_volume @@ -10,38 +9,46 @@ from sdk import OP_BUY from .runtime import Runtime from .order import PlaceOrderRequest from .state import STATUS_ING, StateItem +import logging as log def open_signal(run:Runtime, ticks, open_signals) -> None: """逐个验证开仓信号并提交买入委托。""" + log.info("[开仓] 信号总数:%d", len(open_signals)) for item in open_signals: # 1. 验证信号配置允许开仓的时间区间。 signal_config = run.global_cfg.signals.get(item.signal_key) if signal_config is None or not check_timezone(signal_config.timezone): + log.info("[开仓] %s 信号=%s,跳过:不在信号时间段(%s)", item.code, item.signal_key,signal_config.timezone) continue # 2. 检查该证券是否已有买入委托锁,防止重复下单。 if run.orders.busy(item.code,"BUY"): + log.info("[开仓] %s 信号=%s,跳过:买入委托处理中", item.code, item.signal_key) continue # 3. 验证行情和最新价格是否有效。 tick = ticks.get(item.code) price = tick.last_price if tick is not None else 0 if price <= 0: + log.info("[开仓] %s 信号=%s,跳过:价格无效", item.code, item.signal_key) continue # 5. 根据单笔买入金额计算整手开仓数量。 volume = calc_buy_volume(price, run.account_cfg.buy_value) if volume <= 0: + log.info("[开仓] %s 信号=%s,跳过:数量无效", item.code, item.signal_key) continue # 当前价高于昨收价可开仓 if signal_config.gt_last_price_is_open and item.last_close>0 and price>item.last_close: try: do_open(run,item.code,volume,item.signal_key) - logging.info("[开仓] %s 买入 %d 股", item.code, volume) - except Exception as e: - logging.exception("[开仓] %s 失败: %s", item.code,e) + log.info("[开仓] %s 信号=%s,买入=%d股,原因=现价高于昨收", item.code, item.signal_key, volume) + except RuntimeError as exc: + log.info("[开仓] %s 信号=%s,买入=%d股失败:%s", item.code, item.signal_key, volume, exc) + except Exception: + log.exception("[开仓] %s 信号=%s,买入=%d股异常", item.code, item.signal_key, volume) continue # 4. 等待价格从观察低点反弹,防止直接接下跌中的“飞刀”。 @@ -50,9 +57,11 @@ def open_signal(run:Runtime, ticks, open_signals) -> None: try: do_open(run,item.code,volume,item.signal_key) - logging.info("[开仓] %s 买入 %d 股", item.code, volume) - except Exception as e: - logging.exception("[开仓] %s 失败: %s", item.code,e) + log.info("[开仓] %s 信号=%s,买入=%d股,原因=反弹已确认", item.code, item.signal_key, volume) + except RuntimeError as exc: + log.warning("[开仓] %s 信号=%s,买入=%d股失败:%s", item.code, item.signal_key, volume, exc) + except Exception: + log.exception("[开仓] %s 信号=%s,买入=%d股异常", item.code, item.signal_key, volume) def do_open(run:Runtime,code:str,volume:int,signal_key:str)->None: diff --git a/py-client/strategy/trend/order.py b/py-client/strategy/trend/order.py index 203713b..5aadf06 100644 --- a/py-client/strategy/trend/order.py +++ b/py-client/strategy/trend/order.py @@ -3,12 +3,13 @@ from __future__ import annotations import secrets +import logging as log from dataclasses import dataclass from datetime import datetime, timedelta from threading import Lock from typing import Any -from sdk import ORDER_SIDE_BY_OFFSET, Client, OrderItem +from sdk import APIError, ORDER_SIDE_BY_OFFSET, Client, OrderItem # 表示委托仍在处理、可能继续成交的 QMT 状态。 BUSY_STATUSES = {"48", "49", "50", "51", "52", "55"} @@ -57,6 +58,7 @@ class OrderBook: now_timestamp = current.timestamp() data: list[OrderItem] = [] lock: dict[str, float] = {} + canceled = 0 for item in orders: # 不处理状态不对的 @@ -69,6 +71,8 @@ class OrderBook: and current - item.created_at > self.cancel_timeout_sec ): client.cancel_by_id(item.id) + canceled += 1 + log.info("[订单] 超时撤单,代码=%s,方向=%s,柜台订单=%s", item.code, item.side, item.id) continue # 缓存本次有效订单 @@ -86,20 +90,27 @@ class OrderBook: with self.mutex: self.data = data self.lock = lock + log.info("[订单] 刷新完成,跟踪=%d,处理中=%d,撤销=%d", len(data), len(lock), canceled) def place(self, request: PlaceOrderRequest) -> bool: """按最新价提交委托,并立即写入本地方向锁。""" - result = request.client.passorder_latest_tagged( - request.op, - request.code, - request.volume, - request.strategy_name, - request.order_id, - ) + try: + result = request.client.passorder_latest_tagged( + request.op, + request.code, + request.volume, + request.strategy_name, + request.order_id, + ) + except APIError as exc: + log.exception("[订单] 下单失败,代码=%s,本地订单=%s,HTTP状态=%d,错误=%s", request.code, request.order_id, exc.status_code, exc.message or str(exc)) + return False if not isinstance(result, dict): + log.warning("[订单] 下单失败,代码=%s,本地订单=%s,原因=响应格式无效", request.code, request.order_id) return False order_ref = str(result.get("order_ref") or "").strip().lower() if result.get("status") != "success" or order_ref in {"", "unknown", "none"}: + log.warning("[订单] 下单被拒绝,代码=%s,本地订单=%s,状态=%s,柜台订单=%s", request.code, request.order_id, result.get("status"), order_ref) return False side = ORDER_SIDE_BY_OFFSET.get(str(request.op), "") @@ -117,4 +128,5 @@ class OrderBook: key = f"{side}-{request.code}" self.data.append(pending) self.lock[key] = pending.created_at.timestamp() + log.info("[订单] 下单已受理,代码=%s,方向=%s,数量=%d,本地订单=%s,柜台订单=%s", request.code, side, request.volume, request.order_id, order_ref) return True diff --git a/py-client/strategy/trend/positions.py b/py-client/strategy/trend/positions.py index 1955577..bc2fe84 100644 --- a/py-client/strategy/trend/positions.py +++ b/py-client/strategy/trend/positions.py @@ -2,7 +2,6 @@ from __future__ import annotations -import logging from dataclasses import dataclass from libs.calc import calc_buy_volume, calculate_min_profit_rate @@ -12,6 +11,7 @@ from sdk import OP_BUY, OP_SELL, PositionItem, Tick from .order import PlaceOrderRequest from .runtime import Runtime from .state import STATUS_ING +import logging as log LEG_BASE = "base" LEG_ADDED = "add" @@ -43,11 +43,11 @@ def manage_positions( runtime.profit_tracker.retain(active_keys) remaining_cash = max(0.0, available) - logging.info("[持仓] 共 %d 只,开始处理", len(positions)) for position in positions: code = position.stock_code tick = ticks.get(code) if code in runtime.account_cfg.excluded_codes: + log.info("[持仓] 代码=%s,名称=%s,止盈=跳过,补仓=跳过,原因=已配置为排除股票", code, position.stock_name) continue if ( not code @@ -56,6 +56,7 @@ def manage_positions( or tick is None or tick.last_price <= 0 ): + log.warning("[持仓] 代码=%s,名称=%s,止盈=跳过,补仓=跳过,原因=持仓或行情数据无效", code or "未知", position.stock_name) continue pnl_rate = round( @@ -70,9 +71,8 @@ def manage_positions( pnl_rate=pnl_rate, minimum_profit=minimum_profit, ) - if profit_decision.message: - logging.info("[止盈] %s %s", code, profit_decision.message) - + profit_action = profit_decision.message or "未触发" + loss_add_action = "未启用" if runtime.account_cfg.enable_loss_add_position and market_ok: loss_decision = handle_loss( runtime=runtime, @@ -82,8 +82,14 @@ def manage_positions( available=remaining_cash, ) remaining_cash -= loss_decision.reserved_cash - if loss_decision.message: - logging.info("[补仓] %s %s", code, loss_decision.message) + loss_add_action = loss_decision.message or "未触发" + elif runtime.account_cfg.enable_loss_add_position: + loss_add_action = "大盘信号不允许" + + log.info( + "[持仓] 代码=%s,名称=%s,盈亏=%.2f%%,止盈=%s,补仓=%s", + code, position.stock_name, pnl_rate, profit_action, loss_add_action, + ) def handle_profit( diff --git a/py-client/strategy/trend/state.py b/py-client/strategy/trend/state.py index f8d3804..7475e14 100644 --- a/py-client/strategy/trend/state.py +++ b/py-client/strategy/trend/state.py @@ -3,6 +3,7 @@ from __future__ import annotations import json +import logging as log from dataclasses import asdict, dataclass from pathlib import Path from threading import Lock @@ -93,6 +94,7 @@ class State: 立即保存,确保首次接管的持仓在程序重启后仍可恢复。 """ known_codes = set(self.codes) + imported = 0 for position in positions: if ( not position.stock_code @@ -111,8 +113,11 @@ class State: ) ) known_codes.add(position.stock_code) + imported += 1 self.save() + if imported: + log.info("[状态] 导入持仓=%d,状态总数=%d", imported, len(known_codes)) def reconcile( self, @@ -151,6 +156,8 @@ class State: if all(order.status == "56" for order in matching_orders) else STATUS_ING ) + if status != current_status: + log.info("[状态] %s 订单=%s,状态=%s->%s", code, local_order_id, current_status, status) setattr(item, status_attr, status) self.set(item) @@ -158,7 +165,8 @@ class State: # reconciliation must therefore happen before stale state is removed. for code in list(self.codes): if code not in position_codes: - self.delete(code) + if self.delete(code): + log.info("[状态] 已移除持仓状态,代码=%s", code) self.save() def save(self) -> None: diff --git a/py-client/strategy/trend/watch.py b/py-client/strategy/trend/watch.py index bab726b..315fb19 100644 --- a/py-client/strategy/trend/watch.py +++ b/py-client/strategy/trend/watch.py @@ -1,7 +1,7 @@ +import logging as log from dataclasses import dataclass from datetime import datetime, timedelta from threading import Lock -import logging @dataclass(slots=True) @@ -11,24 +11,95 @@ class _Entry: class DipWatch: - def __init__(self, expire_seconds: float = 300, rebound_threshold: float = 0.61): - self.expire_seconds, self.rebound_threshold = expire_seconds, rebound_threshold - self.data: dict[str, _Entry] = {}; self.lock = Lock() + """观察价格低点,并在价格达到指定反弹幅度时触发。""" - def triggered(self, tag: str, code: str, price: float, now: datetime | None = None) -> bool: - if price <= 0: return False - now = now or datetime.now() + def __init__( + self, + expire_seconds: float = 300, + rebound_threshold: float = 0.61, + ) -> None: + self.expire_seconds = expire_seconds + self.rebound_threshold = rebound_threshold + self.data: dict[str, _Entry] = {} + self.lock = Lock() + + def triggered( + self, + tag: str, + code: str, + price: float, + now: datetime | None = None, + ) -> bool: + """更新观察价格;达到反弹阈值时返回 ``True``。""" + if price <= 0: + log.warning("[%s观察] %s 价格无效:%.2f", tag, code, price) + return False + + current = now or datetime.now() with self.lock: watch = self.data.get(code) - if watch is None or now >= watch.expires_at: - self.data[code] = _Entry(price, now + timedelta(seconds=self.expire_seconds)); return False + + if watch is None: + self._start(code, price, current) + log.info( + "[%s观察] %s 开始观察,收盘价=%.2f,反弹阈值=%.2f%%", + tag, + code, + price, + self.rebound_threshold, + ) + return False + + if current >= watch.expires_at: + self._start(code, price, current) + log.info("[%s观察] %s 观察已过期,重新观察,收盘价=%.2f", tag, code, price) + return False + if price < watch.last_close: - self.data[code] = _Entry(price, now + timedelta(seconds=self.expire_seconds)); return False + old_price = watch.last_close + self._start(code, price, current) + log.info( + "[%s观察] %s 刷新低点,原收盘价=%.2f,新收盘价=%.2f", + tag, + code, + old_price, + price, + ) + return False + rebound = (price - watch.last_close) / watch.last_close * 100 - if rebound <= 0 or rebound < self.rebound_threshold: return False + if rebound < self.rebound_threshold: + log.debug( + "[%s观察] %s 等待反弹,收盘价=%.2f,现价=%.2f,反弹=%.2f%%,阈值=%.2f%%", + tag, + code, + watch.last_close, + price, + rebound, + self.rebound_threshold, + ) + return False + del self.data[code] - logging.info("[%s-触发] %s 反弹=%.2f%%", tag, code, rebound) + log.info( + "[%s观察] %s 反弹触发,收盘价=%.2f,现价=%.2f,反弹=%.2f%%", + tag, + code, + watch.last_close, + price, + rebound, + ) return True - def forget(self, code): - with self.lock: self.data.pop(code, None) + def forget(self, code: str) -> None: + """清除指定股票的价格观察状态。""" + with self.lock: + removed = self.data.pop(code, None) + if removed is not None: + log.info("[价格观察] %s 已清除观察状态", code) + + def _start(self, code: str, price: float, now: datetime) -> None: + self.data[code] = _Entry( + last_close=price, + expires_at=now + timedelta(seconds=self.expire_seconds), + ) diff --git a/py-client/test.py b/py-client/test.py index b878c21..82c3ed7 100644 --- a/py-client/test.py +++ b/py-client/test.py @@ -14,12 +14,12 @@ def main(): _, positions = client.positions() print(f"总资产:{assets.total:.2f}元,可用资金:{assets.available:.2f}元") for p in sorted(positions, key=lambda item: item.stock_code): - if p.volume > 0: print(f"{p.stock_code} {p.stock_name} 持仓={p.volume} 可用={p.can_use_volume} 成本={p.open_price:.3f} 现价={p.last_price:.3f}") + if p.volume > 0: print(f"{p.stock_code} {p.stock_name} 持仓={p.volume} 可用={p.can_use_volume} 成本={p.open_price:.2f} 现价={p.last_price:.2f}") data_dir = os.environ.get("QMT_DATA_DIR", "").strip() if not data_dir: raise SystemExit("环境变量 QMT_DATA_DIR 为空") codes = json.loads((Path(data_dir) / "pass_codes.json").read_text(encoding="utf-8")) for code, tick in sorted(client.full_tick(codes).items()): - print(f"{code} last={tick.last_price:.3f} close={tick.last_close:.3f}") + print(f"{code} last={tick.last_price:.2f} close={tick.last_close:.2f}") if __name__ == "__main__": main() diff --git a/py-client/tests/__pycache__/test_signal.cpython-311.pyc b/py-client/tests/__pycache__/test_signal.cpython-311.pyc new file mode 100644 index 0000000..6e12b0b Binary files /dev/null and b/py-client/tests/__pycache__/test_signal.cpython-311.pyc differ diff --git a/py-client/tests/__pycache__/test_trend.cpython-311.pyc b/py-client/tests/__pycache__/test_trend.cpython-311.pyc index 87fb86f..3192658 100644 Binary files a/py-client/tests/__pycache__/test_trend.cpython-311.pyc and b/py-client/tests/__pycache__/test_trend.cpython-311.pyc differ diff --git a/py-client/tests/test_signal.py b/py-client/tests/test_signal.py new file mode 100644 index 0000000..acba6ad --- /dev/null +++ b/py-client/tests/test_signal.py @@ -0,0 +1,33 @@ +from types import SimpleNamespace +import unittest +from unittest.mock import patch + +from libs.signal import SignalResult, fetch_signal, init_signals + + +class SignalTests(unittest.TestCase): + def test_fetch_failure_returns_empty_result(self): + with patch("libs.signal.get_json", side_effect=OSError("offline")): + self.assertEqual(fetch_signal("http://example", "/signals"), SignalResult()) + + def test_init_signals_continues_after_fetch_failure(self): + config = SimpleNamespace( + api_host="http://example", + signals={ + "failed": SimpleNamespace(url="/failed"), + "working": SimpleNamespace(url="/working"), + }, + ) + responses = [ + SignalResult(), + SignalResult(data={"000001.SZ": SimpleNamespace(signal_key="")}), + ] + with patch("libs.signal.fetch_signal", side_effect=responses): + result = init_signals(config, ["failed", "working"]) + + self.assertEqual(len(result), 1) + self.assertEqual(result[0].signal_key, "working") + + +if __name__ == "__main__": + unittest.main() diff --git a/py-client/tests/test_trend.py b/py-client/tests/test_trend.py index fb077eb..2629ade 100644 --- a/py-client/tests/test_trend.py +++ b/py-client/tests/test_trend.py @@ -8,7 +8,7 @@ from types import SimpleNamespace from unittest.mock import patch from libs.grid_take_profit import GridState, GridTrailingTracker -from sdk import Assets, OrderItem, PositionItem, Tick +from sdk import APIError, Assets, OrderItem, PositionItem, Tick from strategy.trend.order import OrderBook, PlaceOrderRequest from strategy.trend.positions import LOSS_TIERS, handle_loss, manage_positions from strategy.trend.boot import RunOnce @@ -36,6 +36,11 @@ class FakeOrderClient: self.canceled.append(order_id) +class FailedOrderClient: + def passorder_latest_tagged(self, *_args): + raise APIError(502, "QMT did not return a valid order reference") + + class TrendTests(unittest.TestCase): def test_grid_states_and_account_isolation(self): tracker = GridTrailingTracker(1) @@ -53,6 +58,17 @@ class TrendTests(unittest.TestCase): self.assertTrue(book.place(request)) self.assertTrue(book.busy("000001.SZ", "BUY")) + def test_order_api_error_returns_false_with_traceback(self): + book = OrderBook() + request = PlaceOrderRequest(FailedOrderClient(), 23, "000001.SZ", 100, "local", "morning") + + with self.assertLogs(level="ERROR") as captured: + self.assertFalse(book.place(request)) + + output = "\n".join(captured.output) + self.assertIn("HTTP状态=502", output) + self.assertIn("Traceback", output) + def test_refresh_tracks_active_and_completed_and_cancels_expired(self): old = datetime.now() - timedelta(seconds=20) orders = [ @@ -90,6 +106,40 @@ class TrendTests(unittest.TestCase): ) manage_positions(runtime, {"000001.SZ": Tick(last_price=10.1)}, [position], True, 5000) + def test_position_log_contains_code_name_profit_and_loss_actions(self): + with TemporaryDirectory() as directory: + state = State.for_strategy(directory, "trend", "A") + position = PositionItem( + stock_code="000001.SZ", stock_name="平安银行", volume=100, + can_use_volume=100, open_price=10, market_value=1000, + ) + state.sync_positions([position]) + runtime = SimpleNamespace( + client=FakeClient(), state=state, orders=OrderBook(), + add_watch=SimpleNamespace(triggered=lambda *_args: False), + profit_tracker=GridTrailingTracker(1), + account_cfg=SimpleNamespace( + account_id="A", excluded_codes=[], grid_step_pct=1, + enable_loss_add_position=False, buy_value=5000, + strategy="trend", + ), + ) + + with self.assertLogs(level="INFO") as captured: + manage_positions( + runtime, + {"000001.SZ": Tick(last_price=10.1)}, + [position], + True, + 5000, + ) + + output = "\n".join(captured.output) + self.assertIn("代码=000001.SZ", output) + self.assertIn("名称=平安银行", output) + self.assertIn("止盈=未触发", output) + self.assertIn("补仓=未启用", output) + def test_loss_tier_boundary_does_not_overflow(self): self.assertEqual(len(LOSS_TIERS), 2) with TemporaryDirectory() as directory: