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
+10
View File
@@ -0,0 +1,10 @@
# Copyright (c) 2025-2026 马建军. All rights reserved.
"""Strategy routes are registered via modules.trading (install_trading)."""
def register(deps) -> None:
del deps
__all__ = ["register"]
+23
View File
@@ -0,0 +1,23 @@
# Copyright (c) 2025-2026 马建军. All rights reserved.
# 专有软件 — 未经授权禁止复制、传播、转售。
# 严禁用于:带单/代客理财、向他人推荐期货品种或买卖建议、融资配资等业务。
# 详见 LICENSE.zh-CN.txt 与 docs/软件购买与使用协议.md
"""斐波计算(自 crypto_monitor 复制,期货共用)。"""
def calc_fib_plan(direction, upper, lower, ratio):
try:
h = float(upper)
l = float(lower)
r = float(ratio)
except (TypeError, ValueError):
return None
if h <= l or r <= 0 or r >= 1:
return None
span = h - l
direction = (direction or "long").strip().lower()
if direction == "short":
entry = l + r * span
return entry, h, l
entry = h - r * span
return entry, l, h
+169
View File
@@ -0,0 +1,169 @@
# Copyright (c) 2025-2026 马建军. All rights reserved.
# 专有软件 — 未经授权禁止复制、传播、转售。
# 严禁用于:带单/代客理财、向他人推荐期货品种或买卖建议、融资配资等业务。
# 详见 LICENSE.zh-CN.txt 与 docs/软件购买与使用协议.md
"""策略相关表结构。"""
from __future__ import annotations
from modules.core.db_conn import rollback_if_postgres
ROLL_GROUPS_SQL = """
CREATE TABLE IF NOT EXISTS roll_groups (
id INTEGER PRIMARY KEY AUTOINCREMENT,
order_monitor_id INTEGER,
symbol TEXT NOT NULL,
direction TEXT NOT NULL,
initial_take_profit REAL,
initial_stop_loss REAL,
current_stop_loss REAL,
risk_percent REAL DEFAULT 2,
leg_count INTEGER DEFAULT 0,
status TEXT DEFAULT 'active',
created_at TEXT,
updated_at TEXT
)
"""
ROLL_LEGS_SQL = """
CREATE TABLE IF NOT EXISTS roll_legs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
roll_group_id INTEGER NOT NULL,
leg_index INTEGER NOT NULL,
add_mode TEXT NOT NULL,
fill_price REAL,
lots INTEGER,
new_stop_loss REAL,
status TEXT DEFAULT 'filled',
created_at TEXT
)
"""
TREND_PLANS_SQL = """
CREATE TABLE IF NOT EXISTS trend_pullback_plans (
id INTEGER PRIMARY KEY AUTOINCREMENT,
status TEXT DEFAULT 'active',
symbol TEXT NOT NULL,
symbol_name TEXT,
direction TEXT NOT NULL DEFAULT 'long',
stop_loss REAL NOT NULL,
add_upper REAL NOT NULL,
take_profit REAL NOT NULL,
risk_percent REAL DEFAULT 5,
capital_snapshot REAL,
plan_margin REAL,
target_lots INTEGER,
first_lots INTEGER,
remainder_lots INTEGER,
dca_legs INTEGER DEFAULT 5,
leg_amounts_json TEXT,
grid_prices_json TEXT,
legs_done INTEGER DEFAULT 0,
first_order_done INTEGER DEFAULT 0,
avg_entry_price REAL,
lots_open INTEGER DEFAULT 0,
opened_at TEXT,
message TEXT,
period TEXT DEFAULT '15m'
)
"""
STRATEGY_SNAPSHOTS_SQL = """
CREATE TABLE IF NOT EXISTS strategy_trade_snapshots (
id INTEGER PRIMARY KEY AUTOINCREMENT,
strategy_type TEXT NOT NULL,
source_id INTEGER,
symbol TEXT,
direction TEXT,
result_label TEXT,
opened_at TEXT,
closed_at TEXT,
pnl_amount REAL,
snapshot_json TEXT NOT NULL,
created_at TEXT
)
"""
TRADE_ORDER_MONITORS_SQL = """
CREATE TABLE IF NOT EXISTS trade_order_monitors (
id INTEGER PRIMARY KEY AUTOINCREMENT,
symbol TEXT NOT NULL,
symbol_name TEXT,
market_code TEXT,
direction TEXT NOT NULL,
lots INTEGER NOT NULL,
entry_price REAL,
stop_loss REAL,
take_profit REAL,
open_time TEXT,
monitor_type TEXT DEFAULT 'manual',
status TEXT DEFAULT 'active',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
"""
CTP_SIM_ACCOUNT_SQL = """
CREATE TABLE IF NOT EXISTS ctp_sim_account (
id INTEGER PRIMARY KEY CHECK (id = 1),
balance REAL DEFAULT 100000,
available REAL DEFAULT 100000,
updated_at TEXT
)
"""
CTP_SIM_POSITIONS_SQL = """
CREATE TABLE IF NOT EXISTS ctp_sim_positions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
symbol TEXT NOT NULL,
direction TEXT NOT NULL,
lots INTEGER NOT NULL,
avg_price REAL NOT NULL,
updated_at TEXT,
UNIQUE(symbol, direction)
)
"""
ROLL_LEG_EXTRA_COLUMNS = (
"ALTER TABLE roll_legs ADD COLUMN limit_price REAL",
"ALTER TABLE roll_legs ADD COLUMN breakthrough_price REAL",
"ALTER TABLE roll_legs ADD COLUMN last_mark_price REAL",
"ALTER TABLE roll_legs ADD COLUMN invalidated_reason TEXT",
"ALTER TABLE roll_legs ADD COLUMN capital_snapshot REAL",
"ALTER TABLE trade_order_monitors ADD COLUMN risk_percent REAL",
)
_TABLES_READY = False
def init_strategy_tables(conn) -> None:
global _TABLES_READY
if _TABLES_READY:
return
for sql in (
ROLL_GROUPS_SQL,
ROLL_LEGS_SQL,
TREND_PLANS_SQL,
STRATEGY_SNAPSHOTS_SQL,
TRADE_ORDER_MONITORS_SQL,
CTP_SIM_ACCOUNT_SQL,
CTP_SIM_POSITIONS_SQL,
):
conn.execute(sql)
conn.commit()
try:
conn.execute("ALTER TABLE trend_pullback_plans ADD COLUMN period TEXT DEFAULT '15m'")
except Exception:
pass
for sql in ROLL_LEG_EXTRA_COLUMNS:
try:
conn.execute(sql)
conn.commit()
except Exception:
rollback_if_postgres(conn)
pass
if not conn.execute("SELECT id FROM ctp_sim_account WHERE id=1").fetchone():
conn.execute("INSERT INTO ctp_sim_account (id, balance, available) VALUES (1, 100000, 100000)")
conn.commit()
_TABLES_READY = True
+370
View File
@@ -0,0 +1,370 @@
# Copyright (c) 2025-2026 马建军. All rights reserved.
# 专有软件 — 未经授权禁止复制、传播、转售。
# 严禁用于:带单/代客理财、向他人推荐期货品种或买卖建议、融资配资等业务。
# 详见 LICENSE.zh-CN.txt 与 docs/软件购买与使用协议.md
"""顺势加仓(滚仓):纯计算与校验,期货版(手数整数、乘数计入盈亏)。"""
from __future__ import annotations
import math
from typing import Any, Optional, Tuple
from modules.trading.position_sizing import MODE_AMOUNT
from strategy.fib_lib import calc_fib_plan
ROLL_MAX_LEGS_LONG = 3
ROLL_MAX_LEGS_SHORT = 3
ROLL_STOP_OFFSET_PCT_DEFAULT = 1.0
ADD_MODE_MARKET = "market"
ADD_MODE_FIB_618 = "fib_618"
ADD_MODE_FIB_786 = "fib_786"
ADD_MODE_BREAKOUT = "breakout"
FIB_MODES = frozenset({ADD_MODE_FIB_618, ADD_MODE_FIB_786})
PENDING_MODES = frozenset({ADD_MODE_FIB_618, ADD_MODE_FIB_786, ADD_MODE_BREAKOUT})
ADD_MODE_LABELS = {
ADD_MODE_MARKET: "市价加仓",
ADD_MODE_FIB_618: "斐波0.618",
ADD_MODE_FIB_786: "斐波0.786",
ADD_MODE_BREAKOUT: "突破加仓",
}
LEG_STATUS_PENDING = "pending"
LEG_STATUS_FILLED = "filled"
LEG_STATUS_CANCELLED = "cancelled"
LEG_STATUS_INVALIDATED = "invalidated"
def add_mode_label(mode: str) -> str:
return ADD_MODE_LABELS.get((mode or "").strip().lower(), mode or "")
def fib_ratio_from_mode(mode: str) -> Optional[float]:
m = (mode or "").strip().lower()
if m in (ADD_MODE_FIB_618, "618", "0.618"):
return 0.618
if m in (ADD_MODE_FIB_786, "786", "0.786"):
return 0.786
return None
def fib_limit_entry(direction: str, upper: float, lower: float, mode: str) -> Tuple[Optional[float], Optional[str]]:
ratio = fib_ratio_from_mode(mode)
if ratio is None:
return None, "斐波档位无效"
h, l = float(upper), float(lower)
if h <= l:
return None, "上沿须大于下沿"
direction = (direction or "long").strip().lower()
plan = calc_fib_plan(direction, h, l, ratio)
if not plan:
return None, "无法计算斐波限价"
entry, _sl, _tp = plan
return float(entry), None
def max_roll_legs(direction: str) -> int:
return ROLL_MAX_LEGS_LONG if (direction or "long").strip().lower() == "long" else ROLL_MAX_LEGS_SHORT
def lots_precise(raw: float) -> int:
if raw is None or raw < 1:
return 0
return max(1, int(math.floor(float(raw))))
def unified_stop_from_avg(direction: str, avg: float, offset_pct: float) -> float:
avg_f = float(avg)
pct = float(offset_pct) / 100.0
direction = (direction or "long").strip().lower()
if direction == "short":
return avg_f * (1.0 + pct)
return avg_f * (1.0 - pct)
def avg_entry_after_add(qty_existing: float, entry_existing: float, add_qty: float, add_price: float) -> float:
q1, e1, q2, e2 = float(qty_existing), float(entry_existing), float(add_qty), float(add_price)
total = q1 + q2
return (q1 * e1 + q2 * e2) / total if total > 0 else 0.0
def solve_add_lots_for_total_risk(
direction: str,
qty_existing: float,
entry_existing: float,
add_price: float,
new_stop: float,
risk_budget: float,
mult: int,
) -> Tuple[Optional[int], Optional[str]]:
"""方案 C:合并持仓打到新止损 S 时总亏损 ≤ B。"""
q1, e1, e2, sl, b = float(qty_existing), float(entry_existing), float(add_price), float(new_stop), float(risk_budget)
m = float(mult)
direction = (direction or "long").strip().lower()
if direction == "short":
denom = (sl - e2) * m
numer = b - q1 * (sl - e1) * m
else:
denom = (e2 - sl) * m
numer = b - q1 * (e1 - sl) * m
if denom <= 0:
return None, "止损与加仓价关系无效"
q2 = numer / denom
lots = lots_precise(q2)
if lots < 1:
return None, "已满足风险上限或无法再加"
return lots, None
def roll_eligibility_error(
*,
sizing_mode: str,
monitor: dict,
has_active_trend: bool,
legs_done: int = 0,
has_pending_leg: bool = False,
) -> Optional[str]:
if normalize_sizing_mode(sizing_mode) != MODE_AMOUNT:
return "仅固定金额(以损定仓)模式可滚仓"
if has_active_trend:
return "趋势回调运行中,不可滚仓"
if not monitor or (monitor.get("status") or "").strip().lower() != "active":
return "无有效持仓监控"
if int(monitor.get("trailing_be") or 0):
return "移动保本持仓不可滚仓"
direction = (monitor.get("direction") or "long").strip().lower()
if legs_done >= max_roll_legs(direction):
return f"滚仓已达 {max_roll_legs(direction)} 次上限"
if has_pending_leg:
return "已有监控中的加仓腿,请等待成交或删除后再提交"
if int(monitor.get("lots") or 0) < 1:
return "持仓手数为 0"
if not float(monitor.get("take_profit") or 0):
return "首仓须设置止盈(移动保本不可滚仓)"
return None
def normalize_sizing_mode(raw: str) -> str:
from modules.trading.position_sizing import normalize_sizing_mode as _norm
return _norm(raw)
def resolve_risk_percent(monitor: dict, *, default: float) -> float:
try:
rp = float(monitor.get("risk_percent") or 0)
if rp > 0:
return rp
except (TypeError, ValueError):
pass
return float(default)
def validate_roll_geometry(
direction: str,
add_mode: str,
new_stop: float,
*,
mark_price: float,
limit_price: Optional[float] = None,
breakthrough_price: Optional[float] = None,
at_trigger: bool = False,
off_session_pending: bool = False,
) -> Optional[str]:
"""几何校验。
做多斐波(回调):止损 < 触发价 < 当前价
做多突破(向上):止损 < 突破价 < 当前价
做空斐波(反弹):当前价 < 触发价 < 止损
做空突破(向下):突破价 < 当前价 < 止损(提交时);触发后当前价可 ≤ 突破价
"""
direction = (direction or "long").strip().lower()
mode = (add_mode or ADD_MODE_MARKET).strip().lower()
sl = float(new_stop)
mark = float(mark_price)
if sl <= 0 or mark <= 0:
return "止损或参考价无效"
if mode == ADD_MODE_MARKET:
if direction == "long" and sl >= mark:
return "做多:新止损须低于当前价"
if direction == "short" and sl <= mark:
return "做空:新止损须高于当前价"
return None
trigger = None
if mode in FIB_MODES:
trigger = float(limit_price or 0)
if trigger <= 0:
return "须填写斐波触发价"
if direction == "long":
if not (sl < trigger < mark):
return "做多斐波:须满足 止损 < 触发价 < 当前价"
else:
if not (mark < trigger < sl):
return "做空斐波:须满足 当前价 < 触发价 < 止损"
return None
if mode == ADD_MODE_BREAKOUT:
trigger = float(breakthrough_price or 0)
if trigger <= 0:
return "须填写突破价"
if off_session_pending:
if direction == "long" and not (sl < trigger):
return "做多突破:休盘提交须满足 止损 < 突破价"
if direction == "short" and not (trigger < sl):
return "做空突破:休盘提交须满足 突破价 < 止损"
return None
if at_trigger:
if direction == "long":
if not (sl < trigger <= mark):
return "做多突破:触发时须满足 止损 < 突破价 ≤ 当前价"
else:
if not (trigger < sl and mark < sl):
return "做空突破:触发时须满足 突破价 < 止损且当前价 < 止损"
return None
if direction == "long":
if not (sl < trigger < mark):
return "做多突破:须满足 止损 < 突破价 < 当前价"
else:
if not (trigger < mark < sl):
return "做空突破:须满足 突破价 < 当前价 < 止损"
return None
return "加仓方式无效"
def detect_mark_cross(
direction: str,
add_mode: str,
prev_mark: float,
mark: float,
trigger_price: float,
) -> bool:
"""标记价穿越触发价(上一 tick 与当前 tick 比较)。"""
direction = (direction or "long").strip().lower()
mode = (add_mode or "").strip().lower()
p = float(trigger_price)
prev_m = float(prev_mark)
cur_m = float(mark)
if p <= 0 or prev_m <= 0 or cur_m <= 0:
return False
if mode in FIB_MODES:
if direction == "long":
return prev_m > p and cur_m <= p
return prev_m < p and cur_m >= p
if mode == ADD_MODE_BREAKOUT:
if direction == "long":
return prev_m < p and cur_m >= p
return prev_m > p and cur_m <= p
return False
def preview_roll(
*,
direction: str,
symbol: str,
qty_existing: float,
entry_existing: float,
initial_take_profit: float,
add_mode: str,
new_stop_loss: float,
risk_budget: float,
mult: int,
mark_price: Optional[float] = None,
add_price: Optional[float] = None,
limit_price: Optional[float] = None,
breakthrough_price: Optional[float] = None,
fib_upper: Optional[float] = None,
fib_lower: Optional[float] = None,
legs_done: int = 0,
at_trigger: bool = False,
off_session_pending: bool = False,
) -> Tuple[Optional[dict[str, Any]], Optional[str]]:
direction = (direction or "long").strip().lower()
if legs_done >= max_roll_legs(direction):
return None, f"滚仓已达 {max_roll_legs(direction)} 次上限"
mode = (add_mode or ADD_MODE_MARKET).strip().lower()
mark = float(mark_price or add_price or 0)
if mark <= 0 and mode == ADD_MODE_BREAKOUT and off_session_pending:
mark = float(breakthrough_price or 0)
if mark <= 0:
return None, "需要有效参考价"
sl = float(new_stop_loss)
tp = float(initial_take_profit)
if sl <= 0 or tp <= 0:
return None, "止损/止盈无效"
entry_add = mark
mode_label = add_mode_label(mode)
trigger_price = mark
is_pending = mode in PENDING_MODES
if mode == ADD_MODE_MARKET:
entry_add = mark
elif mode in FIB_MODES:
if limit_price and float(limit_price) > 0:
entry_add = float(limit_price)
trigger_price = entry_add
elif fib_upper is not None and fib_lower is not None:
entry_add, err = fib_limit_entry(direction, float(fib_upper), float(fib_lower), mode)
if err:
return None, err
trigger_price = entry_add
else:
return None, "斐波须填触发价或上沿/下沿"
elif mode == ADD_MODE_BREAKOUT:
if not breakthrough_price or float(breakthrough_price) <= 0:
return None, "须填写突破价"
entry_add = float(breakthrough_price)
trigger_price = entry_add
else:
return None, "加仓方式无效"
geom_err = validate_roll_geometry(
direction, mode, sl,
mark_price=mark,
limit_price=trigger_price if mode in FIB_MODES else None,
breakthrough_price=trigger_price if mode == ADD_MODE_BREAKOUT else None,
at_trigger=at_trigger,
off_session_pending=off_session_pending and is_pending,
)
if geom_err:
return None, geom_err
budget = float(risk_budget)
if budget <= 0:
return None, "固定金额无效"
q2, err = solve_add_lots_for_total_risk(
direction, qty_existing, entry_existing, entry_add, sl, budget, mult,
)
if err:
return None, err
new_qty = qty_existing + q2
new_avg = avg_entry_after_add(qty_existing, entry_existing, q2, entry_add)
m = float(mult)
if direction == "long":
loss_at_sl = (new_avg - sl) * new_qty * m
reward_at_tp = (tp - new_avg) * new_qty * m
else:
loss_at_sl = (sl - new_avg) * new_qty * m
reward_at_tp = (new_avg - tp) * new_qty * m
return {
"symbol": symbol,
"direction": direction,
"add_mode": mode,
"add_mode_label": mode_label,
"is_pending": is_pending,
"add_price": round(entry_add, 4),
"trigger_price": round(trigger_price, 4),
"limit_price": round(trigger_price, 4) if mode in FIB_MODES else None,
"breakthrough_price": round(trigger_price, 4) if mode == ADD_MODE_BREAKOUT else None,
"new_stop_loss": round(sl, 4),
"initial_take_profit": tp,
"risk_budget": round(budget, 2),
"fixed_amount": round(budget, 2),
"add_lots": q2,
"qty_after": int(new_qty),
"avg_entry_after": round(new_avg, 4),
"loss_at_sl": round(loss_at_sl, 2),
"reward_at_tp": round(reward_at_tp, 2),
"legs_done": legs_done,
"mark_price": round(mark, 4),
}, None
@@ -0,0 +1,158 @@
# Copyright (c) 2025-2026 马建军. All rights reserved.
# 专有软件 — 未经授权禁止复制、传播、转售。
# 严禁用于:带单/代客理财、向他人推荐期货品种或买卖建议、融资配资等业务。
# 详见 LICENSE.zh-CN.txt 与 docs/软件购买与使用协议.md
"""顺势滚仓程序监控:突破 pending 腿触价成交、外部平仓同步。"""
from __future__ import annotations
import logging
from datetime import datetime
from typing import Any, Callable, Optional
from zoneinfo import ZoneInfo
from modules.core.contract_specs import get_contract_spec
from strategy.strategy_roll_lib import (
ADD_MODE_BREAKOUT,
FIB_MODES,
LEG_STATUS_CANCELLED,
LEG_STATUS_FILLED,
LEG_STATUS_INVALIDATED,
LEG_STATUS_PENDING,
detect_mark_cross,
preview_roll,
)
logger = logging.getLogger(__name__)
TZ = ZoneInfo("Asia/Shanghai")
def _now() -> str:
return datetime.now(TZ).strftime("%Y-%m-%d %H:%M:%S")
def roll_sync_after_external_close(conn, *, monitor_id: int) -> None:
"""手动平仓或监控结案后关闭滚仓组并清除 pending 腿。"""
grp = conn.execute(
"SELECT id FROM roll_groups WHERE order_monitor_id=? AND status='active'",
(int(monitor_id),),
).fetchone()
if not grp:
return
gid = int(grp["id"])
conn.execute(
"UPDATE roll_legs SET status=? WHERE roll_group_id=? AND status=?",
(LEG_STATUS_CANCELLED, gid, LEG_STATUS_PENDING),
)
conn.execute(
"UPDATE roll_groups SET status='closed', updated_at=? WHERE id=?",
(_now(), gid),
)
def cancel_roll_leg(conn, leg_id: int) -> tuple[bool, str]:
row = conn.execute(
"SELECT * FROM roll_legs WHERE id=? AND status=?",
(int(leg_id), LEG_STATUS_PENDING),
).fetchone()
if not row:
return False, "仅可删除监控中的腿"
conn.execute(
"UPDATE roll_legs SET status=? WHERE id=?",
(LEG_STATUS_CANCELLED, int(leg_id)),
)
return True, "已删除"
def check_roll_monitors(
conn,
*,
get_mark_price_fn: Callable[[str], Optional[float]],
fill_roll_leg_fn: Callable[[dict, dict, dict, dict], tuple[bool, str]],
is_trading_session_fn: Callable[[], bool],
get_risk_budget_fn: Callable[[], float],
get_entry_price_fn: Optional[Callable[[str, str, float], float]] = None,
) -> None:
"""扫描 pending 滚仓腿,标记价穿越则重算手数并市价成交。"""
if not is_trading_session_fn():
return
rows = conn.execute(
"""SELECT l.*, g.order_monitor_id, g.symbol, g.direction, g.initial_take_profit,
g.risk_percent, g.leg_count AS group_leg_count,
m.lots AS mon_lots, m.entry_price AS mon_entry, m.take_profit AS mon_tp,
m.status AS mon_status
FROM roll_legs l
JOIN roll_groups g ON g.id = l.roll_group_id
JOIN trade_order_monitors m ON m.id = g.order_monitor_id
WHERE l.status=? AND g.status='active' AND m.status='active'""",
(LEG_STATUS_PENDING,),
).fetchall()
for raw in rows:
leg = dict(raw)
if (leg.get("mon_status") or "").strip().lower() != "active":
_invalidate_leg(conn, leg, "监控已结束")
continue
sym = (leg.get("symbol") or "").strip()
mark = get_mark_price_fn(sym)
if not mark or mark <= 0:
continue
prev_mark = float(leg.get("last_mark_price") or mark)
mode = (leg.get("add_mode") or "").strip().lower()
trigger = float(leg.get("limit_price") or leg.get("breakthrough_price") or 0)
direction = (leg.get("direction") or "long").strip().lower()
if mode in FIB_MODES or mode == ADD_MODE_BREAKOUT:
if not detect_mark_cross(direction, mode, prev_mark, mark, trigger):
conn.execute(
"UPDATE roll_legs SET last_mark_price=? WHERE id=?",
(float(mark), int(leg["id"])),
)
continue
mon = {
"id": leg["order_monitor_id"],
"symbol": sym,
"direction": direction,
"lots": leg["mon_lots"],
"entry_price": leg["mon_entry"],
"take_profit": leg["mon_tp"] or leg["initial_take_profit"],
}
entry_fb = float(leg["mon_entry"] or 0)
entry_existing = (
get_entry_price_fn(sym, direction, entry_fb)
if get_entry_price_fn
else entry_fb
)
grp = {
"id": leg["roll_group_id"],
"order_monitor_id": leg["order_monitor_id"],
"leg_count": leg.get("group_leg_count") or 0,
"risk_percent": leg.get("risk_percent"),
}
preview, err = preview_roll(
direction=direction,
symbol=sym,
qty_existing=float(leg["mon_lots"] or 0),
entry_existing=entry_existing,
initial_take_profit=float(leg["mon_tp"] or leg["initial_take_profit"] or 0),
add_mode=mode,
new_stop_loss=float(leg["new_stop_loss"] or 0),
risk_budget=float(leg.get("risk_percent") or 0) or get_risk_budget_fn(),
mult=int(get_contract_spec(sym).get("mult") or 1),
mark_price=mark,
limit_price=trigger if mode in FIB_MODES else None,
breakthrough_price=trigger if mode == ADD_MODE_BREAKOUT else None,
legs_done=int(leg.get("group_leg_count") or 0),
at_trigger=True,
)
if err or not preview:
_invalidate_leg(conn, leg, err or "触发时无法加仓")
continue
ok, msg = fill_roll_leg_fn(mon, grp, leg, preview)
if not ok:
logger.warning("roll leg fill failed #%s: %s", leg.get("id"), msg)
def _invalidate_leg(conn, leg: dict, reason: str) -> None:
conn.execute(
"UPDATE roll_legs SET status=?, invalidated_reason=? WHERE id=?",
(LEG_STATUS_INVALIDATED, (reason or "")[:200], int(leg["id"])),
)
+75
View File
@@ -0,0 +1,75 @@
# Copyright (c) 2025-2026 马建军. All rights reserved.
# 专有软件 — 未经授权禁止复制、传播、转售。
# 严禁用于:带单/代客理财、向他人推荐期货品种或买卖建议、融资配资等业务。
# 详见 LICENSE.zh-CN.txt 与 docs/软件购买与使用协议.md
"""策略结束快照。"""
from __future__ import annotations
import json
from datetime import datetime
from typing import Any
STRATEGY_TREND = "trend_pullback"
STRATEGY_ROLL = "roll"
MAX_ROWS = 100
def save_snapshot(
conn,
*,
strategy_type: str,
source_id: int,
symbol: str,
direction: str,
result_label: str,
payload: dict,
pnl: float | None = None,
opened_at: str = "",
) -> None:
now = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
conn.execute(
"""INSERT INTO strategy_trade_snapshots (
strategy_type, source_id, symbol, direction, result_label,
opened_at, closed_at, pnl_amount, snapshot_json, created_at
) VALUES (?,?,?,?,?,?,?,?,?,?)""",
(
strategy_type,
source_id,
symbol,
direction,
result_label,
opened_at,
now,
pnl,
json.dumps(payload, ensure_ascii=False),
now,
),
)
conn.execute(
"""DELETE FROM strategy_trade_snapshots WHERE id NOT IN (
SELECT id FROM strategy_trade_snapshots ORDER BY id DESC LIMIT ?
)""",
(MAX_ROWS,),
)
def list_snapshots(conn, limit: int = 100) -> tuple[list[dict], list[dict]]:
rows = conn.execute(
"SELECT * FROM strategy_trade_snapshots ORDER BY id DESC LIMIT ?",
(max(1, min(limit, 200)),),
).fetchall()
trend, roll = [], []
for r in rows:
d = dict(r)
try:
d["snapshot"] = json.loads(d.get("snapshot_json") or "{}")
except Exception:
d["snapshot"] = {}
st = d.get("strategy_type")
d["strategy_label"] = "趋势回调" if st == STRATEGY_TREND else "顺势加仓"
if st == STRATEGY_TREND:
trend.append(d)
else:
roll.append(d)
return trend, roll
+233
View File
@@ -0,0 +1,233 @@
# Copyright (c) 2025-2026 马建军. All rights reserved.
# 专有软件 — 未经授权禁止复制、传播、转售。
# 严禁用于:带单/代客理财、向他人推荐期货品种或买卖建议、融资配资等业务。
# 详见 LICENSE.zh-CN.txt 与 docs/软件购买与使用协议.md
"""趋势回调:纯计算(期货整数手)。"""
from __future__ import annotations
import json
import math
from typing import Any, Optional, Tuple
from modules.core.contract_specs import get_contract_spec
def validate_trend_bounds(direction: str, stop_loss: float, add_upper: float) -> Optional[str]:
direction = (direction or "long").strip().lower()
if direction == "long":
if not (float(stop_loss) < float(add_upper)):
return "做多:止损须低于补仓上沿"
else:
if not (float(stop_loss) > float(add_upper)):
return "做空:止损须高于补仓下沿"
return None
def build_grid_prices(direction: str, sl: float, upper: float, n_legs: int) -> list[float]:
sl, upper = float(sl), float(upper)
out: list[float] = []
if n_legs <= 0:
return out
direction = (direction or "long").strip().lower()
if direction == "long":
if upper <= sl:
return out
span = upper - sl
for i in range(1, n_legs + 1):
out.append(sl + (i / float(n_legs + 1)) * span)
out.sort(reverse=True)
else:
if sl <= upper:
return out
span = sl - upper
for i in range(1, n_legs + 1):
out.append(upper + (i / float(n_legs + 1)) * span)
out.sort()
return [round(p, 4) for p in out]
def compute_trend_plan_futures(
*,
direction: str,
stop_loss: float,
add_upper: float,
take_profit: float,
risk_percent: float,
capital: float,
live_price: float,
ths_code: str,
dca_legs: int = 5,
) -> Tuple[Optional[dict[str, Any]], Optional[str]]:
err = validate_trend_bounds(direction, stop_loss, add_upper)
if err:
return None, err
spec = get_contract_spec(ths_code)
mult = spec["mult"]
d = (direction or "long").strip().lower()
if d == "short":
worst_per_lot = (float(stop_loss) - float(add_upper)) * mult
else:
worst_per_lot = (float(add_upper) - float(stop_loss)) * mult
if worst_per_lot <= 0:
return None, "止损与补仓边界无法计算风险"
budget = float(capital) * float(risk_percent) / 100.0
total_lots = int(math.floor(budget / worst_per_lot))
if total_lots < 3:
return None, f"{risk_percent}% 风险,总手数至少需 3 手才能拆分首仓+补仓(当前 {total_lots} 手)"
first_lots = total_lots // 2
remainder = total_lots - first_lots
legs = max(1, min(int(dca_legs), remainder))
per_leg = remainder // legs
leg_amounts = [per_leg] * (legs - 1) + [remainder - per_leg * (legs - 1)]
if any(x < 1 for x in leg_amounts):
legs = 1
leg_amounts = [remainder]
grid = build_grid_prices(d, stop_loss, add_upper, len(leg_amounts))
margin_rate = spec["margin_rate"]
plan_margin = float(live_price) * mult * total_lots * margin_rate
return {
"direction": d,
"stop_loss": float(stop_loss),
"add_upper": float(add_upper),
"take_profit": float(take_profit),
"risk_percent": float(risk_percent),
"capital_snapshot": float(capital),
"live_price_ref": float(live_price),
"target_lots": total_lots,
"first_lots": first_lots,
"remainder_lots": remainder,
"dca_legs": len(leg_amounts),
"leg_amounts": leg_amounts,
"leg_amounts_json": json.dumps(leg_amounts),
"grid_prices_json": json.dumps(grid),
"grid": grid,
"plan_margin": round(plan_margin, 2),
"mult": mult,
}, None
def trend_dca_level_reached(direction: str, mark_price: float, level: float) -> bool:
d = (direction or "long").strip().lower()
pf, lv = float(mark_price), float(level)
return pf <= lv if d == "long" else pf >= lv
def trend_strategy_periods() -> list[dict[str, str]]:
"""策略页可选 K 线周期。"""
from modules.market.kline_chart import MARKET_PERIODS
skip = frozenset({"timeshare", "w"})
return [p for p in MARKET_PERIODS if p["key"] not in skip]
def trend_period_label(key: str) -> str:
k = (key or "").strip()
for p in trend_strategy_periods():
if p["key"] == k:
return p["label"]
return k or "15分"
def normalize_trend_period(key: str) -> str:
valid = {p["key"] for p in trend_strategy_periods()}
k = (key or "15m").strip()
return k if k in valid else "15m"
def _avg_after_entries(entries: list[tuple[float, int]]) -> float:
total = sum(q for _, q in entries)
if total <= 0:
return 0.0
return sum(p * q for p, q in entries) / total
def enrich_trend_plan_preview(
plan: dict,
*,
symbol: str,
symbol_name: str = "",
period: str = "15m",
) -> dict[str, Any]:
"""补全预览:周期、风险金额、分档表格(对齐币圈预览样式)。"""
out = dict(plan)
d = (out.get("direction") or "long").strip().lower()
sl = float(out["stop_loss"])
tp = float(out["take_profit"])
mult = float(out.get("mult") or 1)
entry0 = float(out["live_price_ref"])
first_lots = int(out["first_lots"])
leg_amounts = [int(x) for x in (out.get("leg_amounts") or [])]
grid = [float(x) for x in (out.get("grid") or [])]
capital = float(out.get("capital_snapshot") or 0)
risk_pct = float(out.get("risk_percent") or 0)
budget = capital * risk_pct / 100.0
remainder = int(out.get("remainder_lots") or sum(leg_amounts))
out["symbol"] = symbol
out["symbol_name"] = symbol_name or symbol
out["period"] = normalize_trend_period(period)
out["period_label"] = trend_period_label(out["period"])
out["stop_loss_budget"] = round(budget, 2)
out["direction_label"] = "做多" if d == "long" else "做空"
entries: list[tuple[float, int]] = [(entry0, first_lots)]
rows: list[dict[str, Any]] = []
def leg_metrics() -> tuple[float, float, float, Optional[float]]:
total = sum(q for _, q in entries)
avg = _avg_after_entries(entries)
if d == "long":
profit = (tp - avg) * total * mult
loss = (avg - sl) * total * mult
else:
profit = (avg - tp) * total * mult
loss = (sl - avg) * total * mult
rr = profit / loss if loss > 0 else None
return (
round(avg, 4),
round(profit, 2),
round(loss, 2),
round(rr, 2) if rr is not None else None,
)
avg, profit, loss, rr = leg_metrics()
rows.append({
"level": "首仓",
"price": round(entry0, 4),
"lots": first_lots,
"avg_after": avg,
"profit_at_tp": profit,
"loss_at_sl": loss,
"rr_ratio": rr,
})
out["first_rr_ratio"] = rr
for i, lots in enumerate(leg_amounts):
price = grid[i] if i < len(grid) else sl
entries.append((float(price), int(lots)))
avg, profit, loss, rr = leg_metrics()
rows.append({
"level": f"补仓{i + 1}",
"price": round(float(price), 4),
"lots": int(lots),
"avg_after": avg,
"profit_at_tp": profit,
"loss_at_sl": loss,
"rr_ratio": rr,
})
out["preview_rows"] = rows
out["summary_line"] = (
f"{out['symbol_name']} {out['symbol']} {out['direction_label']} {out['period_label']}"
f" | 权益 {capital:.2f}"
f" | 参考价 {entry0}"
f" | 计划保证金 ≈ {out.get('plan_margin')}"
f" | 总手 {out.get('target_lots')}(首仓 {first_lots} + 补仓 {remainder}"
)
out["detail_line"] = (
f"止损价 {sl} | 止损金额 {out['stop_loss_budget']} 元(权益 × 风险 {risk_pct}%"
f" | 补仓边界 {float(out['add_upper'])} | 止盈价 {tp}"
f" | 首仓盈亏比 {out['first_rr_ratio'] if out['first_rr_ratio'] is not None else ''}"
)
return out