feat dev6

This commit is contained in:
2026-09-01 14:28:49 +08:00
parent dedbf63a92
commit 556e21d624
27 changed files with 334 additions and 144 deletions

View File

@@ -934,7 +934,8 @@ class TradeDetailDataHandler(BaseHandler):
ret = safe_call(get_trade_detail_data, self.acc(), account, datatype) ret = safe_call(get_trade_detail_data, self.acc(), account, datatype)
if ret is None: if ret is None:
ret = [] 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 # get_value_by_order_id() - Get order or trade details by order ID
class ValueByOrderIdHandler(BaseHandler): class ValueByOrderIdHandler(BaseHandler):
@@ -1080,7 +1081,28 @@ class HoldingHandler(BaseHandler):
data = json.loads(self.request.body) data = json.loads(self.request.body)
account = data.get('account', 'stock') account = data.get('account', 'stock')
positions = safe_call(get_trade_detail_data, self.acc(), account, 'position') or [] 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 # get_trade_detail_data('account') - Query account assets
class AssetsHandler(BaseHandler): 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)) 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 # passorder(23) - Simplified buy order wrapper
class BuyHandler(BaseHandler): class BuyHandler(BaseHandler):
def post(self): def post(self):
@@ -1275,8 +1275,6 @@ def make_app():
# Legacy compatibility routes # Legacy compatibility routes
(r"/api/holding", HoldingHandler), (r"/api/holding", HoldingHandler),
(r"/api/money/total", TotalMoneyHandler),
(r"/api/money/available", AvailableMoneyHandler),
(r"/api/order/buy", BuyHandler), (r"/api/order/buy", BuyHandler),
(r"/api/order/sell", SellHandler), (r"/api/order/sell", SellHandler),
(r"/api/order/status", OrderStatusHandler), (r"/api/order/status", OrderStatusHandler),

View File

@@ -29,11 +29,11 @@ def refresh_market(api_host: str = API_HOST) -> str:
logging.error("获取大盘指数失败: %s %s", url, exc) logging.error("获取大盘指数失败: %s %s", url, exc)
with _market_lock: with _market_lock:
_market_status = result _market_status = result
logging.info("大盘信号: url=%s status=%s", url, result)
return result return result
def market_allow_open() -> bool: def market_allow_open() -> bool:
"""读取最近一次后台刷新得到的大盘缓存;未知状态时禁止开仓。""" """读取最近一次后台刷新得到的大盘缓存;未知状态时禁止开仓。"""
with _market_lock: with _market_lock:
return _market_status == "UP" # return _market_status == "UP"
return True

View File

@@ -16,7 +16,10 @@ class SignalResult:
def fetch_signal(api_host: str, sub_url: str, timeout: float = 5.0) -> 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)}" url = f"{api_host}{sub_url}?t={secrets.token_urlsafe(12)}"
try:
raw = get_json(url, timeout) raw = get_json(url, timeout)
except Exception:
return SignalResult()
items = {code: SignalItem(**item) for code, item in (raw.get("data") or {}).items()} 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", "")) 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]: def init_signals(global_config, allow: list[str]) -> list[SignalItem]:
result = [] result = []
for key, cfg in global_config.signals.items(): for key, cfg in global_config.signals.items():
if key in allow: if key not in allow:
for item in fetch_signal(global_config.api_host, cfg.url).data.values(): item.signal_key = key; result.append(item) 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 return result

View File

