fix bug
This commit is contained in:
190
docs/arch/state.py
Normal file
190
docs/arch/state.py
Normal file
@@ -0,0 +1,190 @@
|
||||
"""底仓、补仓记录与待确认订单的 JSON 存储。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging as log
|
||||
import time
|
||||
from dataclasses import asdict, dataclass, field, replace
|
||||
from pathlib import Path
|
||||
from threading import RLock
|
||||
from typing import Any
|
||||
|
||||
from sdk import OrderItem, PositionItem
|
||||
|
||||
PENDING_TIME_OUT = 3600
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class StateItem:
|
||||
code: str
|
||||
base_order_id: str = ""
|
||||
base_qty: int = 0
|
||||
base_cost: float = 0.0
|
||||
added_order_id: str = ""
|
||||
added_num: int = 0
|
||||
added_qty: int = 0
|
||||
added_cost: float = 0.0
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class PendingOrder:
|
||||
order_id: str
|
||||
code: str
|
||||
kind: str
|
||||
pre_qty: int
|
||||
submit_at: int = field(default_factory=lambda: int(time.time()))
|
||||
|
||||
|
||||
class State:
|
||||
def __init__(self, data_dir: str | Path, strategy: str, account_id: str) -> None:
|
||||
self.path = Path(data_dir) / f"{strategy}_{account_id}_state.json"
|
||||
self.lock = RLock()
|
||||
self.items: dict[str, StateItem] = {}
|
||||
self.pending: list[PendingOrder] = []
|
||||
self.IsModify = False
|
||||
self._load()
|
||||
|
||||
def _load(self) -> None:
|
||||
"""启动时读取 JSON 中的持仓记录和待确认订单。"""
|
||||
if self.path.is_file():
|
||||
raw = json.loads(self.path.read_text(encoding="utf-8"))
|
||||
self.items = {code: StateItem(**item) for code, item in raw["items"].items()}
|
||||
self.pending = [PendingOrder(**item) for item in raw["pending"]]
|
||||
now = int(time.time())
|
||||
for pending in list(self.pending):
|
||||
if now - pending.submit_at >= PENDING_TIME_OUT:
|
||||
self.pending.remove(pending)
|
||||
self.IsModify = True
|
||||
log.info("[状态] 清理超时 pending,代码=%s,订单=%s", pending.code, pending.order_id)
|
||||
self.save()
|
||||
|
||||
|
||||
def get(self, code: str) -> StateItem:
|
||||
with self.lock:
|
||||
return self.items[code]
|
||||
|
||||
def new_order(self, order: PendingOrder) -> None:
|
||||
"""提交前保存待确认订单,同一证券已有 pending 时跳过。"""
|
||||
with self.lock:
|
||||
if order:
|
||||
self.pending.append(order)
|
||||
self.IsModify = True
|
||||
self.save()
|
||||
|
||||
def merged_order(self, orders: list[OrderItem]) -> dict[str, dict[str, Any]]:
|
||||
"""按证券代码、本地订单号合并,返回成交数量、金额、均价和状态。"""
|
||||
merged: dict[str, dict[str, Any]] = {}
|
||||
for order in orders:
|
||||
if not order.local_order_id:
|
||||
continue
|
||||
|
||||
if not order.local_order_id in merged.keys():
|
||||
strStatus = "ING"
|
||||
if order.status == "56":
|
||||
strStatus = "OK"
|
||||
merged[order.local_order_id] = {
|
||||
"code":order.code,
|
||||
"qty": order.traded_volume,
|
||||
"cost": order.trade_price,
|
||||
"status": order.status,
|
||||
"merged_status":strStatus
|
||||
}
|
||||
continue
|
||||
|
||||
old = merged[order.local_order_id]
|
||||
strStatus = "ING"
|
||||
if old["status"] == order.status == "56":
|
||||
strStatus = "OK"
|
||||
|
||||
totalQty = old["qty"]+order.traded_volume
|
||||
# 合计成交数量为零,跳过,避免除零。
|
||||
if totalQty == 0:
|
||||
continue
|
||||
# 有成交数量但缺少成交金额,跳过,避免拉低成本。
|
||||
if order.traded_volume > 0 and not order.trade_amount:
|
||||
continue
|
||||
cost = ((old["qty"]*old["cost"])+order.trade_amount) / totalQty
|
||||
merged[order.local_order_id]["qty"] = totalQty
|
||||
merged[order.local_order_id]["cost"] = cost
|
||||
merged[order.local_order_id]["merged_status"] = strStatus
|
||||
|
||||
return merged
|
||||
|
||||
def reconcile(self, positions: list[PositionItem], orders: list[OrderItem]) -> None:
|
||||
"""合并订单 → 对齐 pending 和持仓 → 保存 JSON。"""
|
||||
# 1. 合并同一本地订单的成交数据。
|
||||
merged = self.merged_order(orders)
|
||||
|
||||
with self.lock:
|
||||
# 2. 按本地订单号查找合并结果,核对证券代码后写入底仓或补仓。
|
||||
# 只留下尚未完成确认的订单,避免循环中反复查找、删除列表元素。
|
||||
remaining: list[PendingOrder] = []
|
||||
for pending in self.pending:
|
||||
code = pending.code
|
||||
result = merged.get(pending.order_id)
|
||||
if result is None or result["code"] != code:
|
||||
# 按现有规则:本轮快照中没有对应订单,就清理 pending。
|
||||
self.IsModify = True
|
||||
log.info("[状态] 清理无对应订单的 pending,代码=%s,订单=%s", code, pending.order_id)
|
||||
continue
|
||||
if result["merged_status"] != "OK" or result["qty"] != pending.pre_qty:
|
||||
# 未全部成功,或拆单快照的数量尚未齐全,留到下轮确认。
|
||||
remaining.append(pending)
|
||||
continue
|
||||
qty, cost = result["qty"], result["cost"]
|
||||
# 只记录实际成交。
|
||||
if qty:
|
||||
item = self.items.get(code, StateItem(code))
|
||||
if pending.kind == "base":
|
||||
# 底仓:记录本次成交数量和实际均价。
|
||||
item = replace(item, base_order_id=pending.order_id,
|
||||
base_qty=qty, base_cost=cost)
|
||||
else:
|
||||
# 补仓:次数加一,数量累加,成本按成交数量加权。
|
||||
total_qty = item.added_qty + qty
|
||||
total_amount = item.added_qty * item.added_cost + qty * cost
|
||||
item = replace(item, added_order_id=pending.order_id,
|
||||
added_num=item.added_num + 1,
|
||||
added_qty=total_qty, added_cost=total_amount / total_qty)
|
||||
self.items[code] = item
|
||||
self.IsModify = True
|
||||
log.info("[状态] 对账结束,代码=%s,订单=%s,计划=%d,成交=%d",
|
||||
code, pending.order_id, pending.pre_qty, qty)
|
||||
|
||||
self.pending = remaining
|
||||
|
||||
# 未记录且没有待确认订单的持仓,作为首次接管的底仓导入。
|
||||
pending_codes = {pending.code for pending in remaining}
|
||||
for position in positions:
|
||||
code = position.stock_code
|
||||
if not code or position.volume <= 0:
|
||||
continue
|
||||
item = self.items.get(code)
|
||||
if item is None:
|
||||
if code not in pending_codes:
|
||||
self.items[code] = StateItem(code=code, base_order_id=position.trade_id,
|
||||
base_qty=position.volume, base_cost=position.open_price)
|
||||
self.IsModify = True
|
||||
continue
|
||||
|
||||
# 已有记录只报告数量差异,不用总持仓覆盖底仓/补仓的划分。
|
||||
recorded_qty = item.base_qty + item.added_qty
|
||||
if recorded_qty != position.volume:
|
||||
log.info("[状态] 持仓数量差异,代码=%s,记录=%d,实际=%d",
|
||||
code, recorded_qty, position.volume)
|
||||
|
||||
# 3. 本轮统一保存;没有修改时 save() 不写文件。
|
||||
self.save()
|
||||
|
||||
def save(self) -> None:
|
||||
with self.lock:
|
||||
if not self.IsModify:
|
||||
return
|
||||
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||
temporary = self.path.with_suffix(self.path.suffix + ".tmp")
|
||||
payload = dict(items={code: asdict(item) for code, item in self.items.items()},
|
||||
pending=[asdict(item) for item in self.pending])
|
||||
temporary.write_text(json.dumps(payload, ensure_ascii=False, indent=2, allow_nan=False) + "\n", encoding="utf-8")
|
||||
temporary.replace(self.path)
|
||||
self.IsModify = False
|
||||
84
docs/trend-audit-2026-09-05.md
Normal file
84
docs/trend-audit-2026-09-05.md
Normal file
@@ -0,0 +1,84 @@
|
||||
# Trend 当前工作树重新审计
|
||||
|
||||
审计日期:2026-09-05。以当前磁盘文件(包含未提交的手动修改)为准,不继承旧报告的问题状态。仅审计 Trend 及直接依赖;不修改策略代码,不连接交易接口。
|
||||
|
||||
## 结论
|
||||
|
||||
当前仍有 4 项高风险、3 项中风险问题。不建议仅凭语法检查通过直接运行实盘。本报告只保留当前存在的问题与待确认行为,问题编号保留以便引用。
|
||||
|
||||
工作树中 `strategy/trend/state.py` 已删除,`Runtime` 也已移除 `state` 字段;`docs/arch/state.py` 是归档,不是当前运行模块。因此此前围绕 `merged_order()` 的结论不作为当前策略结论。
|
||||
|
||||
## H5:止盈委托刚受理就清理峰值,未成交持仓失去回撤基准
|
||||
|
||||
- 位置:`positions.py:136`;`libs/grid_take_profit.py` 的 `clear(self, position_key)`。
|
||||
- 证据:`orders.place()` 返回成功后立即执行 `runtime.profit_tracker.clear(key)`,没有等待成交或持仓消失。
|
||||
- 影响:卖单未成交、部分成交或随后被超时撤销时,剩余持仓的旧峰值已丢失;下轮会从较低盈利重新建立基准,原回撤信号可能不再触发。
|
||||
- 验证:真实网格跟踪器记录 12% 峰值,10% 时模拟提交成功;同一持仓下一次 9.5% 观察返回 `ARMED` 而不是沿用旧峰值的 `RETREAT`。没有真实交易。
|
||||
- 建议:仅在后续快照确认持仓消失时清理;撤单或部分成交继续保留剩余持仓的峰值,成本变化时另行明确重置规则。
|
||||
|
||||
## H2:自动撤单未限定为本策略订单
|
||||
|
||||
- 位置:`order.py:60`、`:68`、`:78`;`boot.py:57`;`sdk/portfolio.py:9`。
|
||||
- 证据:将组合接口的订单列表直接传入 `refresh()`,只检查状态与时间,没有检查本地订单号前缀、策略归属或排除名单。
|
||||
- 触发:组合接口返回同账户其他策略或手工订单,且符合超时条件。
|
||||
- 影响:启动及每轮刷新都可能撤销非 Trend 订单,包括排除证券的订单。
|
||||
- 建议:账户级活动订单可用于防重,但主动撤单必须单独限定所有权。若业务确实授权管理整个账户,需明确记录该权限。
|
||||
|
||||
## H3:大盘过滤实际始终放行
|
||||
|
||||
- 位置:`py-client/libs/market.py` 的 `market_allow_open()`;`boot.py:151`;`positions.py:67`。
|
||||
- 证据:真实状态比较被注释,函数直接 `return True`。
|
||||
- 影响:下跌或未知状态仍允许开仓及补仓,外层看似存在的风险限制不生效。该问题属于 Trend 直接依赖,不是 Trend 文件内的新修改。
|
||||
- 建议:恢复真实状态判断,或以明确、可见的配置表示主动关闭过滤。
|
||||
- 验证:将本地测试状态设为 `DOWN`,仍返回 `True`;未请求网络。
|
||||
|
||||
## H4:买入没有统一资金预算,最小整手还可能突破单笔额度
|
||||
|
||||
- 位置:`boot.py:146`、`:176`;`open.py:51`;`positions.py:160`;`libs/calc.py:10`。
|
||||
- 证据:开仓只依据本轮起始资金比例决定是否启动,不逐单扣减预算;补仓线程独立使用相同快照资金。`calc_buy_volume()` 用 `max(1, ...)` 强制至少买一手。
|
||||
- 影响:多个开仓与补仓可能同时消耗同一份可用资金;可能跌破配置的现金安全线,或产生资金不足拒单。并非断言券商一定允许超额成交。
|
||||
- 示例:价格 100 元、单笔额度 5,000 元,计算出 100 股,即 10,000 元。
|
||||
- 建议:开仓和补仓共享本轮可预留预算;不足一手返回零;预留考虑手续费和行情变化,保留最小现金要求。
|
||||
|
||||
## M2:撤单异常会中断整轮刷新和交易管理
|
||||
|
||||
- 位置:`order.py:78`、`:86`;`boot.py:122` 起的快照处理。
|
||||
- 证据:撤单在遍历中直接调用,未逐单隔离;`busy_keys` 和 `data` 在全部遍历结束后才赋值。
|
||||
- 影响:某笔撤单失败即退出 `refresh()`,快照不更新;常规轮次返回而跳过全部交易管理,初始化阶段则退出启动(客户端会关闭)。持续失败的订单可能持续阻断后续轮次。
|
||||
- 建议:撤单逐项记录异常,同时发布完整活动订单快照;撤单失败的订单继续作为在途订单防重。
|
||||
|
||||
## M3:信号前置处理没有逐项异常隔离
|
||||
|
||||
- 位置:`open.py:16` 至 `:55`。
|
||||
- 证据:异常捕获仅包围 `do_open()`;配置、价格、数量计算、观察器调用不在逐候选保护边界中。
|
||||
- 触发:一个候选包含异常数据,例如非有限价格导致数量计算异常,或配置字段类型不符合预期。
|
||||
- 影响:该候选之后的所有开仓信号本轮不再处理;工作线程最外层只能记录整个任务失败。
|
||||
- 建议:把一个候选的完整处理放入同一异常边界,记录证券与信号键后继续;保持必要校验即可。
|
||||
|
||||
## M4:补仓请求结果不确定时未保留资金预算
|
||||
|
||||
- 位置:`positions.py:175`、`:75`;`order.py:119` 起的异常处理。
|
||||
- 证据:`place()` 对请求超时等异常返回 `False`,保留证券方向缓存,但 `handle_loss()` 返回的 `reserved_cash` 默认为零。
|
||||
- 触发:券商已受理补仓但响应丢失,本轮继续处理后续证券。
|
||||
- 影响:方向缓存只能防同证券重复下单,不能阻止其他证券重复使用这笔可能已消耗的资金。该问题即使只启用持仓管理、不开新仓也存在,与 H4 的跨线程预算问题不同。
|
||||
- 验证:模拟 `place()` 返回 `False`,补仓结果 `reserved_cash == 0.0`。
|
||||
- 建议:区分明确未提交与结果不确定;不确定时保留本轮预计金额,或停止本轮后续买入并刷新资金。
|
||||
|
||||
## 需要确认的行为(不直接判为错误)
|
||||
|
||||
- `positions.py:102` 在盈利低于最低收益门槛时不再观察网格:若从高峰直接跌破该门槛,不会触发回撤卖出。需要确认门槛是仅用于首次激活,还是任何时候都禁止低于门槛卖出。
|
||||
- `positions.py:122` 向下取整到百股,可用持仓不足一手将一直跳过。是否需要支持零股清仓取决于交易标的及产品要求,本次未核验外部市场规则。
|
||||
- 当前 `LOSS_TIERS = [-50.0]`,且 `get_add_num()` 依据手数/当前市值判断,不再记录真实补仓次数。若是主动放弃状态机后的新设计,应更新策略说明;不能等同于历史成交次数。
|
||||
- `boot.py:84` 使用 `>= 15:00:00`;与此前“严格大于 15:00”的表述存在边界差别。启动初始化发生在此检查之前,仍可能查询或撤单。
|
||||
|
||||
## 已确认的保护与验证范围
|
||||
|
||||
- `busy()` 和 `place()` 都检查券商快照的 `busy_keys` 及本地 `SimpleCache`;`place()` 在互斥区内检查并写入,不再报告旧版“下单未使用防重”的问题。
|
||||
- 开仓与持仓管理都检查排除名单;未知信号配置有独立日志后跳过。
|
||||
- `place()` 捕获 API、HTTP 请求和解码类异常;持仓管理有逐证券异常边界。
|
||||
- 启动生命周期有 `finally`,等待线程池后关闭客户端。
|
||||
- 当前 7 个 Trend 源文件全部重新通过 AST 语法检查。本轮使用真实网格组件和模拟下单,验证成功提交后的峰值清理及下一轮重新激活行为。
|
||||
- 资金预算、大盘过滤、撤单和信号异常边界本轮已重新读取;预算超额及失败补仓零预算预留的实现与此前最小测试对应代码一致。
|
||||
- 未执行真实下单、撤单、完整 SDK 联调或券商回报测试;语法通过不代表策略可运行。归档文件和历史审计测试统计不计入当前验证。
|
||||
|
||||
优先顺序:先处理撤单权限、市场过滤、资金预算及止盈峰值生命周期,再完善异常边界。
|
||||
Reference in New Issue
Block a user