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

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

View File

@@ -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:

View File

@@ -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,本地订单=%sHTTP状态=%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

View File

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

View File

@@ -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:

View File

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