@@ -5,6 +5,7 @@ import logging
import os import os
import sys import sys
from datetime import datetime from datetime import datetime
import traceback
from apscheduler.schedulers.background import BackgroundScheduler from apscheduler.schedulers.background import BackgroundScheduler
import config import config
from dataclasses import dataclass from dataclasses import dataclass
@@ -21,6 +22,8 @@ logging.basicConfig(
format='[%(levelname)s] %(asctime)s %(message)s', format='[%(levelname)s] %(asctime)s %(message)s',
datefmt='%Y-%m-%d %H:%M:%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 sdk import APIError, Client
from libs.market import refresh_market from libs.market import refresh_market
@@ -140,8 +143,9 @@ def main() -> int:
STRATEGIES[config.account_config.strategy].start_strategy() STRATEGIES[config.account_config.strategy].start_strategy()
logging.info("%s 策略启动成功",config.account_config.strateg) logging.info("%s 策略启动成功",config.account_config.strateg)
return 0 return 0
except (OSError, yaml.YAMLError, ValueError, RuntimeError, KeyError) as exc: except (OSError, yaml.YAMLError, ValueError, RuntimeError, KeyError) as e:
print(f"启动失败: {exc}", file=sys.stderr, flush=True) print(f"启动失败: {e}", file=sys.stderr, flush=True)
traceback.print_exception(type(e), e, e.__traceback__)
wait_for_any_key() wait_for_any_key()
return 1 return 1

View File

@@ -99,30 +99,23 @@ class PositionItem:
@classmethod @classmethod
def from_trade_detail(cls, data: dict[str, Any]) -> "PositionItem": def from_trade_detail(cls, data: dict[str, Any]) -> "PositionItem":
"""从 TradeDetailData/Holding 的 QMT 原始字段创建持仓。""" """从 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( return cls(
stock_code=stock_code, stock_code=str(data.get("StockCode") or ""),
stock_name=str(data.get("m_strInstrumentName") or ""), stock_name=str(data.get("StockName") or ""),
direction=data.get("m_nDirection"), direction=data.get("Direction"),
volume=_number(data.get("m_nVolume"), int), volume=_number(data.get("Volume"), int),
open_price=_number(data.get("m_dOpenPrice")), open_price=_number(data.get("OpenPrice")),
float_profit=_number(data.get("m_dFloatProfit")), float_profit=_number(data.get("FloatProfit")),
market_value=_number(data.get("m_dMarketValue")), market_value=_number(data.get("MarketValue")),
stock_holder=str(data.get("m_strStockHolder") or ""), stock_holder=str(data.get("StockHolder") or ""),
frozen_volume=_number(data.get("m_nFrozenVolume"), int), frozen_volume=_number(data.get("FrozenVolume"), int),
can_use_volume=_number(data.get("m_nCanUseVolume"), int), can_use_volume=_number(data.get("CanUseVolume"), int),
on_road_volume=_number(data.get("m_nOnRoadVolume"), int), on_road_volume=_number(data.get("OnRoadVolume"), int),
yesterday_volume=_number(data.get("m_nYesterdayVolume"), int), yesterday_volume=_number(data.get("YesterdayVolume"), int),
last_price=_number(data.get("m_dLastPrice")), last_price=_number(data.get("LastPrice")),
profit_rate=_number(data.get("m_dProfitRate")), profit_rate=_number(data.get("ProfitRate")),
future_trade_type=data.get("m_eFutureTradeType"), future_trade_type=data.get("FutureTradeType"),
expire_date=str(data.get("m_strExpireDate") or ""), expire_date=str(data.get("ExpireDate") or ""),
) )

View File

@@ -5,8 +5,8 @@
from __future__ import annotations from __future__ import annotations
import logging
import time import time
import logging as log
from concurrent.futures import Future, ThreadPoolExecutor from concurrent.futures import Future, ThreadPoolExecutor
from datetime import datetime from datetime import datetime
@@ -25,42 +25,22 @@ from .positions import manage_positions
def Overview(assets, positions, account_cfg=None) -> None: def Overview(assets, positions, account_cfg=None) -> None:
"""打印策略启动时的账户、资金和持仓概览。 """记录策略启动时的账户、资金和持仓概览。"""
"""
account_cfg = account_cfg or config.account_config 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: if account_cfg is not None:
print( log.info("[启动] 账户=%s,主机=%s,单笔金额=%.2f", account_cfg.account_id, account_cfg.host_key, account_cfg.buy_value)
"【配置】"
f"account_id: {account_cfg.account_id} "
f"host_key: {account_cfg.host_key} "
f"buy_value: {account_cfg.buy_value:.0f}"
)
if assets is not None: if assets is not None:
print( log.info("[启动] 总资产=%.2f,可用资金=%.2f", assets.total, assets.available)
f"【资金】总资产:{assets.total:.2f}元,"
f"可用资金:{assets.available:.2f}"
)
else: else:
print("【资金】查询失败") log.warning("[启动] 获取资金概览失败")
print(f"【持仓】{len(positions)}") log.info("[启动] 持仓数量=%d", len(positions))
print("=" * 80)
for position in positions: for position in positions:
if position.volume <= 0: if position.volume <= 0:
continue continue
print( 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)
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}%"
)
@@ -88,6 +68,7 @@ def StartTrend() -> None:
config.global_config, config.global_config,
config.account_config.signal_allow, config.account_config.signal_allow,
) )
log.info("[启动] 趋势策略已启动,账户=%s,信号=%d,持仓=%d", config.account_config.account_id, len(signals), len(positions))
run = Runtime( run = Runtime(
client=client, client=client,
global_cfg=config.global_config, global_cfg=config.global_config,
@@ -102,31 +83,43 @@ def StartTrend() -> None:
Overview(assets, positions, config.account_config) Overview(assets, positions, config.account_config)
try: DEFAULT_TICK_INTERVAL = 30
while True: while True:
started_at = time.monotonic() 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: try:
RunOnce(run, signals) RunOnce(run, signals)
except Exception: except Exception as e:
# 单轮错误只记录日志,下一轮仍继续运行。 log.error(f"[Trend] 本 tick 执行失败,下一 tick 继续: {e}", exc_info=True)
logging.exception("趋势策略本轮执行失败")
elapsed = time.monotonic() - started_at
time.sleep(max(0.0, 30.0 - elapsed))
finally:
run.executor.shutdown(wait=True, cancel_futures=True)
def RunOnce(run: Runtime, signals:list[SignalItem]) -> None: def RunOnce(run: Runtime, signals:list[SignalItem]) -> None:
"""按固定步骤执行一轮趋势策略, ``RunOnce``。""" """按固定步骤执行一轮趋势策略, ``RunOnce``。"""
if not trading_time(datetime.now()): if not trading_time(datetime.now()):
log.info("[运行] 非交易时间,跳过本轮")
return return
print("=" * 40 + f" RunOnce {datetime.now().strftime('%Y-%m-%d %H:%M:%S')} " +"=" * 40)
started_at = time.monotonic()
# 1. 刷新订单数据,清理过期订单。 # 1. 刷新订单数据,清理过期订单。
try: try:
run.orders.refresh(run.client) run.orders.refresh(run.client)
except Exception: except Exception:
logging.exception("取消过期订单失败") log.exception("[订单] 刷新订单失败")
return return
@@ -134,20 +127,20 @@ def RunOnce(run: Runtime, signals:list[SignalItem]) -> None:
try: try:
assets = run.client.assets() assets = run.client.assets()
except Exception: except Exception:
logging.exception("获取资产失败") log.exception("[资金] 获取资产失败")
return return
allow_open_by_cash = assets.available >= assets.total * run.account_cfg.min_cash_ratio allow_open_by_cash = assets.available >= assets.total * run.account_cfg.min_cash_ratio
if not allow_open_by_cash: if not allow_open_by_cash:
logging.info("资金总闸:可用金额太少,禁止开新仓") log.info("[开仓] 禁止开仓:可用资金不足,可用=%.2f,总资产=%.2f", assets.available, assets.total)
# 3. 获取大盘状态,只有大盘信号允许时才执行开仓。 # 3. 获取大盘状态,只有大盘信号允许时才执行开仓。
market_ok = market_allow_open(run.global_cfg.api_host) market_ok = market_allow_open()
# 4. 获取当前持仓及持仓证券代码。 # 4. 获取当前持仓及持仓证券代码。
try: try:
position_codes, positions = run.client.positions() position_codes, positions = run.client.positions()
except Exception: except Exception:
logging.exception("获取持仓失败") log.exception("[持仓] 获取持仓失败")
return return
# 5. 验证有效开仓信号:排除已有持仓和未决订单。 # 5. 验证有效开仓信号:排除已有持仓和未决订单。
@@ -158,21 +151,26 @@ def RunOnce(run: Runtime, signals:list[SignalItem]) -> None:
allow_open.append(signal) allow_open.append(signal)
allow_codes.append(signal.code) allow_codes.append(signal.code)
if allow_open and not market_ok:
log.info("[开仓] 禁止开仓:大盘信号不允许,候选=%d", len(allow_open))
# 6. 获取持仓和待开仓证券的实时行情 tick。 # 6. 获取持仓和待开仓证券的实时行情 tick。
all_codes = list(dict.fromkeys(position_codes + allow_codes)) all_codes = list(dict.fromkeys(position_codes + allow_codes))
try: try:
ticks = run.client.full_tick(all_codes) ticks = run.client.full_tick(all_codes)
except Exception: except Exception:
logging.exception("获取行情失败") log.exception("[行情] 获取行情失败,代码数量=%d", len(all_codes))
return return
# 7. 更新状态机 # 7. 更新状态机
try: try:
run.state.reconcile(positions, run.orders.data) run.state.reconcile(positions, run.orders.data)
except Exception: except Exception:
logging.exception("订单状态对账失败,本轮禁止自动交易") log.exception("[状态] 订单状态对账失败")
return return
log.info("[RunOnce] 本轮就绪,持仓=%d,候选=%d,大盘允许=%s,资金允许=%s", len(positions), len(allow_open), market_ok, allow_open_by_cash)
# 启动线程,开始计算 # 启动线程,开始计算
# 9. 持仓计算。 # 9. 持仓计算。
futures: list[tuple[str, Future]] = [ futures: list[tuple[str, Future]] = [
@@ -196,6 +194,7 @@ def RunOnce(run: Runtime, signals:list[SignalItem]) -> None:
# 11. 开始执行 # 11. 开始执行
for name, future in futures: for name, future in futures:
_wait_worker(name, future) _wait_worker(name, future)
log.info("[RunOnce] 本轮完成,耗时=%d毫秒", int((time.monotonic() - started_at) * 1000))
def _wait_worker(name: str, future: Future) -> None: def _wait_worker(name: str, future: Future) -> None:
@@ -203,4 +202,4 @@ def _wait_worker(name: str, future: Future) -> None:
try: try:
future.result() future.result()
except Exception: except Exception:
logging.exception("趋势策略%s线程失败", name) log.exception("[运行] %s线程失败", name)

View File

@@ -2,7 +2,6 @@
from __future__ import annotations from __future__ import annotations
import logging
from datetime import datetime from datetime import datetime
from libs import calc_buy_volume from libs import calc_buy_volume
@@ -10,38 +9,46 @@ from sdk import OP_BUY
from .runtime import Runtime from .runtime import Runtime
from .order import PlaceOrderRequest from .order import PlaceOrderRequest
from .state import STATUS_ING, StateItem from .state import STATUS_ING, StateItem
import logging as log
def open_signal(run:Runtime, ticks, open_signals) -> None: def open_signal(run:Runtime, ticks, open_signals) -> None:
"""逐个验证开仓信号并提交买入委托。""" """逐个验证开仓信号并提交买入委托。"""
log.info("[开仓] 信号总数:%d", len(open_signals))
for item in open_signals: for item in open_signals:
# 1. 验证信号配置允许开仓的时间区间。 # 1. 验证信号配置允许开仓的时间区间。
signal_config = run.global_cfg.signals.get(item.signal_key) signal_config = run.global_cfg.signals.get(item.signal_key)
if signal_config is None or not check_timezone(signal_config.timezone): 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 continue
# 2. 检查该证券是否已有买入委托锁,防止重复下单。 # 2. 检查该证券是否已有买入委托锁,防止重复下单。
if run.orders.busy(item.code,"BUY"): if run.orders.busy(item.code,"BUY"):
log.info("[开仓] %s 信号=%s,跳过:买入委托处理中", item.code, item.signal_key)
continue continue
# 3. 验证行情和最新价格是否有效。 # 3. 验证行情和最新价格是否有效。
tick = ticks.get(item.code) tick = ticks.get(item.code)
price = tick.last_price if tick is not None else 0 price = tick.last_price if tick is not None else 0
if price <= 0: if price <= 0:
log.info("[开仓] %s 信号=%s,跳过:价格无效", item.code, item.signal_key)
continue continue
# 5. 根据单笔买入金额计算整手开仓数量。 # 5. 根据单笔买入金额计算整手开仓数量。
volume = calc_buy_volume(price, run.account_cfg.buy_value) volume = calc_buy_volume(price, run.account_cfg.buy_value)
if volume <= 0: if volume <= 0:
log.info("[开仓] %s 信号=%s,跳过:数量无效", item.code, item.signal_key)
continue continue
# 当前价高于昨收价可开仓 # 当前价高于昨收价可开仓
if signal_config.gt_last_price_is_open and item.last_close>0 and price>item.last_close: if signal_config.gt_last_price_is_open and item.last_close>0 and price>item.last_close:
try: try:
do_open(run,item.code,volume,item.signal_key) do_open(run,item.code,volume,item.signal_key)
logging.info("[开仓] %s 买入 %d ", item.code, volume) log.info("[开仓] %s 信号=%s买入=%d,原因=现价高于昨收", item.code, item.signal_key, volume)
except Exception as e: except RuntimeError as exc:
logging.exception("[开仓] %s 失败: %s", item.code,e) 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 continue
# 4. 等待价格从观察低点反弹,防止直接接下跌中的“飞刀”。 # 4. 等待价格从观察低点反弹,防止直接接下跌中的“飞刀”。
@@ -50,9 +57,11 @@ def open_signal(run:Runtime, ticks, open_signals) -> None:
try: try:
do_open(run,item.code,volume,item.signal_key) do_open(run,item.code,volume,item.signal_key)
logging.info("[开仓] %s 买入 %d ", item.code, volume) log.info("[开仓] %s 信号=%s买入=%d,原因=反弹已确认", item.code, item.signal_key, volume)
except Exception as e: except RuntimeError as exc:
logging.exception("[开仓] %s 失败: %s", item.code,e) 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: def do_open(run:Runtime,code:str,volume:int,signal_key:str)->None:

View File

@@ -3,12 +3,13 @@
from __future__ import annotations from __future__ import annotations
import secrets import secrets
import logging as log
from dataclasses import dataclass from dataclasses import dataclass
from datetime import datetime, timedelta from datetime import datetime, timedelta
from threading import Lock from threading import Lock
from typing import Any from typing import Any
from sdk import ORDER_SIDE_BY_OFFSET, Client, OrderItem from sdk import APIError, ORDER_SIDE_BY_OFFSET, Client, OrderItem
# 表示委托仍在处理、可能继续成交的 QMT 状态。 # 表示委托仍在处理、可能继续成交的 QMT 状态。
BUSY_STATUSES = {"48", "49", "50", "51", "52", "55"} BUSY_STATUSES = {"48", "49", "50", "51", "52", "55"}
@@ -57,6 +58,7 @@ class OrderBook:
now_timestamp = current.timestamp() now_timestamp = current.timestamp()
data: list[OrderItem] = [] data: list[OrderItem] = []
lock: dict[str, float] = {} lock: dict[str, float] = {}
canceled = 0
for item in orders: for item in orders:
# 不处理状态不对的 # 不处理状态不对的
@@ -69,6 +71,8 @@ class OrderBook:
and current - item.created_at > self.cancel_timeout_sec and current - item.created_at > self.cancel_timeout_sec
): ):
client.cancel_by_id(item.id) client.cancel_by_id(item.id)
canceled += 1
log.info("[订单] 超时撤单,代码=%s,方向=%s,柜台订单=%s", item.code, item.side, item.id)
continue continue
# 缓存本次有效订单 # 缓存本次有效订单
@@ -86,9 +90,11 @@ class OrderBook:
with self.mutex: with self.mutex:
self.data = data self.data = data
self.lock = lock self.lock = lock
log.info("[订单] 刷新完成,跟踪=%d,处理中=%d,撤销=%d", len(data), len(lock), canceled)
def place(self, request: PlaceOrderRequest) -> bool: def place(self, request: PlaceOrderRequest) -> bool:
"""按最新价提交委托,并立即写入本地方向锁。""" """按最新价提交委托,并立即写入本地方向锁。"""
try:
result = request.client.passorder_latest_tagged( result = request.client.passorder_latest_tagged(
request.op, request.op,
request.code, request.code,
@@ -96,10 +102,15 @@ class OrderBook:
request.strategy_name, request.strategy_name,
request.order_id, request.order_id,
) )
except APIError as exc:
log.exception("[订单] 下单失败,代码=%s,本地订单=%sHTTP状态=%d,错误=%s", request.code, request.order_id, exc.status_code, exc.message or str(exc))
return False
if not isinstance(result, dict): if not isinstance(result, dict):
log.warning("[订单] 下单失败,代码=%s,本地订单=%s,原因=响应格式无效", request.code, request.order_id)
return False return False
order_ref = str(result.get("order_ref") or "").strip().lower() order_ref = str(result.get("order_ref") or "").strip().lower()
if result.get("status") != "success" or order_ref in {"", "unknown", "none"}: 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 return False
side = ORDER_SIDE_BY_OFFSET.get(str(request.op), "") side = ORDER_SIDE_BY_OFFSET.get(str(request.op), "")
@@ -117,4 +128,5 @@ class OrderBook:
key = f"{side}-{request.code}" key = f"{side}-{request.code}"
self.data.append(pending) self.data.append(pending)
self.lock[key] = pending.created_at.timestamp() 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 return True

View File

@@ -2,7 +2,6 @@
from __future__ import annotations from __future__ import annotations
import logging
from dataclasses import dataclass from dataclasses import dataclass
from libs.calc import calc_buy_volume, calculate_min_profit_rate 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 .order import PlaceOrderRequest
from .runtime import Runtime from .runtime import Runtime
from .state import STATUS_ING from .state import STATUS_ING
import logging as log
LEG_BASE = "base" LEG_BASE = "base"
LEG_ADDED = "add" LEG_ADDED = "add"
@@ -43,11 +43,11 @@ def manage_positions(
runtime.profit_tracker.retain(active_keys) runtime.profit_tracker.retain(active_keys)
remaining_cash = max(0.0, available) remaining_cash = max(0.0, available)
logging.info("[持仓] 共 %d 只,开始处理", len(positions))
for position in positions: for position in positions:
code = position.stock_code code = position.stock_code
tick = ticks.get(code) tick = ticks.get(code)
if code in runtime.account_cfg.excluded_codes: if code in runtime.account_cfg.excluded_codes:
log.info("[持仓] 代码=%s,名称=%s,止盈=跳过,补仓=跳过,原因=已配置为排除股票", code, position.stock_name)
continue continue
if ( if (
not code not code
@@ -56,6 +56,7 @@ def manage_positions(
or tick is None or tick is None
or tick.last_price <= 0 or tick.last_price <= 0
): ):
log.warning("[持仓] 代码=%s,名称=%s,止盈=跳过,补仓=跳过,原因=持仓或行情数据无效", code or "未知", position.stock_name)
continue continue
pnl_rate = round( pnl_rate = round(
@@ -70,9 +71,8 @@ def manage_positions(
pnl_rate=pnl_rate, pnl_rate=pnl_rate,
minimum_profit=minimum_profit, minimum_profit=minimum_profit,
) )
if profit_decision.message: profit_action = profit_decision.message or "未触发"
logging.info("[止盈] %s %s", code, profit_decision.message) loss_add_action = "未启用"
if runtime.account_cfg.enable_loss_add_position and market_ok: if runtime.account_cfg.enable_loss_add_position and market_ok:
loss_decision = handle_loss( loss_decision = handle_loss(
runtime=runtime, runtime=runtime,
@@ -82,8 +82,14 @@ def manage_positions(
available=remaining_cash, available=remaining_cash,
) )
remaining_cash -= loss_decision.reserved_cash remaining_cash -= loss_decision.reserved_cash
if loss_decision.message: loss_add_action = loss_decision.message or "未触发"
logging.info("[补仓] %s %s", code, loss_decision.message) 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( def handle_profit(

View File

@@ -3,6 +3,7 @@
from __future__ import annotations from __future__ import annotations
import json import json
import logging as log
from dataclasses import asdict, dataclass from dataclasses import asdict, dataclass
from pathlib import Path from pathlib import Path
from threading import Lock from threading import Lock
@@ -93,6 +94,7 @@ class State:
立即保存,确保首次接管的持仓在程序重启后仍可恢复。 立即保存,确保首次接管的持仓在程序重启后仍可恢复。
""" """
known_codes = set(self.codes) known_codes = set(self.codes)
imported = 0
for position in positions: for position in positions:
if ( if (
not position.stock_code not position.stock_code
@@ -111,8 +113,11 @@ class State:
) )
) )
known_codes.add(position.stock_code) known_codes.add(position.stock_code)
imported += 1
self.save() self.save()
if imported:
log.info("[状态] 导入持仓=%d,状态总数=%d", imported, len(known_codes))
def reconcile( def reconcile(
self, self,
@@ -151,6 +156,8 @@ class State:
if all(order.status == "56" for order in matching_orders) if all(order.status == "56" for order in matching_orders)
else STATUS_ING 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) setattr(item, status_attr, status)
self.set(item) self.set(item)
@@ -158,7 +165,8 @@ class State:
# reconciliation must therefore happen before stale state is removed. # reconciliation must therefore happen before stale state is removed.
for code in list(self.codes): for code in list(self.codes):
if code not in position_codes: if code not in position_codes:
self.delete(code) if self.delete(code):
log.info("[状态] 已移除持仓状态,代码=%s", code)
self.save() self.save()
def save(self) -> None: def save(self) -> None:

View File

@@ -1,7 +1,7 @@
import logging as log
from dataclasses import dataclass from dataclasses import dataclass
from datetime import datetime, timedelta from datetime import datetime, timedelta
from threading import Lock from threading import Lock
import logging
@dataclass(slots=True) @dataclass(slots=True)
@@ -11,24 +11,95 @@ class _Entry:
class DipWatch: 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: def __init__(
if price <= 0: return False self,
now = now or datetime.now() 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: with self.lock:
watch = self.data.get(code) 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: 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 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] 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 return True
def forget(self, code): def forget(self, code: str) -> None:
with self.lock: self.data.pop(code, 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),
)

View File

@@ -14,12 +14,12 @@ def main():
_, positions = client.positions() _, positions = client.positions()
print(f"总资产:{assets.total:.2f}元,可用资金:{assets.available:.2f}") print(f"总资产:{assets.total:.2f}元,可用资金:{assets.available:.2f}")
for p in sorted(positions, key=lambda item: item.stock_code): 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() data_dir = os.environ.get("QMT_DATA_DIR", "").strip()
if not data_dir: raise SystemExit("环境变量 QMT_DATA_DIR 为空") if not data_dir: raise SystemExit("环境变量 QMT_DATA_DIR 为空")
codes = json.loads((Path(data_dir) / "pass_codes.json").read_text(encoding="utf-8")) codes = json.loads((Path(data_dir) / "pass_codes.json").read_text(encoding="utf-8"))
for code, tick in sorted(client.full_tick(codes).items()): 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() if __name__ == "__main__": main()

View File

@@ -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()

View File

@@ -8,7 +8,7 @@ from types import SimpleNamespace
from unittest.mock import patch from unittest.mock import patch
from libs.grid_take_profit import GridState, GridTrailingTracker 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.order import OrderBook, PlaceOrderRequest
from strategy.trend.positions import LOSS_TIERS, handle_loss, manage_positions from strategy.trend.positions import LOSS_TIERS, handle_loss, manage_positions
from strategy.trend.boot import RunOnce from strategy.trend.boot import RunOnce
@@ -36,6 +36,11 @@ class FakeOrderClient:
self.canceled.append(order_id) 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): class TrendTests(unittest.TestCase):
def test_grid_states_and_account_isolation(self): def test_grid_states_and_account_isolation(self):
tracker = GridTrailingTracker(1) tracker = GridTrailingTracker(1)
@@ -53,6 +58,17 @@ class TrendTests(unittest.TestCase):
self.assertTrue(book.place(request)) self.assertTrue(book.place(request))
self.assertTrue(book.busy("000001.SZ", "BUY")) 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): def test_refresh_tracks_active_and_completed_and_cancels_expired(self):
old = datetime.now() - timedelta(seconds=20) old = datetime.now() - timedelta(seconds=20)
orders = [ orders = [
@@ -90,6 +106,40 @@ class TrendTests(unittest.TestCase):
) )
manage_positions(runtime, {"000001.SZ": Tick(last_price=10.1)}, [position], True, 5000) 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): def test_loss_tier_boundary_does_not_overflow(self):
self.assertEqual(len(LOSS_TIERS), 2) self.assertEqual(len(LOSS_TIERS), 2)
with TemporaryDirectory() as directory: with TemporaryDirectory() as directory: