Restructure into modules/ with single-process CTP and config/ layout.

Move business code under modules/, env template to config/, PM2 single qihuo process, and _legacy shims for old imports.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
dekun
2026-07-01 14:42:16 +08:00
parent b354d6c701
commit e5a586f903
209 changed files with 21962 additions and 20963 deletions
+19
View File
@@ -0,0 +1,19 @@
# Copyright (c) 2025-2026 马建军. All rights reserved.
def register(deps) -> None:
from modules.trading.install import install_trading
install_trading(
deps.app,
login_required=deps.login_required,
require_nav=deps.require_nav,
get_db=deps.get_db,
get_setting=deps.get_setting,
set_setting=deps.set_setting,
fetch_price=deps.fetch_price,
send_wechat_msg=deps.send_wechat_msg,
)
__all__ = ["register"]
File diff suppressed because it is too large Load Diff
+284
View File
@@ -0,0 +1,284 @@
# Copyright (c) 2025-2026 马建军. All rights reserved.
# 专有软件 — 未经授权禁止复制、传播、转售。
# 严禁用于:带单/代客理财、向他人推荐期货品种或买卖建议、融资配资等业务。
# 详见 LICENSE.zh-CN.txt 与 docs/软件购买与使用协议.md
"""开仓委托:pending 状态跟踪、成交转正、超时撤单。"""
from __future__ import annotations
import logging
import time
from datetime import datetime
from typing import Any, Callable, Optional
from zoneinfo import ZoneInfo
from modules.market.market_sessions import is_trading_session
from modules.ctp.vnpy_bridge import ctp_cancel_order, ctp_list_active_orders, ctp_status
logger = logging.getLogger(__name__)
TZ = ZoneInfo("Asia/Shanghai")
DEFAULT_PENDING_ORDER_TIMEOUT_SEC = 300
# 报单刚提交后短暂等待 CTP 回报,避免误判为拒单
PENDING_ORDER_SETTLE_GRACE_SEC = 8
def pending_monitor_has_live_order(
mon: dict,
*,
active_orders: dict[str, dict],
active_order_list: list[dict],
match_fn: Callable[[str, str], bool] | None = None,
) -> bool:
"""本地 pending 是否仍对应 CTP 柜台上的有效开仓委托。"""
match = match_fn or _match_symbol
sym = mon.get("symbol") or ""
direction = mon.get("direction") or "long"
vt_oid = (mon.get("vt_order_id") or "").strip()
age = pending_age_sec(mon)
if vt_oid and _vt_order_in_active(vt_oid, active_orders):
return True
if _symbol_open_order_active(active_order_list, sym, direction, match):
return True
if not vt_oid and age < PENDING_ORDER_SETTLE_GRACE_SEC:
return True
if vt_oid and age < PENDING_ORDER_SETTLE_GRACE_SEC:
return True
return False
def parse_monitor_ts(raw: str) -> Optional[float]:
s = (raw or "").strip()
if not s:
return None
for fmt in ("%Y-%m-%d %H:%M:%S", "%Y-%m-%dT%H:%M:%S", "%Y-%m-%dT%H:%M"):
try:
return datetime.strptime(s[:19], fmt).replace(tzinfo=TZ).timestamp()
except ValueError:
continue
return None
def pending_age_sec(mon: dict) -> float:
ts = parse_monitor_ts(mon.get("open_time") or "") or parse_monitor_ts(
str(mon.get("created_at") or "")
)
if ts is None:
return 0.0
return max(0.0, time.time() - ts)
def pending_auto_cancel_remaining(
mon: dict,
*,
timeout_sec: int = DEFAULT_PENDING_ORDER_TIMEOUT_SEC,
) -> int:
limit = max(60, int(timeout_sec or DEFAULT_PENDING_ORDER_TIMEOUT_SEC))
return max(0, int(limit - pending_age_sec(mon)))
def _match_symbol(ctp_sym: str, ths: str) -> bool:
a = (ctp_sym or "").lower()
b = (ths or "").lower()
if a == b:
return True
if a and b and a.split(".")[0] == b.split(".")[0]:
return True
try:
from modules.ctp.ctp_symbol import ths_to_vnpy_symbol
vnpy_sym, _ = ths_to_vnpy_symbol(ths)
if a == vnpy_sym.lower():
return True
except Exception:
pass
return False
def _find_ctp_position(positions: list[dict], sym: str, direction: str) -> Optional[dict]:
direction = (direction or "long").strip().lower()
for p in positions or []:
if int(p.get("lots") or 0) <= 0:
continue
if (p.get("direction") or "long") != direction:
continue
if _match_symbol(p.get("symbol") or "", sym):
return p
return None
def _vt_order_in_active(vt_oid: str, active_orders: dict[str, dict]) -> bool:
oid = (vt_oid or "").strip()
if not oid:
return False
if oid in active_orders:
return True
tail = oid.rsplit("_", 1)[-1]
for key in active_orders:
if key == oid or key.endswith(tail) or oid.endswith(key):
return True
return False
def _symbol_open_order_active(
orders: list[dict],
sym: str,
direction: str,
match_fn: Callable[[str, str], bool],
) -> Optional[dict]:
direction = (direction or "long").strip().lower()
for o in orders or []:
offset_u = (o.get("offset") or "").upper()
if offset_u and "OPEN" not in offset_u:
continue
if (o.get("direction") or "long") != direction:
continue
if match_fn(o.get("symbol") or "", sym):
return o
return None
def reconcile_pending_orders(
conn,
mode: str,
*,
match_symbol_fn: Callable[[str, str], bool] | None = None,
sync_monitor_fn: Callable[..., None] | None = None,
capital: float = 0.0,
list_positions_fn: Callable[..., list] | None = None,
timeout_sec: int = DEFAULT_PENDING_ORDER_TIMEOUT_SEC,
) -> dict[str, int]:
"""同步 pending 委托:成交→active;超时/已撤→closed。"""
limit_sec = max(60, int(timeout_sec or DEFAULT_PENDING_ORDER_TIMEOUT_SEC))
stats = {"promoted": 0, "cancelled": 0, "closed": 0}
if not ctp_status(mode).get("connected"):
return stats
match = match_symbol_fn or _match_symbol
positions = (
list_positions_fn(mode, refresh_if_empty=True, refresh_margin=False)
if list_positions_fn
else []
)
try:
active_order_list = ctp_list_active_orders(mode)
active_orders = {}
for o in active_order_list:
for key in (o.get("order_id"), o.get("vt_order_id")):
if key:
active_orders[str(key)] = o
except Exception as exc:
logger.debug("list active orders: %s", exc)
active_order_list = []
active_orders = {}
rows = conn.execute(
"SELECT * FROM trade_order_monitors WHERE status='pending' ORDER BY id ASC"
).fetchall()
for r in rows:
mon = dict(r)
mid = int(mon["id"])
sym = mon.get("symbol") or ""
direction = mon.get("direction") or "long"
vt_oid = (mon.get("vt_order_id") or "").strip()
age = pending_age_sec(mon)
pos = _find_ctp_position(positions, sym, direction)
if pos:
conn.execute(
"UPDATE trade_order_monitors SET status='active' WHERE id=?",
(mid,),
)
if sync_monitor_fn:
sync_monitor_fn(
conn, mid, sym, direction, mode, ctp=pos, capital=capital,
)
stats["promoted"] += 1
continue
if vt_oid and _vt_order_in_active(vt_oid, active_orders):
if age >= limit_sec and is_trading_session():
if ctp_cancel_order(mode, vt_oid):
conn.execute(
"UPDATE trade_order_monitors SET status='closed' WHERE id=?",
(mid,),
)
stats["cancelled"] += 1
else:
logger.warning("pending auto-cancel failed monitor=%s order=%s", mid, vt_oid)
continue
live_open = _symbol_open_order_active(active_order_list, sym, direction, match)
if live_open:
if age >= limit_sec and is_trading_session():
cancel_oid = (
vt_oid
or live_open.get("vt_order_id")
or live_open.get("order_id")
or ""
)
if cancel_oid and ctp_cancel_order(mode, cancel_oid):
conn.execute(
"UPDATE trade_order_monitors SET status='closed' WHERE id=?",
(mid,),
)
stats["cancelled"] += 1
continue
# 有委托号但已不在 CTP 活跃列表且无持仓 → 拒单/已撤/终态
if vt_oid:
if age < PENDING_ORDER_SETTLE_GRACE_SEC:
continue
conn.execute(
"UPDATE trade_order_monitors SET status='closed' WHERE id=?",
(mid,),
)
stats["closed"] += 1
logger.info(
"pending monitor=%s order=%s closed (no longer active on CTP)",
mid, vt_oid,
)
continue
if age >= limit_sec:
if vt_oid and is_trading_session():
if ctp_cancel_order(mode, vt_oid):
conn.execute(
"UPDATE trade_order_monitors SET status='closed' WHERE id=?",
(mid,),
)
stats["cancelled"] += 1
else:
logger.info(
"pending monitor=%s order=%s kept (cancel not confirmed)",
mid, vt_oid,
)
elif not vt_oid:
conn.execute(
"UPDATE trade_order_monitors SET status='closed' WHERE id=?",
(mid,),
)
stats["closed"] += 1
if any(stats.values()):
conn.commit()
return stats
def cancel_pending_monitor(
conn,
mon: dict,
mode: str,
) -> tuple[bool, str]:
"""手动撤销 pending 开仓委托。"""
mid = int(mon.get("id") or 0)
vt_oid = (mon.get("vt_order_id") or "").strip()
if vt_oid and ctp_status(mode).get("connected"):
try:
ctp_cancel_order(mode, vt_oid)
except Exception as exc:
logger.warning("cancel pending order monitor=%s: %s", mid, exc)
conn.execute("UPDATE trade_order_monitors SET status='closed' WHERE id=?", (mid,))
conn.commit()
return True, "开仓委托已撤销"
+82
View File
@@ -0,0 +1,82 @@
# Copyright (c) 2025-2026 马建军. All rights reserved.
# 专有软件 — 未经授权禁止复制、传播、转售。
# 严禁用于:带单/代客理财、向他人推荐期货品种或买卖建议、融资配资等业务。
# 详见 LICENSE.zh-CN.txt 与 docs/软件购买与使用协议.md
"""开仓挂单超时:后台定期 reconcile,不依赖 SSE 完整刷新。"""
from __future__ import annotations
import logging
import threading
import time
from typing import Callable, Optional
from modules.ctp.vnpy_bridge import ctp_status
logger = logging.getLogger(__name__)
CHECK_INTERVAL_SEC = 10
IDLE_INTERVAL_SEC = 45
DISCONNECTED_SLEEP_SEC = 30
STARTUP_DELAY_SEC = 15
def start_pending_order_worker(
*,
db_path: str,
get_mode_fn: Callable[[], str],
init_tables_fn: Callable | None = None,
get_capital_fn: Callable | None = None,
reconcile_fn: Callable[..., dict],
on_changed_fn: Callable[[], None] | None = None,
interval: int = CHECK_INTERVAL_SEC,
idle_interval: int = IDLE_INTERVAL_SEC,
) -> None:
"""后台线程:存在 pending 开仓监控时定期同步成交/超时撤单。"""
from modules.core.db_conn import connect_db
def _loop() -> None:
time.sleep(STARTUP_DELAY_SEC)
while True:
sleep_sec = max(5, idle_interval)
try:
mode = get_mode_fn()
if not ctp_status(mode).get("connected"):
time.sleep(DISCONNECTED_SLEEP_SEC)
continue
conn = connect_db(db_path)
try:
if init_tables_fn:
init_tables_fn(conn)
pending_n = conn.execute(
"SELECT COUNT(*) AS n FROM trade_order_monitors WHERE status='pending'"
).fetchone()["n"]
if pending_n <= 0:
time.sleep(sleep_sec)
continue
sleep_sec = max(1, interval)
capital = 0.0
if get_capital_fn:
try:
capital = float(get_capital_fn(conn) or 0)
except Exception:
capital = 0.0
stats = reconcile_fn(conn, mode, capital=capital) or {}
if any(int(stats.get(k) or 0) for k in ("promoted", "cancelled", "closed")):
logger.info(
"pending worker reconcile: promoted=%s cancelled=%s closed=%s",
stats.get("promoted", 0),
stats.get("cancelled", 0),
stats.get("closed", 0),
)
if on_changed_fn:
on_changed_fn()
finally:
conn.close()
except Exception as exc:
logger.warning("pending order worker: %s", exc)
time.sleep(sleep_sec)
threading.Thread(target=_loop, daemon=True, name="pending-order-worker").start()
+270
View File
@@ -0,0 +1,270 @@
# Copyright (c) 2025-2026 马建军. All rights reserved.
# 专有软件 — 未经授权禁止复制、传播、转售。
# 严禁用于:带单/代客理财、向他人推荐期货品种或买卖建议、融资配资等业务。
# 详见 LICENSE.zh-CN.txt 与 docs/软件购买与使用协议.md
"""期货计仓:固定手数 / 固定金额。"""
from __future__ import annotations
import math
from typing import Optional
from modules.core.contract_specs import get_contract_spec, margin_one_lot
MODE_FIXED = "fixed"
MODE_AMOUNT = "amount"
MODE_RISK = "amount" # 兼容旧配置「以损定仓」
DEFAULT_MAX_ORDER_LOTS = 50
def normalize_sizing_mode(raw: str) -> str:
m = (raw or MODE_FIXED).strip().lower()
if m == "risk":
m = MODE_AMOUNT
return m if m in (MODE_FIXED, MODE_AMOUNT) else MODE_FIXED
def price_precision_from_tick(tick_size: float) -> int:
if tick_size <= 0:
return 0
s = f"{tick_size:.10f}".rstrip("0").rstrip(".")
if "." not in s:
return 0
return len(s.split(".")[1])
def _per_lot_risk(entry: float, stop_loss: float, direction: str, ths_code: str) -> tuple[float, Optional[str]]:
spec = get_contract_spec(ths_code)
mult = spec["mult"]
d = (direction or "long").strip().lower()
if d == "short":
per_lot = (stop_loss - entry) * mult
else:
per_lot = (entry - stop_loss) * mult
if per_lot <= 0:
return 0.0, "止损方向与入场价不匹配"
return per_lot, None
def calc_lots_by_amount(
entry: float,
stop_loss: float,
direction: str,
amount: float,
ths_code: str,
*,
capital: float = 0.0,
max_lots: Optional[int] = None,
max_margin_pct: float = 30.0,
trading_mode: str | None = None,
) -> tuple[Optional[int], Optional[str], dict]:
"""固定金额:先按止损距离算手数,再按保证金上限收紧。返回 (手数, 错误, 详情)。"""
info: dict = {
"lots_by_risk": 0,
"lots_by_margin": None,
"capped_by": None,
}
try:
entry_f = float(entry)
sl_f = float(stop_loss)
budget = float(amount)
cap = float(capital or 0)
except (TypeError, ValueError):
return None, "参数格式错误", info
if entry_f <= 0 or budget <= 0:
return None, "入场价或固定金额无效", info
per_lot_risk, err = _per_lot_risk(entry_f, sl_f, direction, ths_code)
if err:
return None, err, info
lots = int(math.floor(budget / per_lot_risk))
info["lots_by_risk"] = lots
if lots < 1:
return None, f"按固定金额 {budget:.0f} 元,当前止损距离下不足 1 手", info
if cap > 0:
margin_per_lot, _src, _spec = margin_one_lot(
ths_code, entry_f, direction=direction, trading_mode=trading_mode,
)
if margin_per_lot <= 0:
spec = get_contract_spec(ths_code)
margin_per_lot = entry_f * spec["mult"] * spec["margin_rate"]
margin_cap = max(1.0, min(100.0, float(max_margin_pct or 30.0)))
max_by_margin = (
int(math.floor(cap * margin_cap / 100.0 / margin_per_lot))
if margin_per_lot > 0 else lots
)
info["lots_by_margin"] = max_by_margin
info["margin_per_lot"] = round(margin_per_lot, 2)
info["max_margin_pct"] = margin_cap
if max_by_margin < 1:
return None, f"按保证金上限 {margin_cap:g}%,当前不足 1 手", info
if max_by_margin < lots:
info["capped_by"] = "margin"
lots = min(lots, max_by_margin)
cap_lots = max_lots if max_lots is not None else DEFAULT_MAX_ORDER_LOTS
if lots > cap_lots:
lots = cap_lots
info["capped_by"] = info.get("capped_by") or "max_lots"
info["lots"] = lots
return lots, None, info
def calc_lots_by_risk(
entry: float,
stop_loss: float,
direction: str,
capital: float,
risk_percent: float,
ths_code: str,
*,
max_lots: Optional[int] = None,
max_margin_pct: float = 30.0,
trading_mode: str | None = None,
) -> tuple[Optional[int], Optional[str]]:
"""策略等场景:按权益百分比风险预算换算手数。"""
try:
cap = float(capital)
rp = float(risk_percent)
except (TypeError, ValueError):
return None, "参数格式错误"
if cap <= 0 or rp <= 0:
return None, "资金或风险比例无效"
budget = cap * rp / 100.0
lots, err, info = calc_lots_by_amount(
entry, stop_loss, direction, budget, ths_code,
capital=cap, max_lots=max_lots, max_margin_pct=max_margin_pct,
trading_mode=trading_mode,
)
return lots, err
def calc_order_tick_metrics(
ths_code: str,
lots: float,
price: Optional[float] = None,
*,
direction: str = "long",
trading_mode: str | None = None,
) -> dict:
"""下单区展示:最小变动价位、每跳盈亏、保证金等。"""
spec = get_contract_spec(ths_code)
mult = int(spec["mult"])
tick = float(spec.get("tick_size") or 1.0)
margin_rate = float(spec["margin_rate"])
lots_i = max(1, int(lots or 1))
tick_value_per_lot = round(tick * mult, 4)
tick_value_total = round(tick_value_per_lot * lots_i, 2)
prec = price_precision_from_tick(tick)
mark = float(price) if price else 0.0
margin_per_lot = None
margin_source = "estimate"
if mark > 0:
margin_per_lot, margin_source, spec_used = margin_one_lot(
ths_code, mark, direction=direction, trading_mode=trading_mode,
)
if spec_used.get("mult"):
mult = int(spec_used["mult"])
if spec_used.get("tick_size"):
tick = float(spec_used["tick_size"])
tick_value_per_lot = round(tick * mult, 4)
tick_value_total = round(tick_value_per_lot * lots_i, 2)
prec = price_precision_from_tick(tick)
if margin_per_lot <= 0:
margin_per_lot = round(mark * mult * margin_rate, 2)
margin_source = "estimate"
margin_total = round(margin_per_lot * lots_i, 2) if margin_per_lot else None
return {
"mult": mult,
"tick_size": tick,
"price_precision": prec,
"tick_value_per_lot": tick_value_per_lot,
"tick_value_total": tick_value_total,
"lots": lots_i,
"margin_per_lot": margin_per_lot,
"margin_total": margin_total,
"margin_rate": margin_rate,
"margin_source": margin_source,
}
def calc_margin_usage_pct(
positions: list[dict],
capital: float,
*,
extra_symbol: str = "",
extra_lots: int = 0,
extra_price: float = 0,
extra_direction: str = "long",
trading_mode: str | None = None,
) -> float:
"""当前持仓 + 拟开仓占权益的保证金比例(%)。"""
cap = float(capital or 0)
if cap <= 0:
return 999.0
total = 0.0
for p in positions:
lots = int(p.get("lots") or 0)
if lots <= 0:
continue
ctp_margin = float(p.get("margin") or 0)
if ctp_margin > 0:
total += ctp_margin
continue
sym = (p.get("symbol") or p.get("symbol_code") or "").strip()
entry = float(p.get("avg_price") or p.get("entry_price") or 0)
direction = (p.get("direction") or "long").strip().lower()
if entry <= 0 or not sym:
continue
per_lot, _, _ = margin_one_lot(
sym, entry, direction=direction, trading_mode=trading_mode,
)
if per_lot <= 0:
spec = get_contract_spec(sym)
per_lot = entry * spec["mult"] * spec["margin_rate"]
total += per_lot * lots
if extra_symbol and extra_lots > 0 and extra_price > 0:
per_lot, _, _ = margin_one_lot(
extra_symbol, extra_price, direction=extra_direction, trading_mode=trading_mode,
)
if per_lot <= 0:
spec = get_contract_spec(extra_symbol)
per_lot = extra_price * spec["mult"] * spec["margin_rate"]
total += per_lot * extra_lots
return round(total / cap * 100.0, 2)
def cap_lots_for_margin_budget(
positions: list[dict],
capital: float,
symbol: str,
direction: str,
price: float,
desired_lots: int,
max_margin_pct: float,
trading_mode: str | None = None,
) -> tuple[int, float]:
"""在保证金上限内,返回可加仓手数及占用比例。"""
desired = max(0, int(desired_lots or 0))
if desired <= 0:
return 0, calc_margin_usage_pct(positions, capital, trading_mode=trading_mode)
for lots in range(desired, 0, -1):
usage = calc_margin_usage_pct(
positions,
capital,
extra_symbol=symbol,
extra_lots=lots,
extra_price=price,
extra_direction=direction,
trading_mode=trading_mode,
)
if usage <= max_margin_pct:
return lots, usage
return 0, calc_margin_usage_pct(
positions,
capital,
extra_symbol=symbol,
extra_lots=desired,
extra_price=price,
extra_direction=direction,
trading_mode=trading_mode,
)
+113
View File
@@ -0,0 +1,113 @@
# Copyright (c) 2025-2026 马建军. All rights reserved.
# 专有软件 — 未经授权禁止复制、传播、转售。
# 严禁用于:带单/代客理财、向他人推荐期货品种或买卖建议、融资配资等业务。
# 详见 LICENSE.zh-CN.txt 与 docs/软件购买与使用协议.md
"""持仓监控:后台拉取 CTP 并 SSE 推送给前端(避免每次刷新阻塞读柜台)。"""
from __future__ import annotations
import logging
import queue
import threading
import time
from typing import Callable, Optional
from modules.market.kline_stream import sse_format
from modules.market.market_sessions import is_trading_session
logger = logging.getLogger(__name__)
PUSH_INTERVAL_SEC = 1
IDLE_INTERVAL_SEC = 5
class PositionStreamHub:
def __init__(self) -> None:
self._lock = threading.Lock()
self._subs: list[queue.Queue] = []
self._snapshot: Optional[dict] = None
self._snapshot_ts: float = 0.0
def subscribe(self) -> queue.Queue:
q: queue.Queue = queue.Queue(maxsize=16)
with self._lock:
self._subs.append(q)
return q
def unsubscribe(self, q: queue.Queue) -> None:
with self._lock:
try:
self._subs.remove(q)
except ValueError:
pass
def get_snapshot(self) -> Optional[dict]:
with self._lock:
return dict(self._snapshot) if self._snapshot else None
def set_snapshot(self, data: dict) -> None:
with self._lock:
self._snapshot = dict(data)
self._snapshot_ts = time.time()
def _fanout(self, event: str, data: dict) -> None:
msg = {"event": event, "data": data}
with self._lock:
subs = list(self._subs)
for q in subs:
try:
q.put_nowait(msg)
except queue.Full:
try:
q.get_nowait()
except queue.Empty:
pass
try:
q.put_nowait(msg)
except queue.Full:
pass
def broadcast(self, event: str, data: dict) -> None:
self.set_snapshot(data)
self._fanout(event, data)
def push_event(self, event: str, data: dict) -> None:
"""SSE 推送,不覆盖 positions 全量快照。"""
self._fanout(event, data)
position_hub = PositionStreamHub()
def start_position_worker(
*,
refresh_fn: Callable[[], dict],
interval: int = PUSH_INTERVAL_SEC,
idle_interval: int = IDLE_INTERVAL_SEC,
) -> None:
"""后台定时刷新持仓快照并 SSE 广播。"""
def _loop() -> None:
while True:
sleep_sec = idle_interval
try:
payload = refresh_fn()
if payload:
position_hub.broadcast("positions", payload)
ctp_st = (payload or {}).get("ctp_status") or {}
connected = bool(ctp_st.get("connected"))
in_session = bool((payload or {}).get("trading_session"))
rows = (payload or {}).get("rows") or []
has_sl_tp = any(
r.get("stop_loss") is not None or r.get("take_profit") is not None
for r in rows
)
if connected and in_session:
sleep_sec = max(1, interval)
elif connected:
sleep_sec = max(2, min(idle_interval, 3))
except Exception as exc:
logger.warning("position worker failed: %s", exc)
time.sleep(sleep_sec)
threading.Thread(target=_loop, daemon=True, name="position-stream").start()
+335
View File
@@ -0,0 +1,335 @@
# Copyright (c) 2025-2026 马建军. All rights reserved.
# 专有软件 — 未经授权禁止复制、传播、转售。
# 严禁用于:带单/代客理财、向他人推荐期货品种或买卖建议、融资配资等业务。
# 详见 LICENSE.zh-CN.txt 与 docs/软件购买与使用协议.md
"""按账户资金筛选可开仓品种(保证金与仓位纪律)。"""
from __future__ import annotations
import logging
import math
from concurrent.futures import ThreadPoolExecutor
from typing import Callable, Optional
from modules.core.contract_specs import get_contract_spec, margin_one_lot
from modules.fees.fee_specs import calc_fee_breakdown
from modules.trading.recommend_trend import analyze_product_daily, sort_recommend_by_trend
from modules.core.symbols import PRODUCTS, product_category, product_has_night_session
logger = logging.getLogger(__name__)
# 权益不超过该值时,仅允许下列品种(可开仓列表、品种下拉、开仓报单)
SMALL_ACCOUNT_CAPITAL_MAX = 200_000.0
# 未连接 CTP 时,可开仓品种表按该权益估算最大手数(与参考资金设置无关)
DISCONNECTED_RECOMMEND_CAPITAL = 100_000.0
SMALL_ACCOUNT_PRODUCT_THS = frozenset({"c", "m", "MA", "rb"})
SMALL_ACCOUNT_SCOPE_LABEL = "玉米、豆粕、甲醇、螺纹钢"
SMALL_ACCOUNT_RECOMMENDED_OPEN_MARGIN_PCT = 30.0
SMALL_ACCOUNT_RECOMMENDED_ROLL_MARGIN_PCT = 40.0
def small_account_margin_recommendations() -> dict:
"""20 万以下账户建议的保证金比例(供系统设置参考)。"""
wan = int(SMALL_ACCOUNT_CAPITAL_MAX // 10_000)
return {
"open_margin_pct": SMALL_ACCOUNT_RECOMMENDED_OPEN_MARGIN_PCT,
"roll_margin_pct": SMALL_ACCOUNT_RECOMMENDED_ROLL_MARGIN_PCT,
"label": (
f"权益 {wan} 万以下建议:开仓保证金上限 "
f"{int(SMALL_ACCOUNT_RECOMMENDED_OPEN_MARGIN_PCT)}%"
f"滚仓总保证金不超过 {int(SMALL_ACCOUNT_RECOMMENDED_ROLL_MARGIN_PCT)}%"
),
}
def small_account_scope_hint(*, ctp_connected: bool = True) -> str:
wan = int(SMALL_ACCOUNT_CAPITAL_MAX // 10_000)
if not ctp_connected:
rec_wan = int(DISCONNECTED_RECOMMEND_CAPITAL // 10_000)
return (
f"未连接 CTP,按 {rec_wan} 万权益估算最大手数,"
f"仅显示并可交易 {SMALL_ACCOUNT_SCOPE_LABEL}"
)
return f"权益 {wan} 万以下仅显示并可交易:{SMALL_ACCOUNT_SCOPE_LABEL}"
def small_account_scope_status_label() -> str:
wan = int(SMALL_ACCOUNT_CAPITAL_MAX // 10_000)
return f"权益{wan}万以下限{SMALL_ACCOUNT_SCOPE_LABEL}"
def should_apply_small_account_scope(
capital: float,
*,
ctp_connected: bool,
) -> bool:
"""SimNow/实盘一致:未连接 CTP 时默认按 20 万以下四品种范围。"""
if not ctp_connected:
return True
return is_small_account(capital)
def filter_rows_for_account_scope(
rows: list[dict],
capital: float,
*,
ctp_connected: bool,
) -> list[dict]:
if not should_apply_small_account_scope(capital, ctp_connected=ctp_connected):
return rows
return [r for r in rows if product_in_small_account_whitelist(r.get("ths") or "")]
def normalize_product_ths(ths: str) -> str:
import re
s = (ths or "").strip()
m = re.match(r"^([A-Za-z]+)", s)
return m.group(1) if m else s
def is_small_account(capital: float) -> bool:
cap = float(capital or 0)
return 0 < cap <= SMALL_ACCOUNT_CAPITAL_MAX
def product_in_small_account_whitelist(ths_or_product) -> bool:
if isinstance(ths_or_product, dict):
key = (ths_or_product.get("ths") or "").strip()
else:
key = normalize_product_ths(str(ths_or_product or ""))
if not key:
return False
root = normalize_product_ths(key)
if root in SMALL_ACCOUNT_PRODUCT_THS:
return True
upper = root.upper()
return upper in {x.upper() for x in SMALL_ACCOUNT_PRODUCT_THS}
def assert_product_allowed_for_capital(
ths: str,
capital: float,
*,
ctp_connected: bool = True,
) -> Optional[str]:
"""小账户品种白名单校验;通过返回 None。"""
if not should_apply_small_account_scope(capital, ctp_connected=ctp_connected):
return None
if product_in_small_account_whitelist(ths):
return None
wan = int(SMALL_ACCOUNT_CAPITAL_MAX // 10_000)
if not ctp_connected:
return f"未连接 CTP,仅可交易:{SMALL_ACCOUNT_SCOPE_LABEL}"
return f"权益 {wan} 万以下仅可交易:{SMALL_ACCOUNT_SCOPE_LABEL}"
def filter_products_for_capital(
products: list[dict],
capital: float,
*,
ctp_connected: bool = True,
) -> list[dict]:
if not should_apply_small_account_scope(capital, ctp_connected=ctp_connected):
return list(products)
return [p for p in products if product_in_small_account_whitelist(p)]
def _attach_turnover(row: dict) -> None:
"""成交额 = 昨日成交量(手) × 昨收 × 合约乘数。"""
try:
vol = float(row.get("volume") or 0)
price = float(row.get("prev_close") or row.get("price") or 0)
mult = float(row.get("mult") or 0)
except (TypeError, ValueError):
return
if vol > 0 and price > 0 and mult > 0:
row["turnover"] = round(vol * price * mult, 2)
def _letters_from_ths(ths_code: str) -> str:
import re
m = re.match(r"^([A-Za-z]+)", (ths_code or "").strip())
return m.group(1) if m else ""
def assess_product_for_capital(
product: dict,
capital: float,
price: Optional[float],
*,
max_margin_pct: float = 30.0,
default_stop_ticks: int = 20,
reward_risk_ratio: float = 2.0,
trading_mode: str = "simulation",
ctp_connected: bool = True,
main_code: str = "",
margin_used: float = 0.0,
) -> dict:
"""评估单品种在当前资金下是否可交易。"""
ths = product.get("ths") or ""
name = product.get("name") or ths
exchange = product.get("exchange") or ""
category = product.get("category") or product_category(ths)
spec = get_contract_spec(ths + "8888")
mult = spec["mult"]
margin_rate = spec["margin_rate"]
tick = float(spec.get("tick_size") or 1.0)
p = float(price) if price and price > 0 else 0.0
cap = float(capital or 0)
margin_pct = max(1.0, min(100.0, float(max_margin_pct or 30.0)))
if should_apply_small_account_scope(cap, ctp_connected=ctp_connected) and not product_in_small_account_whitelist(product):
return {
"ths": ths,
"name": name,
"exchange": exchange,
"category": category,
"mult": spec["mult"],
"tick_size": tick,
"status": "blocked",
"status_label": small_account_scope_status_label(),
"min_capital_one_lot": None,
"margin_one_lot": None,
"max_lots": 0,
"risk_one_lot_1pct": None,
"has_night_session": product_has_night_session(product),
}
if p <= 0:
return {
"ths": ths,
"name": name,
"exchange": exchange,
"category": category,
"mult": mult,
"tick_size": tick,
"status": "no_price",
"status_label": "暂无行情",
"min_capital_one_lot": None,
"margin_one_lot": None,
"max_lots": 0,
"risk_one_lot_1pct": None,
"has_night_session": product_has_night_session(product),
}
margin_source = None
code_for_margin = (main_code or "").strip() or (ths + "8888")
if p > 0 and ctp_connected:
margin_one, margin_source, spec_used = margin_one_lot(
code_for_margin, p, direction="max", trading_mode=trading_mode,
)
if spec_used.get("mult"):
mult = spec_used["mult"]
if spec_used.get("tick_size"):
tick = float(spec_used["tick_size"])
else:
margin_one = p * mult * margin_rate
min_capital = margin_one / (margin_pct / 100.0) if margin_pct > 0 else margin_one
margin_budget = cap * margin_pct / 100.0 if cap > 0 else 0.0
margin_budget = max(0.0, margin_budget - max(0.0, float(margin_used or 0)))
max_lots = int(math.floor(margin_budget / margin_one)) if margin_one > 0 and margin_budget > 0 else 0
stop_dist = tick * default_stop_ticks
risk_one_lot = stop_dist * mult
risk_pct_1lot = (risk_one_lot / cap * 100) if cap > 0 else 999.0
ref_sl = round(p - stop_dist, 4)
ref_tp = round(p + stop_dist * reward_risk_ratio, 4)
fee_ths = ths + "8888"
try:
fee_info = calc_fee_breakdown(
fee_ths, p, p, 1.0, open_time="", close_time="", trading_mode=trading_mode,
)
except Exception as exc:
logger.debug("recommend fee calc failed %s: %s", ths, exc)
fee_info = {"open_fee": 0.0, "total_fee": 0.0}
can_margin = max_lots >= 1
can_risk = cap > 0 and risk_one_lot <= cap * 0.01
if can_margin and can_risk:
status, label = "ok", f"最大 {max_lots}"
elif can_margin:
status, label = "margin_ok", f"最大 {max_lots} 手·止损偏宽"
else:
status, label = "blocked", "资金不足"
if margin_source == "ctp" and can_margin:
label += "(柜台保证金)"
row_out = {
"ths": ths,
"name": name,
"exchange": exchange,
"category": category,
"price": round(p, 4),
"mult": mult,
"tick_size": tick,
"margin_one_lot": round(margin_one, 2),
"min_capital_one_lot": round(min_capital, 2),
"max_lots": max_lots,
"margin_budget": round(margin_budget, 2),
"max_margin_pct": margin_pct,
"risk_one_lot_1pct": round(risk_one_lot, 2),
"risk_pct_1lot_at_1pct_rule": round(risk_pct_1lot, 2),
"ref_stop_loss": ref_sl,
"ref_take_profit": ref_tp,
"open_fee_one_lot": fee_info["open_fee"],
"roundtrip_fee_one_lot": fee_info["total_fee"],
"status": status,
"status_label": label,
"has_night_session": product_has_night_session(product),
}
if margin_source:
row_out["margin_source"] = margin_source
return row_out
def list_product_recommendations(
capital: float,
quote_fn: Callable[[str], Optional[dict]],
*,
max_margin_pct: float = 30.0,
trading_mode: str = "simulation",
ctp_connected: bool = True,
margin_used: float = 0.0,
) -> list[dict]:
"""扫描全部品种并排序:可开且纪律友好 > 可开 > 不足。quote_fn(品种代码) -> {price, ths_code, ...}"""
def _one(product: dict) -> dict:
ths = product["ths"]
try:
quote = quote_fn(ths) or {}
price = quote.get("price")
main_code = (quote.get("ths_code") or "").strip()
row = assess_product_for_capital(
product, capital, price,
max_margin_pct=max_margin_pct,
trading_mode=trading_mode,
ctp_connected=ctp_connected,
main_code=main_code,
margin_used=margin_used,
)
row["main_code"] = main_code
if main_code:
row.update(analyze_product_daily(main_code))
_attach_turnover(row)
return row
except Exception as exc:
logger.warning("recommend product failed %s: %s", ths, exc)
spec = get_contract_spec(ths + "8888")
return {
"ths": ths,
"name": product.get("name") or ths,
"exchange": product.get("exchange") or "",
"category": product.get("category") or product_category(ths),
"mult": spec["mult"],
"tick_size": float(spec.get("tick_size") or 1.0),
"status": "no_price",
"status_label": "计算失败",
"main_code": "",
"max_lots": 0,
"has_night_session": product_has_night_session(product),
}
with ThreadPoolExecutor(max_workers=10) as pool:
products = filter_products_for_capital(PRODUCTS, capital)
rows = list(pool.map(_one, products))
return sort_recommend_by_trend(rows)
+399
View File
@@ -0,0 +1,399 @@
# Copyright (c) 2025-2026 马建军. All rights reserved.
# 专有软件 — 未经授权禁止复制、传播、转售。
# 严禁用于:带单/代客理财、向他人推荐期货品种或买卖建议、融资配资等业务。
# 详见 LICENSE.zh-CN.txt 与 docs/软件购买与使用协议.md
"""可开仓品种:计算、按资金过滤、SQLite 缓存。"""
from __future__ import annotations
import json
import logging
import math
from datetime import datetime
from typing import Callable, Optional
from modules.core.contract_specs import get_contract_spec, margin_one_lot
from modules.fees.fee_specs import ensure_fee_rates_schema
from modules.trading.product_recommend import (
_attach_turnover,
filter_rows_for_account_scope,
list_product_recommendations,
)
from modules.trading.recommend_trend import sort_recommend_by_trend
from modules.core.symbols import product_category
logger = logging.getLogger(__name__)
RECOMMEND_CACHE_SQL = """
CREATE TABLE IF NOT EXISTS product_recommend_cache (
id INTEGER PRIMARY KEY CHECK (id = 1),
capital REAL NOT NULL DEFAULT 0,
rows_json TEXT NOT NULL DEFAULT '[]',
updated_at TEXT
)
"""
def ensure_recommend_tables(conn) -> None:
conn.execute(RECOMMEND_CACHE_SQL)
def filter_affordable_recommendations(rows: list[dict]) -> list[dict]:
"""仅保留当前资金可开 1 手的品种(不含资金不足、无行情)。"""
return [r for r in rows if r.get("status") in ("ok", "margin_ok")]
def rows_missing_max_lots(rows: list[dict]) -> bool:
"""缓存是否为旧版(缺少最大手数字段)。"""
if not rows:
return False
return any("max_lots" not in r for r in rows)
def rows_missing_trend(rows: list[dict]) -> bool:
"""缓存是否为旧版(缺少走势字段)。"""
if not rows:
return False
return any("trend" not in r for r in rows)
def rows_missing_daily_stats(rows: list[dict]) -> bool:
"""缓存是否为旧版(缺少跳空/量价字段)。"""
if not rows:
return False
return any("gap" not in r for r in rows)
def rows_missing_category(rows: list[dict]) -> bool:
if not rows:
return False
return any("category" not in r for r in rows)
def rows_missing_turnover(rows: list[dict]) -> bool:
if not rows:
return False
return any("turnover" not in r for r in rows)
def rows_missing_contract_spec(rows: list[dict]) -> bool:
if not rows:
return False
return any("mult" not in r or "tick_size" not in r for r in rows)
def recommend_cache_needs_refresh(
cached: dict,
*,
capital: float = 0.0,
) -> bool:
"""是否需要重新拉行情计算可开仓列表。"""
if recommend_cache_stale(cached.get("updated_at")):
return True
rows = cached.get("rows") or []
if rows_missing_max_lots(rows):
return True
if rows_missing_trend(rows):
return True
if rows_missing_daily_stats(rows):
return True
if rows_missing_category(rows):
return True
if rows_missing_turnover(rows):
return True
if rows_missing_contract_spec(rows):
return True
if float(capital or 0) > 0 and not rows:
return True
return False
def _ctp_connected_for_mode(trading_mode: str) -> bool:
try:
from modules.trading.position_stream import position_hub
snap = position_hub.get_snapshot() or {}
st = snap.get("ctp_status")
if isinstance(st, dict) and st:
return bool(st.get("connected"))
except Exception:
pass
del trading_mode
return False
def recommend_margin_used(trading_mode: str) -> float:
"""当前持仓已占用保证金(各持仓 CTP 回报之和,与柜台持仓保证金一致)。"""
try:
from modules.trading.position_stream import position_hub
snap = position_hub.get_snapshot() or {}
raw = snap.get("margin_used")
if raw is not None:
return max(0.0, float(raw or 0))
except Exception:
pass
if not _ctp_connected_for_mode(trading_mode):
return 0.0
try:
from modules.ctp.vnpy_bridge import ctp_account_margin_used, ctp_sum_position_margins
total = ctp_sum_position_margins(
trading_mode, refresh_if_empty=False, refresh_margin=True,
)
if total > 0:
return total
used = ctp_account_margin_used(trading_mode)
return float(used) if used and used > 0 else 0.0
except Exception as exc:
logger.debug("recommend_margin_used: %s", exc)
return 0.0
def margin_budget_info(
capital: float,
max_margin_pct: float,
margin_used: float = 0.0,
) -> dict[str, float]:
"""保证金上限总额、已占用、剩余可开额度。"""
cap = float(capital or 0)
pct = max(1.0, min(100.0, float(max_margin_pct or 30.0)))
total = cap * pct / 100.0 if cap > 0 else 0.0
used = max(0.0, float(margin_used or 0))
remaining = max(0.0, total - used)
return {
"margin_budget_total": round(total, 2),
"margin_used": round(used, 2),
"margin_budget_remaining": round(remaining, 2),
"max_margin_pct": pct,
}
def enrich_recommend_rows(
rows: list[dict],
capital: float,
*,
max_margin_pct: float = 30.0,
trading_mode: str = "simulation",
margin_used: float = 0.0,
use_ctp_margin: bool = True,
) -> list[dict]:
"""用当前权益与保证金比例补算最大可开手数(兼容旧缓存)。"""
cap = float(capital or 0)
budget_info = margin_budget_info(cap, max_margin_pct, margin_used)
pct = budget_info["max_margin_pct"]
budget = budget_info["margin_budget_remaining"]
ctp_connected = _ctp_connected_for_mode(trading_mode)
enriched: list[dict] = []
for raw in rows:
row = dict(raw)
ths = (row.get("ths") or "").strip()
main_code = (row.get("main_code") or "").strip()
spec_code = main_code or (ths + "8888" if ths else "")
if spec_code:
spec = get_contract_spec(spec_code)
if row.get("mult") in (None, ""):
row["mult"] = spec["mult"]
if row.get("tick_size") in (None, ""):
row["tick_size"] = float(spec.get("tick_size") or 1.0)
margin_one = 0.0
try:
margin_one = float(row.get("margin_one_lot") or 0)
except (TypeError, ValueError):
margin_one = 0.0
price = float(row.get("price") or 0)
code_for_margin = main_code or spec_code
if price > 0 and code_for_margin:
margin_one, margin_source, spec_used = margin_one_lot(
code_for_margin,
price,
direction="max",
trading_mode=trading_mode if (ctp_connected and use_ctp_margin) else None,
)
if spec_used.get("mult"):
row["mult"] = spec_used["mult"]
if spec_used.get("tick_size"):
row["tick_size"] = spec_used["tick_size"]
row["margin_one_lot"] = margin_one
if margin_source == "ctp":
row["margin_source"] = "ctp"
row["spec_source"] = "ctp"
if margin_one > 0 and budget > 0:
lots = int(math.floor(budget / margin_one))
else:
try:
lots = int(row.get("max_lots") or row.get("recommended_lots") or 0)
except (TypeError, ValueError):
lots = 0
row["max_lots"] = lots
row.pop("recommended_lots", None)
row["margin_budget"] = round(budget, 2)
row["margin_budget_total"] = budget_info["margin_budget_total"]
row["margin_used"] = budget_info["margin_used"]
row["max_margin_pct"] = pct
status = row.get("status") or ""
if lots >= 1 and status in ("ok", "margin_ok"):
src = "柜台" if row.get("margin_source") == "ctp" else "估算"
row["status_label"] = (
f"最大 {lots}" if status == "ok" else f"最大 {lots} 手·止损偏宽"
)
if row.get("margin_source") == "ctp":
row["status_label"] += f"{src}保证金)"
if budget_info["margin_used"] > 0:
row["status_label"] += "·扣持仓"
elif lots < 1 and status in ("ok", "margin_ok"):
row["status"] = "blocked"
row["status_label"] = "资金不足"
if not row.get("category"):
row["category"] = product_category(row.get("ths") or "")
from modules.core.symbols import enrich_recommend_row
row = enrich_recommend_row(row)
_attach_turnover(row)
enriched.append(row)
from modules.core.symbols import filter_for_trading_session
return filter_for_trading_session(enriched)
def filter_recommend_by_sizing(
rows: list[dict],
*,
sizing_mode: str,
fixed_lots: int = 1,
) -> list[dict]:
"""固定手数模式下:最大手数低于设定值的品种不展示。"""
if (sizing_mode or "").strip().lower() != "fixed":
return rows
fl = max(1, int(fixed_lots or 1))
return [r for r in rows if int(r.get("max_lots") or 0) >= fl]
def refresh_recommend_cache(
conn,
capital: float,
quote_fn: Callable[[str], Optional[dict]],
*,
trading_mode: str = "simulation",
max_margin_pct: float = 30.0,
margin_used: float | None = None,
) -> list[dict]:
"""后台拉行情、筛选并写入数据库。"""
ensure_recommend_tables(conn)
ensure_fee_rates_schema(conn)
ctp_connected = _ctp_connected_for_mode(trading_mode)
used = (
float(margin_used)
if margin_used is not None
else recommend_margin_used(trading_mode)
)
all_rows = list_product_recommendations(
capital,
quote_fn,
max_margin_pct=max_margin_pct,
trading_mode=trading_mode,
ctp_connected=ctp_connected,
margin_used=used,
)
rows = filter_affordable_recommendations(all_rows)
if not rows and float(capital or 0) > 0:
logger.warning(
"recommend refresh: 0 affordable rows capital=%.2f total=%d no_price=%d blocked=%d",
float(capital or 0),
len(all_rows),
sum(1 for r in all_rows if r.get("status") == "no_price"),
sum(1 for r in all_rows if r.get("status") == "blocked"),
)
now = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
conn.execute(
"""INSERT INTO product_recommend_cache (id, capital, rows_json, updated_at)
VALUES (1, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET
capital=excluded.capital,
rows_json=excluded.rows_json,
updated_at=excluded.updated_at""",
(float(capital or 0), json.dumps(rows, ensure_ascii=False), now),
)
conn.commit()
return rows
def recommend_cache_stale(updated_at: Optional[str], *, now: Optional[datetime] = None) -> bool:
"""缓存是否不是今日更新(需重新拉行情计算)。"""
if not updated_at:
return True
try:
cached_day = datetime.strptime(str(updated_at)[:10], "%Y-%m-%d").date()
except ValueError:
return True
today = (now or datetime.now()).date()
return cached_day != today
def load_recommend_cache(conn) -> dict:
"""优先从数据库读取可开仓品种列表。"""
ensure_recommend_tables(conn)
row = conn.execute("SELECT capital, rows_json, updated_at FROM product_recommend_cache WHERE id=1").fetchone()
if not row:
return {"capital": 0.0, "rows": [], "updated_at": None, "stale": True}
try:
rows = json.loads(row["rows_json"] or "[]")
except (TypeError, ValueError, json.JSONDecodeError):
rows = []
updated_at = row["updated_at"]
return {
"capital": float(row["capital"] or 0),
"rows": rows if isinstance(rows, list) else [],
"updated_at": updated_at,
"stale": recommend_cache_stale(updated_at),
}
def recommend_payload(
conn,
*,
live_capital: float,
max_margin_pct: float = 30.0,
trading_mode: str = "simulation",
sizing_mode: str = "fixed",
fixed_lots: int = 1,
use_ctp_margin: bool = True,
) -> dict:
"""读取缓存并附带当前权益(展示用,可能与缓存计算时不同)。"""
payload = load_recommend_cache(conn)
cap = float(live_capital or 0)
pct = max(1.0, min(100.0, float(max_margin_pct or 30.0)))
if use_ctp_margin:
used = recommend_margin_used(trading_mode)
else:
used = 0.0
try:
from modules.trading.position_stream import position_hub
snap = position_hub.get_snapshot() or {}
raw = snap.get("margin_used")
if raw is not None:
used = max(0.0, float(raw or 0))
except Exception:
pass
if used <= 0:
used = float(payload.get("margin_used") or 0)
budget_info = margin_budget_info(cap, pct, used)
payload["capital"] = cap
payload["max_margin_pct"] = pct
payload.update(budget_info)
rows = payload.get("rows") or []
rows = enrich_recommend_rows(
rows,
cap,
max_margin_pct=pct,
trading_mode=trading_mode,
margin_used=used,
use_ctp_margin=use_ctp_margin,
)
rows = filter_rows_for_account_scope(
rows, cap, ctp_connected=_ctp_connected_for_mode(trading_mode),
)
rows = filter_recommend_by_sizing(rows, sizing_mode=sizing_mode, fixed_lots=fixed_lots)
rows = sort_recommend_by_trend(rows)
payload["rows"] = rows
payload["needs_refresh"] = recommend_cache_needs_refresh(payload, capital=cap)
return payload
+163
View File
@@ -0,0 +1,163 @@
# Copyright (c) 2025-2026 马建军. All rights reserved.
# 专有软件 — 未经授权禁止复制、传播、转售。
# 严禁用于:带单/代客理财、向他人推荐期货品种或买卖建议、融资配资等业务。
# 详见 LICENSE.zh-CN.txt 与 docs/软件购买与使用协议.md
"""可开仓品种 SSE 推送与后台刷新。"""
from __future__ import annotations
import json
import logging
import queue
import threading
import time
from typing import Callable, Optional
from modules.core.db_conn import connect_db
from modules.market.kline_stream import sse_format
from modules.trading.recommend_store import (
load_recommend_cache,
recommend_cache_needs_refresh,
recommend_payload,
refresh_recommend_cache,
)
logger = logging.getLogger(__name__)
CHECK_INTERVAL_SEC = 3600
_refresh_lock = threading.Lock()
_refresh_running = False
def schedule_recommend_refresh(
*,
db_path: str,
get_capital_fn: Callable,
quote_fn: Callable[[str], Optional[dict]],
init_tables_fn: Callable | None = None,
get_mode_fn: Callable[[], str] | None = None,
get_max_margin_pct_fn: Callable[[], float] | None = None,
get_sizing_mode_fn: Callable[[], str] | None = None,
get_fixed_lots_fn: Callable[[], int] | None = None,
) -> None:
"""后台刷新可开仓品种缓存(不阻塞页面请求)。"""
global _refresh_running
with _refresh_lock:
if _refresh_running:
return
_refresh_running = True
def _run() -> None:
global _refresh_running
try:
conn = connect_db(db_path)
try:
if init_tables_fn:
init_tables_fn(conn)
capital = float(get_capital_fn(conn) or 0)
mode = get_mode_fn() if get_mode_fn else "simulation"
max_pct = float(get_max_margin_pct_fn()) if get_max_margin_pct_fn else 30.0
cached = load_recommend_cache(conn)
if not recommend_cache_needs_refresh(cached, capital=capital):
payload = recommend_payload(
conn,
live_capital=capital,
max_margin_pct=max_pct,
trading_mode=mode,
sizing_mode=get_sizing_mode_fn() if get_sizing_mode_fn else "fixed",
fixed_lots=get_fixed_lots_fn() if get_fixed_lots_fn else 1,
)
recommend_hub.broadcast("recommend", {"ok": True, **payload})
return
refresh_recommend_cache(
conn, capital, quote_fn, trading_mode=mode, max_margin_pct=max_pct,
)
cached = load_recommend_cache(conn)
logger.info(
"可开仓品种后台刷新完成,capital=%.2f rows=%d",
capital, len(cached.get("rows") or []),
)
payload = recommend_payload(
conn,
live_capital=capital,
max_margin_pct=max_pct,
trading_mode=mode,
sizing_mode=get_sizing_mode_fn() if get_sizing_mode_fn else "fixed",
fixed_lots=get_fixed_lots_fn() if get_fixed_lots_fn else 1,
)
finally:
conn.close()
recommend_hub.broadcast("recommend", {"ok": True, **payload})
except Exception as exc:
logger.warning("recommend background refresh failed: %s", exc)
finally:
with _refresh_lock:
_refresh_running = False
threading.Thread(target=_run, daemon=True, name="recommend-refresh").start()
class RecommendStreamHub:
def __init__(self) -> None:
self._lock = threading.Lock()
self._subs: list[queue.Queue] = []
def subscribe(self) -> queue.Queue:
q: queue.Queue = queue.Queue(maxsize=8)
with self._lock:
self._subs.append(q)
return q
def unsubscribe(self, q: queue.Queue) -> None:
with self._lock:
try:
self._subs.remove(q)
except ValueError:
pass
def broadcast(self, event: str, data: dict) -> None:
msg = {"event": event, "data": data}
with self._lock:
subs = list(self._subs)
for q in subs:
try:
q.put_nowait(msg)
except queue.Full:
pass
recommend_hub = RecommendStreamHub()
def start_recommend_worker(
*,
db_path: str,
get_capital_fn: Callable,
quote_fn: Callable[[str], Optional[dict]],
init_tables_fn: Callable | None = None,
get_mode_fn: Callable[[], str] | None = None,
get_max_margin_pct_fn: Callable[[], float] | None = None,
get_sizing_mode_fn: Callable[[], str] | None = None,
get_fixed_lots_fn: Callable[[], int] | None = None,
interval: int = CHECK_INTERVAL_SEC,
) -> None:
"""后台每日刷新可开仓列表(每小时检查一次是否需更新),并推送给 SSE 订阅者。"""
def _loop() -> None:
while True:
try:
schedule_recommend_refresh(
db_path=db_path,
get_capital_fn=get_capital_fn,
quote_fn=quote_fn,
init_tables_fn=init_tables_fn,
get_mode_fn=get_mode_fn,
get_max_margin_pct_fn=get_max_margin_pct_fn,
get_sizing_mode_fn=get_sizing_mode_fn,
get_fixed_lots_fn=get_fixed_lots_fn,
)
except Exception as exc:
logger.warning("recommend worker failed: %s", exc)
time.sleep(max(300, interval))
threading.Thread(target=_loop, daemon=True, name="recommend-worker").start()
+339
View File
@@ -0,0 +1,339 @@
# Copyright (c) 2025-2026 马建军. All rights reserved.
# 专有软件 — 未经授权禁止复制、传播、转售。
# 严禁用于:带单/代客理财、向他人推荐期货品种或买卖建议、融资配资等业务。
# 详见 LICENSE.zh-CN.txt 与 docs/软件购买与使用协议.md
"""可开仓品种:近一周日线走势(多头 / 空头 / 震荡 / 转多 / 转空)。"""
from __future__ import annotations
import logging
from typing import Callable, Optional
import requests
from modules.market.kline_chart import fetch_sina_klines, ths_to_sina_chart_symbol
logger = logging.getLogger(__name__)
DAILY_LOOKBACK = 7
OVERLAP_WINDOW = 3
OVERLAP_RANGE_THRESHOLD = 0.70
KLINE_FETCH_TIMEOUT = 5
TREND_LONG = "long"
TREND_SHORT = "short"
TREND_RANGE = "range"
TREND_BREAK_LONG = "break_long"
TREND_BREAK_SHORT = "break_short"
def _bar_ohlc(bar: dict) -> tuple[float, float, float, float]:
o = float(bar.get("o") or bar.get("open") or 0)
h = float(bar.get("h") or bar.get("high") or o)
l = float(bar.get("l") or bar.get("low") or o)
c = float(bar.get("c") or bar.get("close") or o)
return o, h, l, c
def kline_overlap_ratio(bars: list) -> float:
"""三根 K 线高低价区间的重叠度 = 交集 / 并集(0~1)。"""
if len(bars) < OVERLAP_WINDOW:
return 0.0
chunk = bars[-OVERLAP_WINDOW:]
lows, highs = [], []
for bar in chunk:
_, h, l, _ = _bar_ohlc(bar)
if h <= 0 and l <= 0:
continue
lows.append(l)
highs.append(h)
if len(lows) < OVERLAP_WINDOW:
return 0.0
overlap = max(0.0, min(highs) - max(lows))
union = max(highs) - min(lows)
if union <= 0:
return 1.0 if overlap > 0 else 0.0
return overlap / union
def _direction_from_closes(bars: list) -> str:
if len(bars) < 2:
return TREND_RANGE
closes = [_bar_ohlc(b)[3] for b in bars if _bar_ohlc(b)[3] > 0]
if len(closes) < 2:
return TREND_RANGE
if closes[-1] > closes[0]:
return TREND_LONG
if closes[-1] < closes[0]:
return TREND_SHORT
return TREND_RANGE
def _bar_ohlcv(bar: dict) -> tuple[float, float, float, float, float]:
o, h, l, c = _bar_ohlc(bar)
v = float(bar.get("v") or bar.get("volume") or 0)
return o, h, l, c, v
def compute_daily_quote_stats(bars: list) -> dict:
"""从日线提取:跳空、昨收、今开、昨涨跌、昨振幅、成交量。"""
empty = {
"gap": "",
"gap_label": "",
"gap_pct": None,
"prev_close": None,
"today_open": None,
"yesterday_change": None,
"yesterday_change_pct": None,
"yesterday_amplitude_pct": None,
"volume": None,
}
if len(bars) < 2:
return empty
t_o, _, _, _, t_v = _bar_ohlcv(bars[-1])
y_o, y_h, y_l, y_c, y_v = _bar_ohlcv(bars[-2])
if y_c <= 0:
return empty
prev_close = round(y_c, 4)
today_open = round(t_o, 4) if t_o > 0 else None
gap, gap_label, gap_pct = "none", "", 0.0
if today_open is not None and today_open > y_c:
gap, gap_label = "up", "跳空高开"
gap_pct = (today_open - y_c) / y_c * 100
elif today_open is not None and today_open < y_c:
gap, gap_label = "down", "跳空低开"
gap_pct = (today_open - y_c) / y_c * 100
if len(bars) >= 3:
_, _, _, p_c, _ = _bar_ohlcv(bars[-3])
base = p_c if p_c > 0 else y_o
else:
base = y_o if y_o > 0 else y_c
y_change = y_c - base if base > 0 else None
y_change_pct = (y_change / base * 100) if y_change is not None and base > 0 else None
y_amp = ((y_h - y_l) / base * 100) if base > 0 and y_h >= y_l else None
vol = y_v if y_v > 0 else (t_v if t_v > 0 else None)
return {
"gap": gap,
"gap_label": gap_label,
"gap_pct": round(gap_pct, 2) if gap != "none" else 0.0,
"prev_close": prev_close,
"today_open": today_open,
"yesterday_change": round(y_change, 4) if y_change is not None else None,
"yesterday_change_pct": round(y_change_pct, 2) if y_change_pct is not None else None,
"yesterday_amplitude_pct": round(y_amp, 2) if y_amp is not None else None,
"volume": int(vol) if vol is not None else None,
"volume_unit": "lot",
}
def analyze_daily_trend(bars: list, *, overlap_threshold: float = OVERLAP_RANGE_THRESHOLD) -> dict:
"""根据近一周日线判断走势;最近三天重叠度≥阈值视为震荡。"""
empty = {
"trend": "",
"trend_label": "",
"trend_transition": False,
"trend_overlap_pct": None,
"trend_prev_overlap_pct": None,
}
if len(bars) < OVERLAP_WINDOW:
return empty
recent = bars[-DAILY_LOOKBACK:] if len(bars) > DAILY_LOOKBACK else bars
curr_overlap = kline_overlap_ratio(recent)
prev_overlap = kline_overlap_ratio(recent[:-OVERLAP_WINDOW]) if len(recent) >= OVERLAP_WINDOW * 2 else 0.0
curr_range = curr_overlap >= overlap_threshold
prev_range = prev_overlap >= overlap_threshold
if curr_range:
trend, label = TREND_RANGE, "震荡"
transition = False
else:
direction = _direction_from_closes(recent[-OVERLAP_WINDOW:])
if direction == TREND_LONG:
trend, label = TREND_LONG, "多头"
elif direction == TREND_SHORT:
trend, label = TREND_SHORT, "空头"
else:
trend, label = TREND_RANGE, "震荡"
transition = prev_range and trend in (TREND_LONG, TREND_SHORT)
if transition:
if trend == TREND_LONG:
trend, label = TREND_BREAK_LONG, "转多"
else:
trend, label = TREND_BREAK_SHORT, "转空"
return {
"trend": trend,
"trend_label": label,
"trend_transition": transition,
"trend_overlap_pct": round(curr_overlap * 100, 1),
"trend_prev_overlap_pct": round(prev_overlap * 100, 1) if prev_overlap else None,
}
def _normalize_daily_bars(raw: list) -> list:
out = []
for row in raw:
if isinstance(row, list) and len(row) >= 5:
out.append({
"d": str(row[0]),
"o": float(row[1]),
"h": float(row[2]),
"l": float(row[3]),
"c": float(row[4]),
"v": float(row[5]) if len(row) > 5 and row[5] else 0.0,
})
elif isinstance(row, dict) and row.get("d"):
out.append({
"d": str(row["d"]),
"o": float(row.get("o", 0) or 0),
"h": float(row.get("h", 0) or 0),
"l": float(row.get("l", 0) or 0),
"c": float(row.get("c", 0) or 0),
"v": float(row.get("v", 0) or 0),
})
return out
def _fetch_sina_daily_quick(chart_sym: str) -> list:
url = (
"https://stock2.finance.sina.com.cn/futures/api/json.php/"
f"IndexService.getInnerFuturesDailyKLine?symbol={chart_sym}"
)
try:
resp = requests.get(
url, timeout=KLINE_FETCH_TIMEOUT,
headers={"Referer": "https://finance.sina.com.cn"},
)
raw = resp.json()
if raw and isinstance(raw, list):
bars = _normalize_daily_bars(raw)
if bars:
return bars
except Exception as exc:
logger.debug("quick daily kline failed %s: %s", chart_sym, exc)
return []
def fetch_week_daily_bars(
symbol: str,
*,
fetch_fn: Callable[[str, str], list] | None = None,
) -> list:
sym = (symbol or "").strip()
if not sym:
return []
if fetch_fn:
try:
bars = fetch_fn(sym, "d") or []
except Exception as exc:
logger.debug("fetch week daily failed %s: %s", sym, exc)
return []
return bars[-DAILY_LOOKBACK:] if bars else []
chart_sym = ths_to_sina_chart_symbol(sym)
if not chart_sym:
return []
bars = _fetch_sina_daily_quick(chart_sym)
if not bars:
try:
bars = fetch_sina_klines(sym, "d") or []
except Exception as exc:
logger.debug("fetch week daily fallback failed %s: %s", sym, exc)
return []
return bars[-DAILY_LOOKBACK:] if bars else []
def analyze_product_daily(
symbol: str,
*,
fetch_fn: Callable[[str, str], list] | None = None,
) -> dict:
"""拉取主力合约一周日线:走势 + 跳空/量价统计。"""
sym = (symbol or "").strip()
if not sym:
out = analyze_daily_trend([])
out.update(compute_daily_quote_stats([]))
return out
bars = fetch_week_daily_bars(sym, fetch_fn=fetch_fn)
out = analyze_daily_trend(bars)
out.update(compute_daily_quote_stats(bars))
return out
def analyze_product_trend(
symbol: str,
*,
fetch_fn: Callable[[str, str], list] | None = None,
) -> dict:
return analyze_product_daily(symbol, fetch_fn=fetch_fn)
GAP_SORT_RANK = {"up": 2, "down": 1, "none": 0, "": -1}
TREND_SORT_RANK = {
TREND_BREAK_LONG: 0,
TREND_BREAK_SHORT: 0,
TREND_LONG: 1,
TREND_SHORT: 2,
TREND_RANGE: 3,
"": 9,
}
def recommend_sort_key(row: dict, sort_by: str = "trend", *, desc: bool = True) -> tuple:
"""可排序字段:trend / gap / volume / amplitude。"""
key = (sort_by or "trend").strip().lower()
if key == "gap":
primary = GAP_SORT_RANK.get(row.get("gap") or "", -1)
secondary = abs(float(row.get("gap_pct") or 0))
elif key == "volume":
primary = float(row.get("volume") or 0)
secondary = 0.0
elif key == "amplitude":
primary = float(row.get("yesterday_amplitude_pct") or 0)
secondary = 0.0
else:
primary = TREND_SORT_RANK.get(row.get("trend") or "", 9)
secondary = -(int(row.get("max_lots") or 0))
if desc:
return (-primary, -secondary, row.get("name") or "")
return (primary, secondary, row.get("name") or "")
def sort_recommend_rows(
rows: list[dict],
*,
sort_by: str = "trend",
desc: bool = True,
) -> list[dict]:
return sorted(rows, key=lambda r: recommend_sort_key(r, sort_by, desc=desc))
def trend_sort_key(row: dict) -> tuple:
"""转多/转空优先,其次多头/空头,震荡靠后。"""
trend = (row.get("trend") or "").strip()
priority = {
TREND_BREAK_LONG: 0,
TREND_BREAK_SHORT: 0,
TREND_LONG: 1,
TREND_SHORT: 1,
TREND_RANGE: 2,
}
status_order = {"ok": 0, "margin_ok": 1, "blocked": 2, "no_price": 3}
return (
priority.get(trend, 3),
status_order.get(row.get("status") or "", 9),
-(int(row.get("max_lots") or 0)),
)
def sort_recommend_by_trend(rows: list[dict]) -> list[dict]:
return sort_recommend_rows(rows, sort_by="trend", desc=True)
File diff suppressed because it is too large Load Diff
+218
View File
@@ -0,0 +1,218 @@
# Copyright (c) 2025-2026 马建军. All rights reserved.
# 专有软件 — 未经授权禁止复制、传播、转售。
# 严禁用于:带单/代客理财、向他人推荐期货品种或买卖建议、融资配资等业务。
# 详见 LICENSE.zh-CN.txt 与 docs/软件购买与使用协议.md
"""交易记录:字段补全、资金曲线数据。"""
from __future__ import annotations
from typing import Any
TRADE_LOG_EXTRA_COLUMNS = (
"ALTER TABLE trade_logs ADD COLUMN margin_pct REAL",
"ALTER TABLE trade_logs ADD COLUMN equity_after REAL",
"ALTER TABLE trade_logs ADD COLUMN source TEXT DEFAULT 'local'",
"ALTER TABLE trade_logs ADD COLUMN ctp_trade_key TEXT",
)
def ensure_trade_log_columns(conn) -> None:
for sql in TRADE_LOG_EXTRA_COLUMNS:
try:
conn.execute(sql)
except Exception:
pass
def calc_equity_after(capital: float, pnl_net: float) -> float | None:
cap = float(capital or 0)
if cap <= 0:
return None
return round(cap + float(pnl_net or 0), 2)
def recalc_trade_log_pnl(
*,
symbol: str,
direction: str,
entry_price: float,
close_price: float,
lots: float,
stop_loss: float | None = None,
take_profit: float | None = None,
open_time: str = "",
close_time: str = "",
trading_mode: str = "simulation",
capital: float = 0.0,
) -> dict[str, float]:
"""按开/平仓价重算盈亏与手续费(跨日持仓可手动改价后核对)。"""
from modules.core.contract_specs import calc_position_metrics
from modules.fees.fee_specs import calc_round_trip_fee
sym = (symbol or "").strip()
direction = (direction or "long").strip().lower()
entry = float(entry_price or close_price or 0)
close_px = float(close_price or 0)
lots_f = float(lots or 0)
sl = float(stop_loss) if stop_loss is not None else entry
tp = float(take_profit) if take_profit is not None else entry
metrics = calc_position_metrics(
direction, entry, sl, tp, lots_f, close_px, capital, sym,
)
pnl = round(float(metrics.get("float_pnl") or 0), 2)
fee = calc_round_trip_fee(
sym, entry, close_px, lots_f, open_time, close_time, trading_mode=trading_mode,
)
pnl_net = round(pnl - fee, 2)
return {"pnl": pnl, "fee": round(fee, 2), "pnl_net": pnl_net}
def _read_initial_capital(conn, initial_capital: float | None = None) -> float:
if initial_capital is not None and initial_capital > 0:
return float(initial_capital)
try:
row = conn.execute("SELECT value FROM settings WHERE key='live_capital'").fetchone()
if row and row[0]:
val = float(row[0] or 0)
if val > 0:
return val
except (TypeError, ValueError):
pass
try:
from modules.trading.product_recommend import DISCONNECTED_RECOMMEND_CAPITAL
return float(DISCONNECTED_RECOMMEND_CAPITAL)
except Exception:
return 100_000.0
def refresh_trade_log_equity_chain(
conn,
initial_capital: float | None = None,
) -> int:
"""按平仓时间顺序重算 trade_logs.equity_after(起始=参考资金 live_capital)。"""
base = _read_initial_capital(conn, initial_capital)
rows = [
dict(r)
for r in conn.execute(
"SELECT id, close_time, pnl_net FROM trade_logs ORDER BY close_time ASC, id ASC"
).fetchall()
]
running = float(base or 0)
updated = 0
for row in rows:
if running <= 0:
break
running = round(running + float(row.get("pnl_net") or 0), 2)
conn.execute(
"UPDATE trade_logs SET equity_after=? WHERE id=?",
(running, int(row["id"])),
)
updated += 1
return updated
def _norm_symbol(symbol: str) -> str:
return (symbol or "").split(".")[0].strip().lower()
def _norm_close_minute(ts: str) -> str:
"""统一 close_time 到分钟粒度,兼容 ISO `T` 与空格分隔。"""
return (ts or "").strip().replace("T", " ")[:16]
def purge_duplicate_local_trade_logs(conn) -> int:
"""删除已被 CTP 柜台记录覆盖的本地重复成交。"""
removed = 0
ctp_rows = [
dict(r)
for r in conn.execute("SELECT * FROM trade_logs WHERE source='ctp'").fetchall()
]
local_rows = [
dict(r)
for r in conn.execute(
"""SELECT * FROM trade_logs
WHERE COALESCE(source, 'local') != 'ctp'
AND (ctp_trade_key IS NULL OR ctp_trade_key = '')"""
).fetchall()
]
for ctp in ctp_rows:
ct16 = _norm_close_minute(ctp.get("close_time") or "")
sym_n = _norm_symbol(ctp.get("symbol") or "")
lots = float(ctp.get("lots") or 0)
direction = (ctp.get("direction") or "long").strip().lower()
for loc in local_rows:
if loc.get("id") == ctp.get("id"):
continue
if _norm_symbol(loc.get("symbol") or "") != sym_n:
continue
if (loc.get("direction") or "long").strip().lower() != direction:
continue
if _norm_close_minute(loc.get("close_time") or "") != ct16:
continue
if abs(float(loc.get("lots") or 0) - lots) > 0.01:
continue
conn.execute("DELETE FROM trade_logs WHERE id=?", (loc["id"],))
removed += 1
return removed
def _attach_symbol_meta(t: dict[str, Any]) -> None:
try:
from modules.core.symbols import position_symbol_meta
sym = (t.get("symbol") or "").strip()
meta = position_symbol_meta(sym)
if not t.get("symbol_name"):
t["symbol_name"] = meta.get("name") or sym
t["symbol_exchange"] = meta.get("exchange") or ""
t["symbol_is_main"] = bool(meta.get("is_main"))
except Exception:
t.setdefault("symbol_exchange", "")
t.setdefault("symbol_is_main", False)
def enrich_trades_for_records(
trades: list[dict[str, Any]],
*,
initial_capital: float = 0.0,
) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
"""表格仍按 id 降序;资金曲线按平仓时间升序用最新资金绘制。"""
rows = [dict(t) for t in trades]
chrono = sorted(
rows,
key=lambda t: ((t.get("close_time") or ""), int(t.get("id") or 0)),
)
running = float(initial_capital or 0)
curve: list[dict[str, Any]] = []
equity_by_id: dict[int, float | None] = {}
for t in chrono:
_attach_symbol_meta(t)
pnl_net = float(t.get("pnl_net") or 0)
if running > 0:
running = round(running + pnl_net, 2)
eq: float | None = running
else:
eq = None
equity_by_id[int(t.get("id") or 0)] = eq
cap_before = float(eq or 0) - pnl_net if eq is not None else 0.0
if t.get("margin_pct") is None:
margin = float(t.get("margin") or 0)
if margin > 0 and cap_before > 0:
t["margin_pct"] = round(margin / cap_before * 100, 2)
if eq is not None:
curve.append({
"time": (t.get("close_time") or "")[:19],
"value": float(eq),
"id": int(t.get("id") or 0),
})
for t in rows:
tid = int(t.get("id") or 0)
if tid in equity_by_id:
t["equity_after"] = equity_by_id[tid]
return rows, curve
+225
View File
@@ -0,0 +1,225 @@
# Copyright (c) 2025-2026 马建军. All rights reserved.
# 专有软件 — 未经授权禁止复制、传播、转售。
# 严禁用于:带单/代客理财、向他人推荐期货品种或买卖建议、融资配资等业务。
# 详见 LICENSE.zh-CN.txt 与 docs/软件购买与使用协议.md
"""交易事件推送:企业微信 + AI 分析。"""
from __future__ import annotations
from typing import Callable, Optional
from modules.core.contract_specs import calc_position_metrics, get_contract_spec
from modules.trading.sl_tp_guard import monitor_source_label
from modules.notify.wechat_notify import format_close_done, format_key_open_success, format_open_success
def _risk_amount(capital: float, risk_percent: float) -> Optional[float]:
try:
return round(float(capital) * float(risk_percent) / 100.0, 2)
except (TypeError, ValueError):
return None
def notify_manual_open_filled(
*,
send_wechat: Callable[[str], None],
get_setting: Callable[[str, str], str],
mode_label: str,
sym: str,
symbol_name: str,
direction: str,
entry: float,
sl: Optional[float],
tp: Optional[float],
lots: int,
capital: float,
order_id: str = "",
trailing_be: bool = False,
be_tick_buffer: int = 2,
schedule_ai_fn=None,
db_path: str = "",
) -> None:
if not sl:
return
spec = get_contract_spec(sym)
tick = float(spec.get("tick_size") or 1.0)
try:
rp = float(get_setting("risk_percent", "1") or 1)
except (TypeError, ValueError):
rp = 1.0
metrics = calc_position_metrics(direction, entry, sl, tp or entry, lots, entry, capital, sym)
msg = format_open_success(
symbol_name=symbol_name,
symbol=sym,
direction=direction,
mode_label=mode_label,
order_id=order_id,
entry=entry,
stop_loss=float(sl),
take_profit=float(tp) if tp else None,
lots=lots,
capital=capital,
margin=metrics.get("margin"),
margin_pct=metrics.get("position_pct"),
risk_percent=rp,
risk_amount=_risk_amount(capital, rp),
trailing_be=trailing_be,
be_tick_buffer=be_tick_buffer,
tick_size=tick,
source="期货下单",
)
send_wechat(msg)
if schedule_ai_fn and db_path:
schedule_ai_fn(
db_path=db_path,
get_setting_fn=get_setting,
kind="open",
title=f"{symbol_name or sym} 开仓",
payload={
"symbol": sym,
"direction": direction,
"entry": entry,
"stop_loss": sl,
"take_profit": tp,
"lots": lots,
"capital": capital,
},
send_wechat_fn=None,
)
def notify_key_breakout_open(
*,
send_wechat: Callable[[str], None],
get_setting: Callable[[str, str], str],
mode_label: str,
row: dict,
break_side: str,
bar_time: str,
direction: str,
entry: float,
sl: float,
tp: float,
lots: int,
capital: float,
order_id: str = "",
schedule_ai_fn=None,
db_path: str = "",
) -> None:
sym = row.get("symbol") or ""
name = row.get("symbol_name") or sym
trailing_be = bool(int(row.get("trailing_be") or 0))
try:
rp = float(get_setting("risk_percent", "1") or 1)
be_buf = int(float(get_setting("trailing_be_tick_buffer", "2") or 2))
except (TypeError, ValueError):
rp, be_buf = 1.0, 2
spec = get_contract_spec(sym)
tick = float(spec.get("tick_size") or 1.0)
metrics = calc_position_metrics(direction, entry, sl, tp, lots, entry, capital, sym)
msg = format_key_open_success(
symbol_name=name,
symbol=sym,
monitor_type=row.get("monitor_type") or "",
trade_mode=row.get("trade_mode") or "顺势",
bar_time=bar_time,
break_side=break_side,
direction=direction,
mode_label=mode_label,
order_id=order_id,
entry=entry,
stop_loss=sl,
take_profit=tp,
lots=lots,
capital=capital,
margin=metrics.get("margin"),
margin_pct=metrics.get("position_pct"),
risk_percent=rp,
risk_amount=_risk_amount(capital, rp),
trailing_be=trailing_be,
be_tick_buffer=be_buf,
tick_size=tick,
)
send_wechat(msg)
if schedule_ai_fn and db_path:
schedule_ai_fn(
db_path=db_path,
get_setting_fn=get_setting,
kind="key_open",
title=f"{name} 关键位开仓",
payload={
"monitor_type": row.get("monitor_type"),
"trade_mode": row.get("trade_mode"),
"break_side": break_side,
"entry": entry,
"stop_loss": sl,
"take_profit": tp,
"lots": lots,
},
)
def notify_trade_log_close(
*,
send_wechat: Callable[[str], None],
get_setting: Callable[[str, str], str],
mode_label: str,
capital: float,
sym: str,
symbol_name: str,
direction: str,
entry: float,
close_price: float,
sl: Optional[float],
tp: Optional[float],
lots: float,
pnl_net: float,
equity_after: Optional[float],
holding_minutes: int,
result: str,
monitor_type: str = "",
schedule_ai_fn=None,
db_path: str = "",
) -> None:
src = monitor_source_label(monitor_type) if monitor_type else "期货下单"
note = ""
if tp and sl:
if direction == "long":
if close_price > tp or close_price < sl:
note = "成交价不在计划止盈/止损带内(可能为手动或其他类型平仓)"
else:
if close_price < tp or close_price > sl:
note = "成交价不在计划止盈/止损带内(可能为手动或其他类型平仓)"
msg = format_close_done(
symbol_name=symbol_name,
symbol=sym,
mode_label=mode_label,
direction=direction,
result=result,
pnl_net=pnl_net,
equity_after=equity_after,
capital=capital,
entry=entry,
close_price=close_price,
stop_loss=sl,
take_profit=tp,
lots=lots,
holding_minutes=holding_minutes,
note=note,
)
send_wechat(msg)
if schedule_ai_fn and db_path:
schedule_ai_fn(
db_path=db_path,
get_setting_fn=get_setting,
kind="close",
title=f"{symbol_name or sym} 平仓",
payload={
"source": src,
"result": result,
"pnl_net": pnl_net,
"entry": entry,
"close_price": close_price,
"lots": lots,
},
)