This commit is contained in:
2026-08-28 18:52:27 +08:00
parent e0ecdfba52
commit d09f271569
89 changed files with 1716 additions and 2515 deletions

View File

@@ -0,0 +1,5 @@
from .order import OrderBook, PlaceOrderRequest
from .state import State, StateItem
from .watch import DipWatch
from .open import check_timezone, open_signal
from .positions import manage_positions

View File

@@ -0,0 +1,176 @@
"""趋势策略启动器。
该模块负责组合 SDK、配置、状态存储和趋势策略组件供 main.py 调用。
"""
from __future__ import annotations
import logging
import time
from datetime import datetime
import config
from libs import init_signals, market_allow_open, trading_time
from sdk import Client
from .state import State
from .order import OrderBook
from .watch import DipWatch
from .runtime import Runtime
from .open import open_signal
from .positions import manage_positions
def Overview(assets, positions, account_cfg=None) -> None:
"""打印策略启动时的账户、资金和持仓概览。
该函数对应 Go 客户端 ``logic.Overview``。为便于单独测试,可以
显式传入账户配置;未传入时使用 ``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:
print(
"【配置】"
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:
print(
f"【资金】总资产:{assets.total:.2f}元,"
f"可用资金:{assets.available:.2f}"
)
else:
print("【资金】查询失败")
print(f"【持仓】{len(positions)}")
print("=" * 80)
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}%"
)
def StartTrend() -> None:
"""初始化趋势策略,并以 30 秒间隔持续执行。"""
client = Client(
config.global_config.qmt_base_url,
config.global_config.qmt_token,
config.HTTP_TIMEOUT,
)
assets = client.assets()
_, positions = client.positions()
storeState = State.for_strategy(
config.global_config.qmt_data_dir,
config.account_config.strategy,
config.account_config.account_id,
)
storeState.sync_positions(positions)
# 获取本策略的信号开仓数据
signals = init_signals(config.global_config,["morning","tail","arbitrage"])
run = Runtime(
client=client,
global_cfg=config.global_config,
account_cfg=config.account_config,
state=storeState,
orders=OrderBook(),
open_watch=DipWatch(),
add_watch=DipWatch(),
)
logging.info(
"趋势策略启动:总资产=%.2f,持仓=%d,信号=%d",
assets.total,
len(positions),
len(signals),
)
Overview(assets, positions, config.account_config)
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))
def RunOnce(run: Runtime, signals) -> None:
"""按固定步骤执行一轮趋势策略, ``RunOnce``。"""
if not trading_time(datetime.now()):
return
# 1. 取消超过有效期仍未完成的委托订单。
try:
run.orders.cancel_expired(run.client)
except Exception:
logging.exception("取消过期订单失败")
# 2. 验证可用资金;低于资金安全线时禁止开新仓。
try:
assets = run.client.assets()
except Exception:
logging.exception("获取资产失败")
return
if assets.available < assets.total * run.account_cfg.min_cash_ratio:
logging.info("资金总闸:可用金额太少,禁止开新仓")
return
# 3. 获取大盘状态,只有大盘信号允许时才执行开仓。
market_ok = market_allow_open(run.global_cfg.api_host)
# 4. 获取当前持仓及持仓证券代码。
try:
position_codes, positions = run.client.positions()
except Exception:
logging.exception("获取持仓失败")
return
# 5. 验证有效开仓信号:排除已有持仓,并按 signal_allow 过滤。
position_code_set = set(position_codes)
allow_open = [
signal for signal in signals if signal.code not in position_code_set
]
# 6. 获取持仓和待开仓证券的实时行情 tick。
all_codes = list(position_codes)
all_codes.extend(
signal.code for signal in allow_open if signal.code not in position_code_set
)
try:
ticks = run.client.full_tick(list(dict.fromkeys(all_codes)))
except Exception:
logging.exception("获取行情失败")
return
# 7. 执行开仓:必须同时存在有效信号且大盘允许开仓。
if allow_open and market_ok:
open_signal(run, ticks, allow_open)
# 8. 持仓计算。当前 Go 版本的 managePositions 为空,保留扩展入口。
manage_positions(run, ticks, positions, market_ok,assets.available)
def SignalFilter(signals, allowed_names):
"""只保留账户配置明确允许使用的信号。"""
if not allowed_names:
return []
allowed = set(allowed_names)
return [signal for signal in signals if signal.signal_key in allowed]

View File

@@ -0,0 +1,105 @@
"""趋势策略开仓逻辑,对应 Go 版本的 ``logic/open.go``。"""
from __future__ import annotations
import logging
from datetime import datetime
from libs import calc_buy_volume
from sdk import OP_BUY
from .order import PlaceOrderRequest
from .state import STATUS_ING, StateItem
def open_signal(run, ticks, open_signals) -> None:
"""逐个验证开仓信号并提交买入委托。"""
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):
continue
# 2. 检查该证券是否已有买入委托锁,防止重复下单。
if run.orders.busy(item.code,"BUY"):
continue
# 3. 验证行情和最新价格是否有效。
tick = ticks.get(item.code)
price = tick.last_price if tick is not None else 0
if price <= 0:
continue
# 4. 等待价格从观察低点反弹,防止直接接下跌中的“飞刀”。
if not run.open_watch.triggered("开仓", item.code, price):
continue
# 5. 根据单笔买入金额计算整手开仓数量。
volume = calc_buy_volume(price, run.account_cfg.buy_value)
if volume <= 0:
continue
# 6. 生成本地订单号并按最新价提交开仓委托。
order_id = run.orders.new_order_id("base")
request = PlaceOrderRequest(run.client, OP_BUY, item.code, volume, order_id)
if not run.orders.place(request):
continue
# 7. 保存底仓订单、数量、成本和处理中状态。
run.state.set(
StateItem(
code=item.code,
base_order_id=order_id,
base_qty=volume,
base_cost=price,
base_status=STATUS_ING,
)
)
try:
run.state.save()
except OSError:
logging.exception("[状态] %s 开仓状态保存失败", item.code)
run.open_watch.forget(item.code)
logging.info("[ZT][开仓] %s 买入 %d", item.code, volume)
def check_timezone(timezone: str, now: datetime | None = None) -> bool:
"""验证当前时间是否处于配置区间。
``*`` 表示全天允许;多个区间用逗号分隔,例如
``9:30-10:30,13:30-14:30``。同时支持跨午夜区间。
"""
timezone = str(timezone or "").strip()
if timezone == "*":
return True
current = now or datetime.now()
current_minutes = current.hour * 60 + current.minute
for section in timezone.split(","):
bounds = section.strip().split("-")
if len(bounds) != 2:
continue
start = _parse_minutes(bounds[0])
end = _parse_minutes(bounds[1])
if start is None or end is None:
continue
if start <= end and start <= current_minutes <= end:
return True
if start > end and (current_minutes >= start or current_minutes <= end):
return True
return False
def _parse_minutes(value: str) -> int | None:
"""把 ``时:分`` 转换为当天分钟数,无效值返回 None。"""
try:
hour_text, minute_text = value.strip().split(":")
hour, minute = int(hour_text), int(minute_text)
except (TypeError, ValueError):
return None
if not 0 <= hour <= 23 or not 0 <= minute <= 59:
return None
return hour * 60 + minute

View File

@@ -0,0 +1,148 @@
"""趋势策略委托簿,对应 Go 客户端的 ``logic/order.go``。"""
from __future__ import annotations
import secrets
from dataclasses import dataclass
from datetime import datetime, timedelta
from threading import Lock
from typing import Any
# QMT 开平方向字段到本地买卖方向的映射。
OFFSET_FLAG = {"23": "BUY", "24": "SELL", "48": "BUY", "49": "SELL"}
# 表示委托仍在处理、可能继续成交的 QMT 状态。
BUSY_STATUSES = {"48", "49", "50", "51", "52", "55"}
@dataclass(frozen=True)
class PlaceOrderRequest:
"""``OrderBook.place`` 提交委托所需的全部参数。"""
client: Any
op: int
code: str
volume: int
order_id: str
@dataclass
class OrderItem:
"""从 QMT 委托明细转换得到的本地订单记录。"""
id: str
code: str
side: str
remark: str
status: str
created_at: datetime | None
volume: int
class OrderBook:
"""线程安全的活动委托缓存。"""
def __init__(self, timeout_seconds: float = 300) -> None:
self.timeout = timedelta(seconds=timeout_seconds)
self.data: dict[str, OrderItem] = {}
self.index: list[str] = []
self.lock = Lock()
@staticmethod
def new_order_id(leg: str) -> str:
"""生成不超过 24 个字符的策略订单号。"""
return f"zt-{leg}-{secrets.token_hex(6)}"[:24]
def is_lock(self, side: str, code: str) -> bool:
"""判断证券在指定买卖方向上是否已经被委托锁定。"""
with self.lock:
return f"{side}-{code}" in self.index
def busy(self, code: str, side: str) -> bool:
"""判断证券是否存在仍在处理中的同方向委托。"""
with self.lock:
order = self.data.get(f"{side}-{code}")
return bool(order and order.status in BUSY_STATUSES)
def refresh(self, client: Any) -> None:
"""从 QMT 刷新当前委托明细和方向索引。"""
parsed_orders = [
parse_order(row) for row in client.trade_detail_data("order")
]
with self.lock:
self.data = {key: item for key, item in parsed_orders}
self.index = [key for key, _ in parsed_orders]
def cancel_expired(self, client: Any, now: datetime | None = None) -> None:
"""尝试撤销超过有效期且具有委托编号的订单。"""
self.refresh(client)
current = now or datetime.now()
# 使用快照遍历,避免网络调用期间长期持有互斥锁。
for order in list(self.data.values()):
if (
order.created_at is not None
and current - order.created_at > self.timeout
and order.id
):
client.can_cancel_order(order.id)
def place(self, request: PlaceOrderRequest) -> bool:
"""按最新价提交委托,并立即写入本地方向锁。"""
request.client.passorder_latest_tagged(
request.op,
request.code,
request.volume,
request.order_id,
)
side = OFFSET_FLAG.get(str(request.op), "")
with self.lock:
self.index.append(f"{side}-{request.code}")
return True
def parse_order(row: dict[str, Any]) -> tuple[str, OrderItem]:
"""把 QMT 原始委托字段转换为本地订单及其索引键。"""
volume = _as_int(row.get("m_nVolumeTotal")) + _as_int(
row.get("m_nVolumeTraded")
)
timestamp = _as_int(row.get("m_nOrderTime"))
if timestamp > 100_000_000_000:
# QMT 某些版本返回毫秒时间戳。
timestamp /= 1000
created_at = (
datetime.fromtimestamp(timestamp)
if timestamp
else _parse_insert_datetime(row)
)
item = OrderItem(
id=str(row.get("m_strOrderSysID") or ""),
code=str(row.get("m_strInstrumentID") or ""),
side=OFFSET_FLAG.get(str(row.get("m_nOffsetFlag")), ""),
remark=str(row.get("m_strRemark") or ""),
status=str(row.get("m_nOrderStatus") or ""),
created_at=created_at,
volume=volume,
)
return f"{item.side}-{item.code}", item
def _as_int(value: Any) -> int:
"""安全转换整数,无效值按 0 处理。"""
try:
return int(value or 0)
except (TypeError, ValueError):
return 0
def _parse_insert_datetime(row: dict[str, Any]) -> datetime | None:
"""使用委托日期和时间字段构造本地时间。"""
date = str(row.get("m_strInsertDate") or "")
clock = str(row.get("m_strInsertTime") or "").replace(":", "").zfill(6)
try:
return datetime.strptime(date + clock, "%Y%m%d%H%M%S")
except ValueError:
return None

View File

@@ -0,0 +1,167 @@
"""趋势策略持仓管理逻辑,对应 Go 版本的 ``logic/positions.go``。"""
from __future__ import annotations
import logging
from math import floor
from libs.calc import calc_buy_volume,calculate_min_profit_rate
from libs.grid_take_profit import GridState, GridTrailingTracker
from sdk import OP_BUY, OP_SELL
import config
from .order import PlaceOrderRequest
from .state import STATUS_ING, STATUS_NONE, STATUS_OK
from .runtime import Runtime
LEG_BASE = "base"
LEG_ADDED = "add"
# 止盈网格跟踪器延迟初始化,避免导入模块时账户配置尚未加载。
profit_tracker = None
# 分级补仓档位(百分比)
LOSS_TIERS = [-30, -50]
# 补仓反弹确认阈值(百分比)
LOSS_REBOUND_THRESHOLD = 0.5
def manage_positions(run:Runtime, ticks, positions, market_ok: bool,available:float) -> None:
"""执行持仓计算。"""
logging.info(f"持仓:{len(positions)} 支股票,开始处理")
global profit_tracker
profit_tracker = GridTrailingTracker(step=run.account_cfg.grid_step_pct)
for idx,pos in positions:
code = pos['stock_code']
avg_price = pos.get('avg_price', 0)
volume = pos.get('volume', 0)
can_use_volume = pos.get('can_use_volume', 0)
current_price = ticks.get(code, {}).get('lastPrice', 0)
strategy_name = pos.get('strategy_name', '')
market_value = pos.get('market_value',0)
profit = pos.get('profit_rate', 0)
# 排除指定股票
if code in config.account_config.excluded_codes:
continue
# 过滤无效仓位
if avg_price == 0 or can_use_volume == 0 or current_price == 0 or volume == 0:
continue
# 计算盈亏率(百分比)
pnl_ratio = (current_price - avg_price) / avg_price * 100 if avg_price != 0 else 0
pnl_ratio = round(pnl_ratio, 2)
# 计算最小利润率1倍
min_profit_rate_val = calculate_min_profit_rate(avg_price, 1)
# 盈利处理
is_closed, message = handle_profit(run,code,avg_price, pnl_ratio, min_profit_rate_val, can_use_volume, strategy_name)
if is_closed:
logging.info("profit", code, f"止盈执行 | {message}")
if message != "":
logging.info("profit", code, message)
# 补仓处理
if config.account_config.enable_loss_add_position and market_ok:
is_replenished, message = handle_loss(run,code,current_price,pnl_ratio,market_value,market_ok,available)
if is_replenished:
logging.info("loss", code, f"补仓执行 | {message}")
if message != "":
logging.info("loss", code, message)
# 盈利处理
def handle_profit(run:Runtime, code: str, pnl_rate: float,
min_profit_rate: float, vol: int) -> tuple[bool, str]:
"""
盈利处理 - 基于网格的止盈策略
Args:
code: 股票代码
open_price: 开仓价格
pnl_rate: 当前盈亏率(百分比)
min_profit_rate: 最小利润率阈值
vol: 可用股数
strategy_name: str
Returns:
tuple[bool, str]: (是否执行平仓, 操作说明)
"""
# 预检查:未达到最小利润率
if pnl_rate < min_profit_rate:
return False, ""
position_key = f"{run.account_cfg.account_id}:{code}"
observation = profit_tracker.observe(position_key, pnl_rate)
if observation.state == GridState.ARMED:
msg = f"首次达到{pnl_rate}%,设置峰值网格{observation.current_grid}"
return False, msg
if observation.state == GridState.RAISED:
return False, f"上涨至{pnl_rate}%,更新峰值网格{observation.current_grid}"
# 执行平仓
if observation.state == GridState.RETREAT:
order_id = run.orders.new_order_id(LEG_BASE)
request = PlaceOrderRequest(run.client, OP_SELL, code, vol, order_id)
result = run.orders.place(request)
if result :
success_msg = f"✓ 委托成功 | {vol}股 订单号:{result} 等待成交"
logging.info("profit", code, success_msg)
return True, success_msg
else:
fail_msg = f"止盈委托失败: {code}"
logging.error("profit", code, "✗ 止盈委托失败")
return False, fail_msg
def handle_loss(run:Runtime, code: str, current_price,pnl_rate,market_value: float,market_ok: bool, available: float) -> tuple[bool, str]:
"""满足条件时提交补仓委托,并返回扣减后的剩余预算。"""
state = run.state.get(code)
added_num = state.get('added_num',0)
# 预检查:未达到最低补仓阈值
if pnl_rate > LOSS_TIERS[added_num]:
return False, ""
# 强制条件
if current_price>200 or market_value>=60000:
return False, f"成本价{current_price}>200,仓位价值{market_value}>=60000, 不补仓"
# 1. 大盘必须允许开仓,且价格已从观察低点达到反弹阈值。
if not market_ok or not run.add_watch.triggered("补仓", code, current_price):
return False
# 2. 计算补仓数量和预计占用金额。
volume = calc_buy_volume(current_price, run.account_cfg.buy_value)
amount = current_price * volume
# 3. 检查预算。
if amount > available:
return False, f"f{code} f{amount} 仓位资金不够补仓"
# 是否已有未完成的买入委托
if run.orders.busy(run, code, "BUY"):
return False, f"{code}订单锁定中"
# 4. 生成补仓订单号并提交买入委托。
order_id = run.orders.new_order_id(LEG_ADDED)
request = PlaceOrderRequest(run.client, OP_BUY, code, volume, order_id)
result = run.orders.place(request)
if result :
state.added_num = +1
state.added_status = run.state.STATUS_ING
state.added_order_id = order_id
run.state.set(state)
run.state.save()
run.add_watch.forget(code)
return True,f"补仓委托成功: {code} {volume}手, 等待成交确认"
else:
return False,f"补仓失败: {code}"
def forget(run, code: str) -> None:
"""持仓退出后清理开仓、补仓观察记录和止盈峰值。"""
run.peak_grids.pop(f"{code}|{LEG_ADDED}", None)

View File

@@ -0,0 +1,43 @@
"""趋势策略单次运行所需的上下文对象。"""
from __future__ import annotations
from dataclasses import dataclass, field
from config import AccountConfig, GlobalConfig
from sdk import Client
from .order import OrderBook
from .state import State
from .watch import DipWatch
@dataclass(slots=True)
class Runtime:
"""集中保存趋势策略运行期间共享的依赖和状态。
将这些对象集中到一个 dataclass 后,开仓、持仓管理和单轮调度函数
只需接收一个 ``Runtime``,无需重复传递大量参数。
Attributes:
client: QMT HTTP 客户端,用于查询账户、行情和提交委托。
global_cfg: 公共配置,包含 QMT、外部 API 和信号配置。
account_cfg: 当前主机的账户及交易策略配置。
state: 策略持仓状态的本地持久化存储。
orders: 当前活动委托和证券方向锁。
open_watch: 新开仓使用的价格反弹观察器。
add_watch: 亏损补仓使用的价格反弹观察器。
peak_grids: ``证券代码|仓位类型`` 到最高盈利网格的映射。
"""
# 外部服务与账户配置。
client: Client
global_cfg: GlobalConfig
account_cfg: AccountConfig
# 策略运行过程中共享的状态组件。
state: State
orders: OrderBook
open_watch: DipWatch
add_watch: DipWatch

View File

@@ -0,0 +1,146 @@
"""趋势策略持仓状态的内存管理与 JSON 持久化。"""
from __future__ import annotations
import json
from dataclasses import asdict, dataclass
from pathlib import Path
from threading import Lock
from typing import Iterable
from sdk import Position
# 委托状态:无操作、处理中、已完成。
STATUS_NONE = ""
STATUS_ING = "ING"
STATUS_OK = "OK"
@dataclass(slots=True)
class StateItem:
"""单只证券的底仓和补仓状态。"""
# 证券代码。
code: str
# 底仓订单、数量、成本和处理状态。
base_order_id: str = ""
base_qty: int = 0
base_cost: float = 0.0
base_status: str = STATUS_NONE
# 补仓订单、补仓次数、数量、成本和处理状态。
added_order_id: str = ""
added_num: int = 0
added_qty: int = 0
added_cost: float = 0.0
added_status: str = STATUS_NONE
class State:
"""线程安全的策略状态存储。
状态以内存字典提供快速访问,并通过临时文件替换的方式写入 JSON
防止程序在写入过程中退出而破坏原状态文件。
"""
def __init__(self, path: str | Path) -> None:
self.path = Path(path)
self.lock = Lock()
self.items = self._load()
@classmethod
def for_strategy(
cls,
data_dir: str | Path,
strategy: str,
account_id: str,
) -> "State":
"""根据数据目录、策略名称和账户生成独立状态文件。"""
state_path = Path(data_dir) / f"{strategy}_{account_id}_state.json"
return cls(state_path)
@property
def codes(self) -> list[str]:
"""返回当前已经接管的全部证券代码快照。"""
with self.lock:
return list(self.items)
def get(self, code: str) -> StateItem:
"""获取指定证券的状态;不存在时抛出 KeyError。"""
with self.lock:
return self.items[code]
def set(self, item: StateItem) -> None:
"""新增或覆盖一只证券的状态。"""
with self.lock:
self.items[item.code] = item
def delete(self, code: str) -> None:
"""删除证券状态;证券不存在时不报错。"""
with self.lock:
self.items.pop(code, None)
def sync_positions(self, positions: Iterable[Position]) -> None:
"""把尚未接管的真实持仓初始化为已完成底仓。
无证券代码、无持仓数量或成本无效的记录会被忽略。同步结束后
立即保存,确保首次接管的持仓在程序重启后仍可恢复。
"""
known_codes = set(self.codes)
for position in positions:
if (
not position.stock_code
or position.volume <= 0
or position.open_price <= 0
or position.stock_code in known_codes
):
continue
self.set(
StateItem(
code=position.stock_code,
base_qty=position.volume,
base_cost=position.open_price,
base_status=STATUS_OK,
)
)
known_codes.add(position.stock_code)
self.save()
def save(self) -> None:
"""将内存状态格式化写入 JSON并原子替换正式文件。"""
with self.lock:
self.path.parent.mkdir(parents=True, exist_ok=True)
temporary_path = self.path.with_suffix(self.path.suffix + ".tmp")
payload = {
code: asdict(item)
for code, item in self.items.items()
}
temporary_path.write_text(
json.dumps(payload, ensure_ascii=False, indent=2) + "\n",
encoding="utf-8",
)
temporary_path.replace(self.path)
def _load(self) -> dict[str, StateItem]:
"""读取已有状态文件;文件不存在时从空状态开始。"""
try:
raw = json.loads(self.path.read_text(encoding="utf-8"))
except FileNotFoundError:
return {}
except (OSError, json.JSONDecodeError) as exc:
raise ValueError(f"[状态] 读取或解析失败: {exc}") from exc
if not isinstance(raw, dict):
raise ValueError("[状态] 状态文件根节点必须是 JSON 对象")
try:
return {
code: StateItem(**item)
for code, item in raw.items()
}
except (TypeError, ValueError) as exc:
raise ValueError(f"[状态] 状态字段无效: {exc}") from exc

View File

@@ -0,0 +1,34 @@
from dataclasses import dataclass
from datetime import datetime, timedelta
from threading import Lock
import logging
@dataclass
class _Entry:
last_close: float
expires_at: datetime
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()
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 price < watch.last_close:
self.data[code] = _Entry(price, now + timedelta(seconds=self.expire_seconds)); return False
rebound = (price - watch.last_close) / watch.last_close * 100
if rebound <= 0 or rebound < self.rebound_threshold: return False
del self.data[code]
logging.info("[%s-触发] %s 反弹=%.2f%%", tag, code, rebound)
return True
def forget(self, code):
with self.lock: self.data.pop(code, None)