接入 SimNow 模拟盘与期货下单、策略及品种推荐功能。
新增 vnpy CTP 桥接、以损定仓/固定张数、趋势回调与滚仓策略、按资金推荐品种及交易风控;模拟盘走 SimNow,实盘预留期货公司配置。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,18 @@
|
||||
"""斐波计算(自 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
|
||||
@@ -0,0 +1,131 @@
|
||||
"""策略相关表结构。"""
|
||||
from __future__ import annotations
|
||||
|
||||
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
|
||||
)
|
||||
"""
|
||||
|
||||
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)
|
||||
)
|
||||
"""
|
||||
|
||||
|
||||
def init_strategy_tables(conn) -> None:
|
||||
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)
|
||||
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)")
|
||||
@@ -0,0 +1,159 @@
|
||||
"""顺势加仓(滚仓):纯计算,期货版(手数整数、乘数计入盈亏)。"""
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from typing import Any, Optional, Tuple
|
||||
|
||||
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
|
||||
FIB_MODES = frozenset({"fib_618", "fib_786"})
|
||||
|
||||
|
||||
def fib_ratio_from_mode(mode: str) -> Optional[float]:
|
||||
m = (mode or "").strip().lower()
|
||||
if m in ("fib_618", "618", "0.618"):
|
||||
return 0.618
|
||||
if m in ("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]]:
|
||||
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 preview_roll(
|
||||
*,
|
||||
direction: str,
|
||||
symbol: str,
|
||||
qty_existing: float,
|
||||
entry_existing: float,
|
||||
initial_take_profit: float,
|
||||
add_mode: str,
|
||||
new_stop_loss: float,
|
||||
risk_percent: float,
|
||||
capital_base: float,
|
||||
mult: int,
|
||||
add_price: Optional[float] = None,
|
||||
fib_upper: Optional[float] = None,
|
||||
fib_lower: Optional[float] = None,
|
||||
legs_done: int = 0,
|
||||
) -> 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 "market").strip().lower()
|
||||
if mode == "market":
|
||||
if not add_price or add_price <= 0:
|
||||
return None, "需要有效参考价"
|
||||
entry_add = float(add_price)
|
||||
mode_label = "市价"
|
||||
elif mode in FIB_MODES:
|
||||
if fib_upper is None or fib_lower is None:
|
||||
return None, "斐波须填上沿/下沿"
|
||||
entry_add, err = fib_limit_entry(direction, float(fib_upper), float(fib_lower), mode)
|
||||
if err:
|
||||
return None, err
|
||||
mode_label = "斐波0.618" if "618" in mode else "斐波0.786"
|
||||
else:
|
||||
return None, "加仓方式无效"
|
||||
sl = float(new_stop_loss)
|
||||
tp = float(initial_take_profit)
|
||||
if sl <= 0 or tp <= 0:
|
||||
return None, "止损/止盈无效"
|
||||
risk_budget = float(capital_base) * float(risk_percent) / 100.0
|
||||
q2, err = solve_add_lots_for_total_risk(
|
||||
direction, qty_existing, entry_existing, entry_add, sl, risk_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_label": mode_label,
|
||||
"add_price": round(entry_add, 4),
|
||||
"new_stop_loss": round(sl, 4),
|
||||
"initial_take_profit": tp,
|
||||
"risk_percent": float(risk_percent),
|
||||
"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,
|
||||
}, None
|
||||
@@ -0,0 +1,70 @@
|
||||
"""策略结束快照。"""
|
||||
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
|
||||
@@ -0,0 +1,108 @@
|
||||
"""趋势回调:纯计算(期货整数手)。"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import math
|
||||
from typing import Any, Optional, Tuple
|
||||
|
||||
from 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
|
||||
Reference in New Issue
Block a user