接入 SimNow 模拟盘与期货下单、策略及品种推荐功能。

新增 vnpy CTP 桥接、以损定仓/固定张数、趋势回调与滚仓策略、按资金推荐品种及交易风控;模拟盘走 SimNow,实盘预留期货公司配置。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
dekun
2026-06-24 10:04:37 +08:00
parent 9c0e5d9c57
commit 6e423eebfb
30 changed files with 2789 additions and 60 deletions
+33 -6
View File
@@ -3,19 +3,46 @@ HOST=0.0.0.0
PORT=6600
DEBUG=false
# Flask Session 密钥(部署时务必改为随机字符串,deploy.sh 首次会自动生成)
SECRET_KEY=change-this-to-a-random-secret-key
# 初始管理员(首次建库自动写入;已建库后修改需设 ADMIN_SYNC_FROM_ENV=true 并重启)
ADMIN_USERNAME=admin
ADMIN_PASSWORD=change-me-on-first-login
ADMIN_SYNC_FROM_ENV=false
# 企业微信 Webhook(也可在系统设置页面修改)
WECHAT_WEBHOOK=
# 行情数据源: sina(默认,免费)| auto(有机构 token 时优先同花顺)| ths
QUOTE_SOURCE=sina
# 同花顺 iFinD refresh_token(仅机构用户,普通用户留空即可)
THS_REFRESH_TOKEN=
# 交易模式:simulation=SimNowlive=期货公司(系统设置页可改)
TRADING_MODE=simulation
POSITION_SIZING_MODE=risk
RISK_PERCENT=1
# —— SimNow 模拟盘(在 simnow.com 注册后填写)——
SIMNOW_USER=
SIMNOW_PASSWORD=
SIMNOW_BROKER_ID=9999
# 7×24 环境示例(以 SimNow 官网最新为准)
SIMNOW_TD_ADDRESS=tcp://180.168.146.187:10201
SIMNOW_MD_ADDRESS=tcp://180.168.146.187:10211
SIMNOW_APP_ID=simnow_client_test
SIMNOW_AUTH_CODE=0000000000000000
SIMNOW_PRODUCT_INFO=simnow_client_test
# —— 期货公司实盘(后期接入)——
CTP_LIVE_USER=
CTP_LIVE_PASSWORD=
CTP_LIVE_BROKER_ID=
CTP_LIVE_TD_ADDRESS=
CTP_LIVE_MD_ADDRESS=
CTP_LIVE_APP_ID=
CTP_LIVE_AUTH_CODE=
CTP_LIVE_PRODUCT_INFO=
# 账户冷静期
RISK_CONTROL_ENABLED=true
RISK_COOLING_HOURS_MANUAL=4
RISK_COOLING_HOURS_MANUAL_JOURNAL=1
RISK_MANUAL_CLOSE_DAILY_LIMIT=2
MAX_ACTIVE_POSITIONS=1
+56 -3
View File
@@ -34,6 +34,9 @@ from kline_store import ensure_kline_tables
from kline_stream import kline_hub, sse_format
from kline_chart import generate_review_kline_chart, fetch_market_klines, MARKET_PERIODS
from market import get_price as market_get_price, set_ths_refresh_token, get_quote_source_label
from strategy.strategy_db import init_strategy_tables
from install_trading import install_trading
from vnpy_bridge import try_init_vnpy
load_dotenv(os.path.join(os.path.dirname(os.path.abspath(__file__)), ".env"))
@@ -306,6 +309,7 @@ def init_db():
data_json TEXT NOT NULL,
updated_at TEXT NOT NULL)''')
ensure_kline_tables(conn)
init_strategy_tables(conn)
conn.commit()
conn.close()
@@ -322,6 +326,12 @@ def init_db():
if not get_setting("fee_multiplier"):
set_setting("fee_multiplier", "2")
if not get_setting("trading_mode"):
set_setting("trading_mode", "simulation")
if not get_setting("position_sizing_mode"):
set_setting("position_sizing_mode", "risk")
if not get_setting("risk_percent"):
set_setting("risk_percent", "1")
conn = get_db()
fee_cnt = conn.execute("SELECT COUNT(*) FROM fee_rates").fetchone()[0]
conn.close()
@@ -569,6 +579,9 @@ def background_task():
expire_old_plans()
check_key_monitors()
check_order_plans()
fn = getattr(app, "_check_trend_plans", None)
if fn:
fn(app)
except Exception:
pass
time.sleep(3)
@@ -583,8 +596,6 @@ def start_background_threads():
threading.Thread(target=refresh_main_index, daemon=True).start()
start_background_threads()
# —————————————— 登录 ——————————————
def login_required(f):
@@ -1278,6 +1289,14 @@ def add_review():
d.get("notes", "").strip(),
),
)
hook = getattr(app, "_risk_review_hook", None)
if hook:
hook(
conn,
",".join(tags),
exit_trigger,
d.get("exit_supplement", "").strip(),
)
conn.commit()
conn.close()
touch_stats_cache()
@@ -1535,9 +1554,25 @@ def settings():
flash("实盘资金不能为负数")
else:
set_setting("live_capital", str(val))
flash("实盘资金已保存")
flash("参考资金已保存CTP 已连接时以 SimNow/柜台权益为准)")
except ValueError:
flash("请输入有效的实盘资金金额")
elif action == "trading":
mode = request.form.get("trading_mode", "simulation").strip()
if mode not in ("simulation", "live"):
mode = "simulation"
sizing = request.form.get("position_sizing_mode", "risk").strip()
if sizing not in ("fixed", "risk"):
sizing = "risk"
set_setting("trading_mode", mode)
set_setting("position_sizing_mode", sizing)
try:
rp = float(request.form.get("risk_percent", "1") or 1)
set_setting("risk_percent", str(max(0.1, min(100.0, rp))))
except ValueError:
flash("风险比例无效")
return redirect(url_for("settings"))
flash("交易模式已保存")
elif action == "password":
old_p = request.form.get("old_password", "")
new_p = request.form.get("new_password", "")
@@ -1563,8 +1598,26 @@ def settings():
username=username,
live_capital=live_capital,
quote_label=get_quote_source_label(),
trading_mode=get_setting("trading_mode", "simulation"),
position_sizing_mode=get_setting("position_sizing_mode", "risk"),
risk_percent=get_setting("risk_percent", "1"),
)
install_trading(
app,
login_required=login_required,
get_db=get_db,
get_setting=get_setting,
set_setting=set_setting,
fetch_price=fetch_price,
send_wechat_msg=send_wechat_msg,
)
try_init_vnpy({})
start_background_threads()
# —————————————— 启动 ——————————————
if __name__ == "__main__":
+17 -10
View File
@@ -2,13 +2,13 @@
import re
from typing import Optional
DEFAULT_SPEC = {"mult": 10, "margin_rate": 0.10}
DEFAULT_SPEC = {"mult": 10, "margin_rate": 0.10, "tick_size": 1.0}
# 参考交易所常见规格(乘数 + 保证金比例估算
# 参考交易所常见规格(乘数 + 保证金比例 + 最小变动价位
_SPEC_BY_THS: dict[str, dict] = {
"ag": {"mult": 15, "margin_rate": 0.14},
"au": {"mult": 1000, "margin_rate": 0.10},
"cu": {"mult": 5, "margin_rate": 0.10},
"ag": {"mult": 15, "margin_rate": 0.14, "tick_size": 1.0},
"au": {"mult": 1000, "margin_rate": 0.10, "tick_size": 0.02},
"cu": {"mult": 5, "margin_rate": 0.10, "tick_size": 10.0},
"al": {"mult": 5, "margin_rate": 0.10},
"zn": {"mult": 5, "margin_rate": 0.10},
"pb": {"mult": 5, "margin_rate": 0.10},
@@ -52,10 +52,14 @@ _SPEC_BY_THS: dict[str, dict] = {
"AP": {"mult": 10, "margin_rate": 0.10},
"CJ": {"mult": 5, "margin_rate": 0.10},
"PK": {"mult": 5, "margin_rate": 0.10},
"IF": {"mult": 300, "margin_rate": 0.12},
"IH": {"mult": 300, "margin_rate": 0.12},
"IC": {"mult": 200, "margin_rate": 0.12},
"IM": {"mult": 200, "margin_rate": 0.12},
"IF": {"mult": 300, "margin_rate": 0.12, "tick_size": 0.2},
"IH": {"mult": 300, "margin_rate": 0.12, "tick_size": 0.2},
"IC": {"mult": 200, "margin_rate": 0.12, "tick_size": 0.2},
"IM": {"mult": 200, "margin_rate": 0.12, "tick_size": 0.2},
}
_TICK_OVERRIDES: dict[str, float] = {
"sc": 0.1, "TA": 2.0, "CF": 5.0, "SF": 2.0, "SM": 2.0,
}
@@ -67,7 +71,10 @@ def get_contract_spec(ths_code: str) -> dict:
letters = m.group(1)
spec = _SPEC_BY_THS.get(letters) or _SPEC_BY_THS.get(letters.upper()) or _SPEC_BY_THS.get(letters.lower())
if spec:
return {"mult": spec["mult"], "margin_rate": spec["margin_rate"]}
tick = spec.get("tick_size")
if tick is None:
tick = _TICK_OVERRIDES.get(letters) or _TICK_OVERRIDES.get(letters.upper()) or 1.0
return {"mult": spec["mult"], "margin_rate": spec["margin_rate"], "tick_size": float(tick)}
return dict(DEFAULT_SPEC)
+57
View File
@@ -0,0 +1,57 @@
"""同花顺合约代码 → vnpy Symbol + Exchange。"""
from __future__ import annotations
import re
from typing import Optional, Tuple
from symbols import ths_to_codes
try:
from vnpy.trader.constant import Exchange
except ImportError:
Exchange = None # type: ignore
_EX_MAP = {
"SHFE": "SHFE",
"DCE": "DCE",
"CZCE": "CZCE",
"CFFEX": "CFFEX",
"INE": "INE",
}
def ths_to_vnpy_symbol(ths_code: str) -> Tuple[str, str]:
"""
返回 (symbol, exchange_enum_name)。
例:rb2610 → rb2610, SHFESR609 → SR609, CZCE
"""
code = (ths_code or "").strip()
codes = ths_to_codes(code)
ex = (codes.get("ex") if codes else None) or "SHFE"
ex = _EX_MAP.get(ex, "SHFE")
m = re.match(r"^([A-Za-z]+)(\d+)$", code)
if not m:
return code, ex
letters, digits = m.group(1), m.group(2)
if ex == "CZCE":
# 郑商所 CTP 常为大写 + 3 位年月(如 SR509);4 位则取后 3 位
sym = letters.upper() + (digits[-3:] if len(digits) >= 3 else digits)
else:
sym = letters.lower() + digits
return sym, ex
def to_vnpy_exchange(ex_name: str):
if Exchange is None:
raise ImportError("vnpy 未安装")
mapping = {
"SHFE": Exchange.SHFE,
"DCE": Exchange.DCE,
"CZCE": Exchange.CZCE,
"CFFEX": Exchange.CFFEX,
"INE": Exchange.INE,
}
ex = mapping.get((ex_name or "").upper())
if ex is None:
raise ValueError(f"未知交易所: {ex_name}")
return ex
+40
View File
@@ -0,0 +1,40 @@
# 期货下单与策略交易
## 两种交易通道
| 设置 | 实际连接 | 资金 |
|------|----------|------|
| **模拟盘** | **SimNow**vnpy → CTP 仿真前置) | SimNow 账户权益 |
| **实盘** | **期货公司 CTP**(后期配置 `CTP_LIVE_*` | 柜台权益 |
已移除「本地 SQLite 假撮合」;模拟盘与实盘均走 **vnpy_ctp**,仅 `.env` 前置与账号不同。
## 首次使用 SimNow
1. 在 [SimNow](https://www.simnow.com.cn/) 注册仿真账号
2. 复制 `.env.example``.env`,填写 `SIMNOW_USER``SIMNOW_PASSWORD`
3. 核对 SimNow 官网最新的 **7×24 或交易时段** 前置地址
4. `pip install vnpy vnpy_ctp`
5. 启动程序 → **期货下单** → 点击 **连接 CTP**
6. 连接成功后,权益、持仓、下单均来自 SimNow
## 参考资金
系统设置中的「参考资金」仅在 **CTP 未连接** 时用于品种推荐与以损定仓估算;连接 SimNow 后自动改用柜台权益。
## 导航
| 页面 | 路径 |
|------|------|
| 品种推荐 | `/recommend` |
| 期货下单 | `/trade` |
| 策略交易 | `/strategy` |
| 策略记录 | `/strategy/records` |
## API
| 接口 | 说明 |
|------|------|
| `POST /api/ctp/connect` | 按当前模式连接 SimNow 或实盘 CTP |
| `GET /api/ctp/status` | 连接状态与缺失配置项 |
| `POST /api/trade/order` | 限价报单(需已连接 CTP) |
+690
View File
@@ -0,0 +1,690 @@
"""期货下单、品种推荐、策略交易路由注册。"""
from __future__ import annotations
import json
from datetime import datetime
from typing import Any, Callable
from flask import flash, jsonify, redirect, render_template, request, url_for
from contract_specs import calc_position_metrics, get_contract_spec
from position_sizing import (
MODE_FIXED,
MODE_RISK,
calc_lots_by_risk,
calc_order_tick_metrics,
normalize_sizing_mode,
)
from product_recommend import list_product_recommendations
from risk.account_risk_lib import (
assert_can_open,
get_risk_status,
on_mood_journal_freeze,
on_user_initiated_close,
parse_mood_issues,
reduce_cooloff_after_journal,
trading_day_label,
)
from strategy.strategy_db import init_strategy_tables
from strategy.strategy_roll_lib import preview_roll
from strategy.strategy_snapshot_lib import list_snapshots, save_snapshot
from strategy.strategy_trend_lib import compute_trend_plan_futures, trend_dca_level_reached
from strategy.strategy_snapshot_lib import STRATEGY_ROLL, STRATEGY_TREND
from symbols import ths_to_codes, resolve_main_contract, PRODUCTS
from trading_context import (
TRADING_MODE_LIVE,
TRADING_MODE_SIM,
get_account_capital,
get_risk_percent,
get_sizing_mode,
get_trading_mode,
trading_mode_label,
)
from ctp_symbol import ths_to_vnpy_symbol
from vnpy_bridge import (
ctp_connect,
ctp_get_account,
ctp_list_positions,
ctp_status,
execute_order,
)
def install_trading(app, *, login_required, get_db, get_setting, set_setting, fetch_price, send_wechat_msg):
"""注册交易相关路由。"""
def _settings_dict() -> dict:
return {
"trading_mode": get_trading_mode(get_setting),
"position_sizing_mode": get_sizing_mode(get_setting),
"risk_percent": str(get_risk_percent(get_setting)),
}
def _capital(conn) -> float:
return get_account_capital(conn, get_setting)
def _main_price(product_ths: str):
for p in PRODUCTS:
if p["ths"] == product_ths:
main = resolve_main_contract(p)
if not main:
return None
sym = main.get("ths_code") or ""
codes = ths_to_codes(sym)
if codes:
return fetch_price(sym, codes.get("market_code", ""), codes.get("sina_code", ""))
return None
def _ctp_account(mode: str) -> dict:
try:
return ctp_get_account(mode)
except Exception:
return {}
def _ctp_positions(mode: str) -> list:
try:
return ctp_list_positions(mode)
except Exception:
return []
def _match_ctp_symbol(ctp_sym: str, ths: str) -> bool:
a = (ctp_sym or "").lower()
b = (ths or "").lower()
if a == b:
return True
try:
vnpy_sym, _ = ths_to_vnpy_symbol(ths)
return a == vnpy_sym.lower()
except Exception:
return False
@app.route("/trade")
@login_required
def trade_page():
conn = get_db()
init_strategy_tables(conn)
mode = get_trading_mode(get_setting)
ctp_st = ctp_status(mode)
capital = _capital(conn)
sizing = get_sizing_mode(get_setting)
risk = get_risk_status(conn)
ctp_acc = _ctp_account(mode) if ctp_st.get("connected") else {}
positions = _ctp_positions(mode) if ctp_st.get("connected") else []
conn.close()
return render_template(
"trade.html",
trading_mode=mode,
trading_mode_label=trading_mode_label(get_setting),
sizing_mode=sizing,
risk_percent=get_risk_percent(get_setting),
capital=capital,
risk_status=risk,
ctp_status=ctp_st,
ctp_account=ctp_acc,
ctp_positions=positions,
)
@app.route("/recommend")
@login_required
def recommend_page():
conn = get_db()
capital = _capital(conn)
conn.close()
rows = list_product_recommendations(capital, _main_price)
return render_template("recommend.html", capital=capital, rows=rows, trading_mode_label=trading_mode_label(get_setting))
@app.route("/strategy")
@login_required
def strategy_page():
conn = get_db()
init_strategy_tables(conn)
capital = _capital(conn)
active_trend = conn.execute(
"SELECT * FROM trend_pullback_plans WHERE status='active' ORDER BY id DESC LIMIT 1"
).fetchone()
monitors = conn.execute(
"SELECT * FROM trade_order_monitors WHERE status='active' ORDER BY id DESC"
).fetchall()
roll_groups = conn.execute(
"SELECT * FROM roll_groups WHERE status='active' ORDER BY id DESC"
).fetchall()
conn.close()
return render_template(
"strategy.html",
capital=capital,
risk_percent=get_risk_percent(get_setting),
sizing_mode=get_sizing_mode(get_setting),
active_trend=dict(active_trend) if active_trend else None,
monitors=[dict(m) for m in monitors],
roll_groups=[dict(g) for g in roll_groups],
)
@app.route("/strategy/records")
@login_required
def strategy_records_page():
conn = get_db()
init_strategy_tables(conn)
trend, roll = list_snapshots(conn)
conn.close()
return render_template("strategy_records.html", trend_rows=trend, roll_rows=roll)
@app.route("/api/trade/quote")
@login_required
def api_trade_quote():
sym = (request.args.get("symbol") or "").strip()
lots = request.args.get("lots") or "1"
if not sym:
return jsonify({"ok": False, "error": "缺少品种"}), 400
codes = ths_to_codes(sym)
price = fetch_price(sym, codes.get("market_code", "") if codes else "", codes.get("sina_code", "") if codes else "")
try:
lots_f = max(1, int(float(lots)))
except (TypeError, ValueError):
lots_f = 1
metrics = calc_order_tick_metrics(sym, lots_f, price)
spec = get_contract_spec(sym)
name = codes.get("name", sym) if codes else sym
pos_long = pos_short = 0
mode = get_trading_mode(get_setting)
ctp_st = ctp_status(mode)
if ctp_st.get("connected"):
for p in _ctp_positions(mode):
if not _match_ctp_symbol(p.get("symbol", ""), sym):
continue
if p["direction"] == "long":
pos_long = int(p["lots"])
else:
pos_short = int(p["lots"])
max_open = int(_capital(get_db()) / (metrics["margin_per_lot"] or 1)) if metrics.get("margin_per_lot") else 0
return jsonify({
"ok": True,
"symbol": sym,
"name": name,
"price": price,
"lots": lots_f,
"metrics": metrics,
"exchange": codes.get("exchange", "") if codes else "",
"pos_long": pos_long,
"pos_short": pos_short,
"max_open_long": max_open,
"max_open_short": max_open,
"footer_text": (
f"*{name} 每手{spec['mult']}吨/点 最小变动{metrics['tick_size']} "
f"每跳{metrics['tick_value_per_lot']}元/手×{lots_f}={metrics['tick_value_total']}"
f"精度{metrics['price_precision']}位小数"
),
})
@app.route("/api/trade/preview", methods=["POST"])
@login_required
def api_trade_preview():
d = request.get_json(silent=True) or {}
sym = (d.get("symbol") or "").strip()
direction = (d.get("direction") or "long").strip().lower()
try:
entry = float(d.get("entry") or d.get("price") or 0)
sl = float(d.get("stop_loss") or 0)
tp = float(d.get("take_profit") or 0)
except (TypeError, ValueError):
return jsonify({"ok": False, "error": "价格参数无效"}), 400
conn = get_db()
capital = _capital(conn)
conn.close()
sizing = get_sizing_mode(get_setting)
if sizing == MODE_RISK:
lots, err = calc_lots_by_risk(entry, sl, direction, capital, get_risk_percent(get_setting), sym)
if err:
return jsonify({"ok": False, "error": err}), 400
else:
try:
lots = max(1, int(d.get("lots") or 1))
except (TypeError, ValueError):
lots = 1
metrics = calc_position_metrics(direction, entry, sl, tp, lots, entry, capital, sym)
tick = calc_order_tick_metrics(sym, lots, entry)
return jsonify({"ok": True, "lots": lots, "sizing_mode": sizing, "metrics": metrics, "tick": tick, "capital": capital})
@app.route("/api/trade/order", methods=["POST"])
@login_required
def api_trade_order():
d = request.get_json(silent=True) or {}
sym = (d.get("symbol") or "").strip()
offset = (d.get("offset") or "open").strip().lower()
direction = (d.get("direction") or "long").strip().lower()
try:
lots = max(1, int(d.get("lots") or 1))
price = float(d.get("price") or 0)
except (TypeError, ValueError):
return jsonify({"ok": False, "error": "手数或价格无效"}), 400
if not sym or price <= 0:
return jsonify({"ok": False, "error": "品种或价格无效"}), 400
conn = get_db()
init_strategy_tables(conn)
if offset.startswith("open"):
err = assert_can_open(conn)
if err:
conn.close()
return jsonify({"ok": False, "error": err}), 403
mode = get_trading_mode(get_setting)
sizing = get_sizing_mode(get_setting)
if offset.startswith("open") and sizing == MODE_RISK:
sl = float(d.get("stop_loss") or 0)
if sl <= 0:
conn.close()
return jsonify({"ok": False, "error": "以损定仓模式须填写止损价"}), 400
lots_calc, err = calc_lots_by_risk(price, sl, direction, _capital(conn), get_risk_percent(get_setting), sym)
if err:
conn.close()
return jsonify({"ok": False, "error": err}), 400
lots = lots_calc or lots
try:
result = execute_order(
conn,
mode=mode,
offset=offset,
symbol=sym,
direction=direction,
lots=lots,
price=price,
settings=_settings_dict(),
)
if offset.startswith("open"):
sl = d.get("stop_loss")
tp = d.get("take_profit")
codes = ths_to_codes(sym)
conn.execute(
"""INSERT INTO trade_order_monitors (
symbol, symbol_name, market_code, direction, lots, entry_price,
stop_loss, take_profit, open_time, monitor_type, status
) VALUES (?,?,?,?,?,?,?,?,?,?, 'active')""",
(
sym,
codes.get("name", sym) if codes else sym,
codes.get("market_code", "") if codes else "",
direction,
lots,
price,
float(sl) if sl else None,
float(tp) if tp else None,
datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
"manual",
),
)
conn.commit()
send_wechat_msg(f"{trading_mode_label(get_setting)} {offset} {sym} {direction} {lots}手 @{price}")
conn.close()
return jsonify({"ok": True, "result": result, "lots": lots})
except ValueError as exc:
conn.close()
return jsonify({"ok": False, "error": str(exc)}), 400
except Exception as exc:
conn.close()
return jsonify({"ok": False, "error": str(exc)}), 500
@app.route("/api/ctp/connect", methods=["POST"])
@login_required
def api_ctp_connect():
mode = get_trading_mode(get_setting)
force = bool((request.get_json(silent=True) or {}).get("force"))
try:
st = ctp_connect(mode, force=force)
acc = _ctp_account(mode)
return jsonify({"ok": True, "status": st, "account": acc})
except Exception as exc:
st = ctp_status(mode)
return jsonify({"ok": False, "error": str(exc), "status": st}), 400
@app.route("/api/ctp/status")
@login_required
def api_ctp_status():
mode = get_trading_mode(get_setting)
st = ctp_status(mode)
acc = _ctp_account(mode) if st.get("connected") else {}
return jsonify({"ok": True, "status": st, "account": acc})
@app.route("/api/account_snapshot")
@login_required
def api_account_snapshot():
conn = get_db()
init_strategy_tables(conn)
mode = get_trading_mode(get_setting)
ctp_st = ctp_status(mode)
capital = _capital(conn)
risk = get_risk_status(conn)
ctp_acc = _ctp_account(mode) if ctp_st.get("connected") else {}
positions = _ctp_positions(mode) if ctp_st.get("connected") else []
conn.close()
return jsonify({
"capital": capital,
"trading_mode": mode,
"trading_mode_label": trading_mode_label(get_setting),
"sizing_mode": get_sizing_mode(get_setting),
"risk_status": risk,
"ctp_status": ctp_st,
"ctp_account": ctp_acc,
"positions": positions,
})
@app.route("/api/recommend/list")
@login_required
def api_recommend_list():
conn = get_db()
capital = _capital(conn)
conn.close()
return jsonify({"ok": True, "capital": capital, "rows": list_product_recommendations(capital, _main_price)})
@app.route("/api/strategy/trend/preview", methods=["POST"])
@login_required
def api_trend_preview():
d = request.get_json(silent=True) or {}
sym = (d.get("symbol") or "").strip()
conn = get_db()
if conn.execute("SELECT id FROM trend_pullback_plans WHERE status='active'").fetchone():
conn.close()
return jsonify({"ok": False, "error": "已有运行中趋势计划"}), 400
capital = _capital(conn)
codes = ths_to_codes(sym)
price = fetch_price(sym, codes.get("market_code", "") if codes else "", codes.get("sina_code", "") if codes else "")
conn.close()
if not price:
return jsonify({"ok": False, "error": "无法获取现价"}), 400
plan, err = compute_trend_plan_futures(
direction=d.get("direction") or "long",
stop_loss=float(d.get("stop_loss") or 0),
add_upper=float(d.get("add_upper") or 0),
take_profit=float(d.get("take_profit") or 0),
risk_percent=float(d.get("risk_percent") or get_risk_percent(get_setting)),
capital=capital,
live_price=price,
ths_code=sym,
dca_legs=int(d.get("dca_legs") or 5),
)
if err:
return jsonify({"ok": False, "error": err}), 400
return jsonify({"ok": True, "plan": plan})
@app.route("/api/strategy/trend/execute", methods=["POST"])
@login_required
def api_trend_execute():
d = request.get_json(silent=True) or {}
sym = (d.get("symbol") or "").strip()
conn = get_db()
init_strategy_tables(conn)
err = assert_can_open(conn)
if err:
conn.close()
return jsonify({"ok": False, "error": err}), 403
capital = _capital(conn)
codes = ths_to_codes(sym)
price = fetch_price(sym, codes.get("market_code", "") if codes else "", codes.get("sina_code", "") if codes else "")
plan, perr = compute_trend_plan_futures(
direction=d.get("direction") or "long",
stop_loss=float(d.get("stop_loss") or 0),
add_upper=float(d.get("add_upper") or 0),
take_profit=float(d.get("take_profit") or 0),
risk_percent=float(d.get("risk_percent") or get_risk_percent(get_setting)),
capital=capital,
live_price=price or float(d.get("live_price") or 0),
ths_code=sym,
)
if perr:
conn.close()
return jsonify({"ok": False, "error": perr}), 400
mode = get_trading_mode(get_setting)
try:
execute_order(
conn, mode=mode, offset="open", symbol=sym,
direction=plan["direction"], lots=plan["first_lots"], price=price, settings=_settings_dict(),
)
except ValueError as exc:
conn.close()
return jsonify({"ok": False, "error": str(exc)}), 400
now = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
cur = conn.execute(
"""INSERT INTO trend_pullback_plans (
status, symbol, symbol_name, direction, stop_loss, add_upper, take_profit,
risk_percent, capital_snapshot, plan_margin, target_lots, first_lots, remainder_lots,
dca_legs, leg_amounts_json, grid_prices_json, first_order_done, avg_entry_price,
lots_open, opened_at
) VALUES ('active',?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,1,?,?,?,?)""",
(
sym, codes.get("name", sym) if codes else sym, plan["direction"],
plan["stop_loss"], plan["add_upper"], plan["take_profit"],
plan["risk_percent"], plan["capital_snapshot"], plan["plan_margin"],
plan["target_lots"], plan["first_lots"], plan["remainder_lots"],
plan["dca_legs"], plan["leg_amounts_json"], plan["grid_prices_json"],
price, plan["first_lots"], now,
),
)
plan_id = cur.lastrowid
conn.commit()
conn.close()
send_wechat_msg(f"趋势回调首仓 {sym} {plan['first_lots']}")
return jsonify({"ok": True, "plan_id": plan_id, "plan": plan})
@app.route("/api/strategy/roll/preview", methods=["POST"])
@login_required
def api_roll_preview():
d = request.get_json(silent=True) or {}
conn = get_db()
mon_id = int(d.get("monitor_id") or 0)
mon = conn.execute("SELECT * FROM trade_order_monitors WHERE id=? AND status='active'", (mon_id,)).fetchone()
conn.close()
if not mon:
return jsonify({"ok": False, "error": "无有效持仓监控"}), 400
sym = mon["symbol"]
spec = get_contract_spec(sym)
capital = _capital(get_db())
preview, err = preview_roll(
direction=mon["direction"],
symbol=sym,
qty_existing=float(mon["lots"]),
entry_existing=float(mon["entry_price"]),
initial_take_profit=float(mon["take_profit"] or 0),
add_mode=d.get("add_mode") or "market",
new_stop_loss=float(d.get("new_stop_loss") or 0),
risk_percent=float(d.get("risk_percent") or 2),
capital_base=capital,
mult=spec["mult"],
add_price=float(d.get("add_price") or mon["entry_price"]),
fib_upper=d.get("fib_upper"),
fib_lower=d.get("fib_lower"),
legs_done=int(d.get("legs_done") or 0),
)
if err:
return jsonify({"ok": False, "error": err}), 400
return jsonify({"ok": True, "preview": preview})
@app.route("/api/strategy/roll/execute", methods=["POST"])
@login_required
def api_roll_execute():
d = request.get_json(silent=True) or {}
conn = get_db()
init_strategy_tables(conn)
mon_id = int(d.get("monitor_id") or 0)
mon = conn.execute("SELECT * FROM trade_order_monitors WHERE id=? AND status='active'", (mon_id,)).fetchone()
if not mon:
conn.close()
return jsonify({"ok": False, "error": "无有效持仓监控"}), 400
if conn.execute("SELECT id FROM trend_pullback_plans WHERE status='active'").fetchone():
conn.close()
return jsonify({"ok": False, "error": "趋势回调运行中,不可滚仓"}), 400
sym = mon["symbol"]
spec = get_contract_spec(sym)
capital = _capital(conn)
prev, err = preview_roll(
direction=mon["direction"],
symbol=sym,
qty_existing=float(mon["lots"]),
entry_existing=float(mon["entry_price"]),
initial_take_profit=float(mon["take_profit"] or 0),
add_mode=d.get("add_mode") or "market",
new_stop_loss=float(d.get("new_stop_loss") or 0),
risk_percent=float(d.get("risk_percent") or 2),
capital_base=capital,
mult=spec["mult"],
add_price=float(d.get("add_price") or mon["entry_price"]),
)
if err:
conn.close()
return jsonify({"ok": False, "error": err}), 400
price = float(prev["add_price"])
mode = get_trading_mode(get_setting)
try:
execute_order(
conn, mode=mode, offset="open", symbol=sym,
direction=mon["direction"], lots=int(prev["add_lots"]), price=price, settings=_settings_dict(),
)
except ValueError as exc:
conn.close()
return jsonify({"ok": False, "error": str(exc)}), 400
new_lots = int(mon["lots"]) + int(prev["add_lots"])
new_avg = prev["avg_entry_after"]
new_sl = prev["new_stop_loss"]
conn.execute(
"UPDATE trade_order_monitors SET lots=?, entry_price=?, stop_loss=? WHERE id=?",
(new_lots, new_avg, new_sl, mon_id),
)
grp = conn.execute(
"SELECT * FROM roll_groups WHERE order_monitor_id=? AND status='active'",
(mon_id,),
).fetchone()
now = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
if grp:
gid = grp["id"]
leg_n = int(grp["leg_count"] or 0) + 1
conn.execute(
"UPDATE roll_groups SET leg_count=?, current_stop_loss=?, updated_at=? WHERE id=?",
(leg_n, new_sl, now, gid),
)
else:
cur = conn.execute(
"""INSERT INTO roll_groups (
order_monitor_id, symbol, direction, initial_take_profit, initial_stop_loss,
current_stop_loss, risk_percent, leg_count, status, created_at, updated_at
) VALUES (?,?,?,?,?,?,?,1,'active',?,?)""",
(mon_id, sym, mon["direction"], mon["take_profit"], mon["stop_loss"], new_sl,
float(d.get("risk_percent") or 2), now, now),
)
gid = cur.lastrowid
leg_n = 1
conn.execute(
"""INSERT INTO roll_legs (roll_group_id, leg_index, add_mode, fill_price, lots, new_stop_loss, status, created_at)
VALUES (?,?,?,?,?,?, 'filled', ?)""",
(gid, leg_n, d.get("add_mode") or "market", price, int(prev["add_lots"]), new_sl, now),
)
conn.commit()
conn.close()
return jsonify({"ok": True, "preview": prev})
@app.route("/api/strategy/trend/stop", methods=["POST"])
@login_required
def api_trend_stop():
d = request.get_json(silent=True) or {}
plan_id = int(d.get("plan_id") or 0)
conn = get_db()
plan = conn.execute("SELECT * FROM trend_pullback_plans WHERE id=? AND status='active'", (plan_id,)).fetchone()
if not plan:
conn.close()
return jsonify({"ok": False, "error": "计划不存在"}), 404
mode = get_trading_mode(get_setting)
price = fetch_price(plan["symbol"]) or float(plan["avg_entry_price"] or 0)
try:
if int(plan["lots_open"] or 0) > 0:
execute_order(
conn, mode=mode, offset="close", symbol=plan["symbol"],
direction=plan["direction"], lots=int(plan["lots_open"]), price=price, settings=_settings_dict(),
)
except ValueError:
pass
now = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
conn.execute(
"UPDATE trend_pullback_plans SET status='stopped_manual', message=?, opened_at=opened_at WHERE id=?",
("手动结束", plan_id),
)
save_snapshot(
conn, strategy_type=STRATEGY_TREND, source_id=plan_id,
symbol=plan["symbol"], direction=plan["direction"], result_label="手动结束",
payload=dict(plan), opened_at=plan["opened_at"] or "",
)
on_user_initiated_close(conn, trading_day=trading_day_label())
conn.commit()
conn.close()
return jsonify({"ok": True})
def check_trend_plans(app_ref):
"""后台:趋势补仓与止盈。"""
conn = get_db()
init_strategy_tables(conn)
rows = conn.execute("SELECT * FROM trend_pullback_plans WHERE status='active'").fetchall()
mode = get_trading_mode(get_setting)
for plan in rows:
sym = plan["symbol"]
price = fetch_price(sym)
if not price:
continue
direction = plan["direction"]
tp = float(plan["take_profit"] or 0)
if tp > 0:
hit_tp = (direction == "long" and price >= tp) or (direction == "short" and price <= tp)
if hit_tp:
try:
execute_order(
conn, mode=mode, offset="close", symbol=sym, direction=direction,
lots=int(plan["lots_open"] or 0), price=price, settings=_settings_dict(),
)
except ValueError:
pass
conn.execute(
"UPDATE trend_pullback_plans SET status='stopped_tp', message=? WHERE id=?",
("程序止盈", plan["id"]),
)
save_snapshot(
conn, strategy_type=STRATEGY_TREND, source_id=plan["id"],
symbol=sym, direction=direction, result_label="止盈",
payload=dict(plan), opened_at=plan["opened_at"] or "",
)
send_wechat_msg(f"趋势回调止盈 {sym}")
continue
try:
grid = json.loads(plan["grid_prices_json"] or "[]")
legs = json.loads(plan["leg_amounts_json"] or "[]")
except Exception:
grid, legs = [], []
done = int(plan["legs_done"] or 0)
if done < len(grid) and done < len(legs):
level = float(grid[done])
if trend_dca_level_reached(direction, price, level):
add_lots = int(legs[done])
try:
execute_order(
conn, mode=mode, offset="open", symbol=sym, direction=direction,
lots=add_lots, price=price, settings=_settings_dict(),
)
new_open = int(plan["lots_open"] or 0) + add_lots
old_avg = float(plan["avg_entry_price"] or price)
new_avg = (old_avg * int(plan["lots_open"] or 0) + price * add_lots) / new_open if new_open else price
conn.execute(
"""UPDATE trend_pullback_plans SET legs_done=?, lots_open=?, avg_entry_price=? WHERE id=?""",
(done + 1, new_open, new_avg, plan["id"]),
)
send_wechat_msg(f"趋势回调补仓 {sym} +{add_lots}手 @档位{done+1}")
except ValueError:
pass
conn.commit()
conn.close()
app._check_trend_plans = check_trend_plans
@app.route("/settings/trading", methods=["POST"])
@login_required
def settings_trading_post():
return redirect(url_for("settings"))
def hook_review_mood(conn, behavior_tags: str, exit_trigger: str, exit_supplement: str):
if parse_mood_issues(behavior_tags):
on_mood_journal_freeze(conn, trading_day=trading_day_label())
if (exit_trigger or "").strip() == "手动平仓" and (exit_supplement or "").strip():
reduce_cooloff_after_journal(conn, trading_day=trading_day_label())
app._risk_review_hook = hook_review_mood
+94
View File
@@ -0,0 +1,94 @@
"""期货计仓:固定张数 / 以损定仓(不含币圈全仓杠杆模式)。"""
from __future__ import annotations
import math
from typing import Optional
from contract_specs import get_contract_spec
MODE_FIXED = "fixed"
MODE_RISK = "risk"
def normalize_sizing_mode(raw: str) -> str:
m = (raw or MODE_RISK).strip().lower()
return m if m in (MODE_FIXED, MODE_RISK) else MODE_RISK
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 calc_lots_by_risk(
entry: float,
stop_loss: float,
direction: str,
capital: float,
risk_percent: float,
ths_code: str,
*,
max_lots: Optional[int] = None,
) -> tuple[Optional[int], Optional[str]]:
"""以损定仓:返回 (手数, 错误信息)。"""
try:
entry_f = float(entry)
sl_f = float(stop_loss)
cap = float(capital)
rp = float(risk_percent)
except (TypeError, ValueError):
return None, "参数格式错误"
if entry_f <= 0 or cap <= 0 or rp <= 0:
return None, "入场价、资金或风险比例无效"
spec = get_contract_spec(ths_code)
mult = spec["mult"]
d = (direction or "long").strip().lower()
if d == "short":
per_lot_risk = (sl_f - entry_f) * mult
else:
per_lot_risk = (entry_f - sl_f) * mult
if per_lot_risk <= 0:
return None, "止损方向与入场价不匹配"
budget = cap * rp / 100.0
lots = int(math.floor(budget / per_lot_risk))
if lots < 1:
return None, f"{rp}% 风险预算,当前止损距离下不足 1 手"
margin_rate = spec["margin_rate"]
margin_per_lot = entry_f * mult * margin_rate
max_by_margin = int(math.floor(cap * 0.85 / margin_per_lot)) if margin_per_lot > 0 else lots
if max_by_margin < 1:
return None, "可用资金不足以覆盖 1 手保证金"
lots = min(lots, max_by_margin)
if max_lots is not None:
lots = min(lots, int(max_lots))
return lots, None
def calc_order_tick_metrics(ths_code: str, lots: float, price: Optional[float] = 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 = round(mark * mult * margin_rate, 2) if mark > 0 else None
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,
}
+96
View File
@@ -0,0 +1,96 @@
"""按账户资金推荐可交易品种(期货核心筛选)。"""
from __future__ import annotations
from typing import Callable, Optional
from contract_specs import get_contract_spec
from symbols import PRODUCTS
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_position_pct: float = 50.0,
default_stop_ticks: int = 20,
) -> dict:
"""评估单品种在当前资金下是否可交易。"""
ths = product.get("ths") or ""
name = product.get("name") or ths
exchange = product.get("exchange") or ""
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)
if p <= 0:
return {
"ths": ths,
"name": name,
"exchange": exchange,
"status": "no_price",
"status_label": "暂无行情",
"min_capital_one_lot": None,
"margin_one_lot": None,
"risk_one_lot_1pct": None,
}
margin_one = p * mult * margin_rate
min_capital = margin_one / (max_position_pct / 100.0) if max_position_pct > 0 else margin_one
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
can_margin = cap >= min_capital
can_risk = cap > 0 and risk_one_lot <= cap * 0.01
if can_margin and can_risk:
status, label = "ok", "推荐"
elif can_margin:
status, label = "margin_ok", "可开1手·止损偏宽"
else:
status, label = "blocked", "资金不足"
return {
"ths": ths,
"name": name,
"exchange": exchange,
"price": round(p, 4),
"mult": mult,
"tick_size": tick,
"margin_one_lot": round(margin_one, 2),
"min_capital_one_lot": round(min_capital, 2),
"risk_one_lot_1pct": round(risk_one_lot, 2),
"risk_pct_1lot_at_1pct_rule": round(risk_pct_1lot, 2),
"status": status,
"status_label": label,
}
def list_product_recommendations(
capital: float,
price_fn: Callable[[str], Optional[float]],
*,
max_position_pct: float = 50.0,
) -> list[dict]:
"""扫描全部品种并排序:推荐 > 可开 > 不足。"""
rows = []
for p in PRODUCTS:
ths = p["ths"]
main_code = price_fn(ths)
row = assess_product_for_capital(
p, capital, main_code, max_position_pct=max_position_pct
)
rows.append(row)
order = {"ok": 0, "margin_ok": 1, "blocked": 2, "no_price": 3}
rows.sort(key=lambda r: (order.get(r["status"], 9), r.get("min_capital_one_lot") or 1e18))
return rows
+2
View File
@@ -4,3 +4,5 @@ python-dotenv==1.0.1
Werkzeug==3.0.3
matplotlib==3.9.2
akshare==1.18.64
# 实盘 / 模拟 CTPSimNow + 期货公司)
# pip install vnpy vnpy_ctp
View File
+271
View File
@@ -0,0 +1,271 @@
"""账户冷静期 / 日冻结(自 crypto_monitor 复制并简化为单账户期货版)。"""
from __future__ import annotations
import os
from datetime import datetime
from typing import Any, Optional
from zoneinfo import ZoneInfo
STATUS_NORMAL = "normal"
STATUS_FREEZE_1H = "freeze_1h"
STATUS_FREEZE_4H = "freeze_4h"
STATUS_DAILY = "freeze_daily"
STATUS_FREEZE_POSITION = "freeze_position"
STATUS_LABELS = {
STATUS_NORMAL: "正常",
STATUS_FREEZE_1H: "1h冻结",
STATUS_FREEZE_4H: "4h冻结",
STATUS_DAILY: "日冻结",
STATUS_FREEZE_POSITION: "仓位上限冻结",
}
MOOD_ISSUE_OPTIONS = (
"怕踏空", "报复开仓", "盈利飘了", "拿不住单", "扛单", "重仓违规",
)
CLOSE_SOURCE_USER = "user_instance"
CLOSE_SOURCE_TREND_STOP = "user_trend_stop"
def _app_tz():
name = (os.getenv("APP_TIMEZONE") or "Asia/Shanghai").strip()
try:
return ZoneInfo(name)
except Exception:
return ZoneInfo("Asia/Shanghai")
def risk_control_enabled() -> bool:
raw = (os.getenv("RISK_CONTROL_ENABLED") or "true").strip().lower()
return raw in ("1", "true", "yes", "on")
def cooling_hours_manual() -> float:
try:
return max(0.0, float(os.getenv("RISK_COOLING_HOURS_MANUAL", "4")))
except (TypeError, ValueError):
return 4.0
def cooling_hours_manual_journal() -> float:
try:
return max(0.0, float(os.getenv("RISK_COOLING_HOURS_MANUAL_JOURNAL", "1")))
except (TypeError, ValueError):
return 1.0
def manual_close_daily_limit() -> int:
try:
return max(1, int(os.getenv("RISK_MANUAL_CLOSE_DAILY_LIMIT", "2")))
except (TypeError, ValueError):
return 2
def max_active_positions() -> int:
try:
return max(1, int(os.getenv("MAX_ACTIVE_POSITIONS", "1")))
except (TypeError, ValueError):
return 1
def trading_day_reset_hour() -> int:
try:
return max(0, min(23, int(os.getenv("TRADING_DAY_RESET_HOUR", "8"))))
except (TypeError, ValueError):
return 8
def ensure_account_risk_schema(conn) -> None:
conn.execute(
"""CREATE TABLE IF NOT EXISTS account_risk_state (
id INTEGER PRIMARY KEY CHECK (id = 1),
trading_day TEXT,
manual_close_count INTEGER DEFAULT 0,
cooloff_until_ms INTEGER,
cooloff_hours INTEGER,
daily_frozen INTEGER DEFAULT 0,
last_close_at_ms INTEGER,
updated_at TEXT
)"""
)
if not conn.execute("SELECT id FROM account_risk_state WHERE id=1").fetchone():
conn.execute(
"INSERT INTO account_risk_state (id, trading_day, manual_close_count, daily_frozen) VALUES (1, '', 0, 0)"
)
def _row_get(row, key, default=None):
if row is None:
return default
try:
return row[key]
except (KeyError, IndexError, TypeError):
return default
def _now_ms(now: Optional[datetime] = None) -> int:
dt = now or datetime.now(_app_tz())
if dt.tzinfo is None:
dt = dt.replace(tzinfo=_app_tz())
return int(dt.timestamp() * 1000)
def trading_day_label(now: Optional[datetime] = None) -> str:
dt = now or datetime.now(_app_tz())
if dt.hour < trading_day_reset_hour():
from datetime import timedelta
dt = dt - timedelta(days=1)
return dt.date().isoformat()
def count_active_trade_monitors(conn) -> int:
try:
n = conn.execute(
"SELECT COUNT(*) FROM trade_order_monitors WHERE status='active'"
).fetchone()[0]
return int(n or 0)
except Exception:
return 0
def parse_mood_issues(raw: Any) -> list[str]:
if raw is None:
return []
if isinstance(raw, (list, tuple)):
parts = [str(x).strip() for x in raw if str(x).strip()]
else:
parts = [x.strip() for x in str(raw).split(",") if x.strip()]
return [p for p in parts if p in MOOD_ISSUE_OPTIONS]
def on_user_initiated_close(conn, *, trading_day: str, now: Optional[datetime] = None) -> None:
if not risk_control_enabled():
return
ensure_account_risk_schema(conn)
row = conn.execute("SELECT * FROM account_risk_state WHERE id=1").fetchone()
td = (trading_day or trading_day_label(now)).strip()
stored = str(_row_get(row, "trading_day") or "")
count = int(_row_get(row, "manual_close_count") or 0)
if stored != td:
count = 0
count += 1
close_ms = _now_ms(now)
if count >= manual_close_daily_limit():
conn.execute(
"""UPDATE account_risk_state SET trading_day=?, manual_close_count=?,
daily_frozen=1, cooloff_until_ms=NULL, last_close_at_ms=?, updated_at=? WHERE id=1""",
(td, count, close_ms, datetime.now().strftime("%Y-%m-%d %H:%M:%S")),
)
return
until = close_ms + int(cooling_hours_manual() * 3600 * 1000)
conn.execute(
"""UPDATE account_risk_state SET trading_day=?, manual_close_count=?,
daily_frozen=0, cooloff_until_ms=?, cooloff_hours=?, last_close_at_ms=?, updated_at=? WHERE id=1""",
(td, count, until, int(cooling_hours_manual()), close_ms, datetime.now().strftime("%Y-%m-%d %H:%M:%S")),
)
def on_mood_journal_freeze(conn, *, trading_day: str) -> None:
if not risk_control_enabled():
return
ensure_account_risk_schema(conn)
td = (trading_day or trading_day_label()).strip()
conn.execute(
"UPDATE account_risk_state SET trading_day=?, daily_frozen=1, updated_at=? WHERE id=1",
(td, datetime.now().strftime("%Y-%m-%d %H:%M:%S")),
)
def reduce_cooloff_after_journal(conn, *, trading_day: str, now: Optional[datetime] = None) -> None:
"""复盘手动平仓说明后,4h 冷静期降为 1h。"""
if not risk_control_enabled():
return
ensure_account_risk_schema(conn)
row = conn.execute("SELECT * FROM account_risk_state WHERE id=1").fetchone()
if int(_row_get(row, "daily_frozen") or 0):
return
until = _row_get(row, "cooloff_until_ms")
if not until:
return
now_ms = _now_ms(now)
if int(until) <= now_ms:
return
last = int(_row_get(row, "last_close_at_ms") or now_ms)
journal_ms = int(cooling_hours_manual_journal() * 3600 * 1000)
new_until = max(now_ms, last + journal_ms)
conn.execute(
"""UPDATE account_risk_state SET cooloff_until_ms=?, cooloff_hours=?, updated_at=? WHERE id=1""",
(new_until, int(cooling_hours_manual_journal()), datetime.now().strftime("%Y-%m-%d %H:%M:%S")),
)
def get_risk_status(conn, *, now: Optional[datetime] = None) -> dict:
ensure_account_risk_schema(conn)
row = conn.execute("SELECT * FROM account_risk_state WHERE id=1").fetchone()
td = trading_day_label(now)
stored = str(_row_get(row, "trading_day") or "")
if stored != td:
conn.execute(
"UPDATE account_risk_state SET trading_day=?, manual_close_count=0, daily_frozen=0 WHERE id=1 AND trading_day<>?",
(td, td),
)
row = conn.execute("SELECT * FROM account_risk_state WHERE id=1").fetchone()
now_ms = _now_ms(now)
daily = int(_row_get(row, "daily_frozen") or 0) == 1
until = _row_get(row, "cooloff_until_ms")
active = count_active_trade_monitors(conn)
mx = max_active_positions()
pos_limit = active >= mx
if daily:
return {
"status": STATUS_DAILY,
"status_label": STATUS_LABELS[STATUS_DAILY],
"can_trade": False,
"can_roll": False,
"reason": "当日日冻结,禁止新开仓",
"active_count": active,
"max_active_positions": mx,
}
if until and int(until) > now_ms:
rem = int((int(until) - now_ms) / 1000)
hours = float(_row_get(row, "cooloff_hours") or cooling_hours_manual())
st = STATUS_FREEZE_1H if hours <= cooling_hours_manual_journal() + 0.01 else STATUS_FREEZE_4H
return {
"status": st,
"status_label": STATUS_LABELS[st],
"can_trade": False,
"can_roll": pos_limit,
"reason": f"冷静期中,剩余约 {rem // 3600}h {(rem % 3600) // 60}m",
"freeze_remaining_sec": rem,
"active_count": active,
"max_active_positions": mx,
}
if pos_limit:
return {
"status": STATUS_FREEZE_POSITION,
"status_label": STATUS_LABELS[STATUS_FREEZE_POSITION],
"can_trade": False,
"can_roll": True,
"reason": f"已达仓位上限 {active}/{mx}",
"active_count": active,
"max_active_positions": mx,
}
return {
"status": STATUS_NORMAL,
"status_label": STATUS_LABELS[STATUS_NORMAL],
"can_trade": True,
"can_roll": True,
"reason": "可新开仓",
"active_count": active,
"max_active_positions": mx,
}
def assert_can_open(conn) -> Optional[str]:
rs = get_risk_status(conn)
if not rs.get("can_trade"):
return rs.get("reason") or "当前不可开仓"
return None
+20
View File
@@ -0,0 +1,20 @@
.trade-page{max-width:720px;margin:0 auto}
.trade-top-bar{display:flex;flex-wrap:wrap;gap:.65rem;align-items:center;margin-bottom:1rem}
.trade-order-card{padding:1.25rem}
.trade-tabs{display:flex;gap:1rem;margin-bottom:1rem;font-size:.88rem}
.trade-tabs span.active{color:var(--accent);font-weight:600;border-bottom:2px solid var(--accent);padding-bottom:.25rem}
.trade-tabs a{color:var(--text-muted);text-decoration:none}
.trade-input-row,.trade-risk-row{display:grid;grid-template-columns:2fr 1fr 1fr;gap:.65rem;margin-bottom:.75rem}
.trade-field label{display:block;font-size:.72rem;margin-bottom:.25rem;color:var(--text-label)}
.trade-btn-row{display:grid;grid-template-columns:repeat(4,1fr);gap:.5rem;margin:1rem 0}
.trade-btn{border:none;border-radius:8px;padding:.75rem .35rem;cursor:pointer;display:flex;flex-direction:column;align-items:center;gap:.15rem;color:#fff;font-weight:600}
.trade-btn .btn-price{font-size:1.1rem}
.trade-btn .btn-label{font-size:.85rem}
.trade-btn .btn-sub{font-size:.68rem;opacity:.85;font-weight:400}
.trade-btn.long{background:linear-gradient(180deg,#e74c3c,#c0392b)}
.trade-btn.lock{background:linear-gradient(180deg,#27ae60,#1e8449)}
.trade-btn.close{background:linear-gradient(180deg,#3498db,#2980b9)}
.trade-footer{background:var(--card-inner);border-radius:8px;padding:.75rem 1rem;font-size:.82rem;line-height:1.55;border:1px solid var(--card-border)}
.trade-footer strong{color:var(--accent)}
.rec-blocked td{opacity:.55}
.rec-ok td:first-child{font-weight:600}
+101 -37
View File
@@ -242,41 +242,59 @@
function getDataZoom(c, preserve) {
var defStart = getDefaultZoomStart();
var zoom = [
{
type: 'inside',
xAxisIndex: [0, 1],
start: defStart,
end: 100,
zoomOnMouseWheel: true,
moveOnMouseMove: true,
moveOnMouseWheel: false,
var xZoom = {
type: 'inside',
id: 'dzInsideX',
xAxisIndex: [0, 1],
start: defStart,
end: 100,
filterMode: 'none',
zoomOnMouseWheel: true,
moveOnMouseMove: true,
moveOnMouseWheel: false,
preventDefaultMouseMove: true,
minSpan: 2,
};
var yZoom = {
type: 'inside',
id: 'dzInsideY',
yAxisIndex: [0],
orient: 'vertical',
filterMode: 'none',
zoomOnMouseWheel: true,
moveOnMouseMove: true,
preventDefaultMouseMove: true,
};
var slider = {
type: 'slider',
id: 'dzSlider',
xAxisIndex: [0, 1],
start: defStart,
end: 100,
height: 22,
bottom: 4,
borderColor: c.grid,
backgroundColor: c.bg,
fillerColor: c.area,
handleStyle: { color: c.sliderFill },
dataBackground: {
lineStyle: { color: c.grid, opacity: 0.35 },
areaStyle: { color: c.area },
},
{
type: 'slider',
xAxisIndex: [0, 1],
start: defStart,
end: 100,
height: 22,
bottom: 4,
borderColor: c.grid,
backgroundColor: c.bg,
fillerColor: c.area,
handleStyle: { color: c.sliderFill },
dataBackground: {
lineStyle: { color: c.grid },
areaStyle: { color: c.area },
},
textStyle: { color: c.text, fontSize: 10 },
},
];
textStyle: { color: c.text, fontSize: 10 },
filterMode: 'none',
brushSelect: false,
};
var zoom = [xZoom, yZoom, slider];
if (preserve && chart) {
var opt = chart.getOption();
if (opt && opt.dataZoom) {
opt.dataZoom.forEach(function (z, i) {
if (zoom[i] && z.start != null && z.end != null) {
zoom[i].start = z.start;
zoom[i].end = z.end;
opt.dataZoom.forEach(function (z) {
if (!z.id) return;
var target = zoom.find(function (t) { return t.id === z.id; });
if (target && z.start != null && z.end != null) {
target.start = z.start;
target.end = z.end;
}
});
}
@@ -284,6 +302,11 @@
return zoom;
}
function isFollowingLatest() {
var z = getZoomRange();
return z.end >= 98;
}
function mapSeriesData(bars, values, gapDay) {
if (!gapDay) return values;
return bars.map(function (b, i) {
@@ -303,6 +326,7 @@
var times = bars.map(function (b) { return b.time; });
var isLine = data.chart_type === 'line' || data.period === 'timeshare';
var gapDay = chartOpts.gapDay;
var followLatest = preserveZoom && isFollowingLatest();
var dataZoom = getDataZoom(c, preserveZoom);
var zoom = preserveZoom ? getZoomRange() : { start: dataZoom[0].start, end: dataZoom[0].end };
var vIdx = visibleIndices(bars, zoom);
@@ -326,6 +350,7 @@
boundaryGap: gapDay ? false : true,
axisLabel: { color: c.text, fontSize: 10 },
axisLine: { lineStyle: { color: c.grid } },
splitLine: { show: false },
};
var xAxis1 = {
type: xAxisType,
@@ -333,6 +358,7 @@
boundaryGap: gapDay ? false : true,
axisLabel: { show: false },
axisLine: { lineStyle: { color: c.grid } },
splitLine: { show: false },
};
if (!gapDay) {
xAxis0.data = times;
@@ -344,14 +370,27 @@
animation: false,
tooltip: { trigger: 'axis', axisPointer: { type: 'cross' } },
axisPointer: { link: [{ xAxisIndex: 'all' }] },
dataZoom: dataZoom,
grid: grids,
xAxis: [xAxis0, xAxis1],
yAxis: [
{ scale: true, gridIndex: 0, splitLine: { lineStyle: { color: c.grid } }, axisLabel: { color: c.text } },
{ scale: true, gridIndex: 1, splitLine: { show: false }, axisLabel: { color: c.text, fontSize: 10 }, splitNumber: 2 },
{
scale: true,
gridIndex: 0,
splitLine: { show: false },
axisLabel: { color: c.text },
},
{
scale: true,
gridIndex: 1,
splitLine: { show: false },
axisLabel: { color: c.text, fontSize: 10 },
splitNumber: 2,
},
],
};
if (!preserveZoom) {
base.dataZoom = dataZoom;
}
var series = [];
var mainMark = {
@@ -465,7 +504,12 @@
};
}
chart.setOption(Object.assign(base, { series: series }), true);
if (preserveZoom) {
chart.setOption(Object.assign(base, { series: series }), false);
} else {
chart.setOption(Object.assign(base, { series: series }), true);
dataZoomBound = false;
}
var title = (data.chart_symbol || data.symbol || '') + ' · ' + periodLabel(data.period);
chart.setOption({
@@ -478,6 +522,22 @@
} : { show: false },
});
if (followLatest) {
var span = zoom.end - zoom.start;
chart.dispatchAction({
type: 'dataZoom',
dataZoomIndex: 0,
start: Math.max(0, 100 - span),
end: 100,
});
chart.dispatchAction({
type: 'dataZoom',
dataZoomIndex: 2,
start: Math.max(0, 100 - span),
end: 100,
});
}
bindDataZoomHL();
}
@@ -656,12 +716,16 @@
if (start === 0) end = newSpan;
else start = end - newSpan;
}
chart.dispatchAction({ type: 'dataZoom', start: start, end: end });
chart.dispatchAction({ type: 'dataZoom', dataZoomIndex: 0, start: start, end: end });
chart.dispatchAction({ type: 'dataZoom', dataZoomIndex: 2, start: start, end: end });
}
function resetDataZoom() {
if (!chart) return;
chart.dispatchAction({ type: 'dataZoom', start: getDefaultZoomStart(), end: 100 });
var start = getDefaultZoomStart();
chart.dispatchAction({ type: 'dataZoom', dataZoomIndex: 0, start: start, end: 100 });
chart.dispatchAction({ type: 'dataZoom', dataZoomIndex: 2, start: start, end: 100 });
chart.dispatchAction({ type: 'dataZoom', dataZoomIndex: 1, start: 0, end: 100 });
}
function bindPeriodTabs() {
+76
View File
@@ -0,0 +1,76 @@
(function () {
var trendPayload = null;
function jsonPost(url, body) {
return fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body || {})
}).then(function (r) { return r.json(); });
}
function formData(form) {
var fd = new FormData(form);
var o = {};
fd.forEach(function (v, k) { o[k] = v; });
return o;
}
var trendForm = document.getElementById('trend-form');
var btnPreview = document.getElementById('btn-trend-preview');
var btnExec = document.getElementById('btn-trend-exec');
var previewEl = document.getElementById('trend-preview');
if (btnPreview && trendForm) {
btnPreview.addEventListener('click', function () {
jsonPost('/api/strategy/trend/preview', formData(trendForm)).then(function (d) {
if (!d.ok) { previewEl.textContent = d.error || '预览失败'; btnExec.hidden = true; return; }
trendPayload = formData(trendForm);
previewEl.textContent = JSON.stringify(d.plan, null, 2);
btnExec.hidden = false;
});
});
}
if (btnExec) {
btnExec.addEventListener('click', function () {
if (!trendPayload) return;
jsonPost('/api/strategy/trend/execute', trendPayload).then(function (d) {
if (!d.ok) { alert(d.error); return; }
location.reload();
});
});
}
var rollForm = document.getElementById('roll-form');
var btnRollP = document.getElementById('btn-roll-preview');
var btnRollE = document.getElementById('btn-roll-exec');
var rollPrev = document.getElementById('roll-preview');
if (btnRollP && rollForm) {
btnRollP.addEventListener('click', function () {
jsonPost('/api/strategy/roll/preview', formData(rollForm)).then(function (d) {
if (!d.ok) { rollPrev.textContent = d.error; btnRollE.hidden = true; return; }
rollPrev.textContent = JSON.stringify(d.preview, null, 2);
btnRollE.hidden = false;
});
});
}
if (btnRollE && rollForm) {
btnRollE.addEventListener('click', function () {
jsonPost('/api/strategy/roll/execute', formData(rollForm)).then(function (d) {
if (!d.ok) { alert(d.error); return; }
location.reload();
});
});
}
var btnStop = document.getElementById('btn-trend-stop');
if (btnStop) {
btnStop.addEventListener('click', function () {
var pid = document.querySelector('#trend-stop-form input[name=plan_id]');
jsonPost('/api/strategy/trend/stop', { plan_id: pid ? pid.value : 0 }).then(function (d) {
if (!d.ok) { alert(d.error); return; }
location.reload();
});
});
}
})();
+127
View File
@@ -0,0 +1,127 @@
(function () {
var symInput = document.getElementById('trade-symbol');
var lotsInput = document.getElementById('trade-lots');
var priceInput = document.getElementById('trade-price');
var footer = document.getElementById('trade-footer');
var slInput = document.getElementById('trade-sl');
var tpInput = document.getElementById('trade-tp');
var debounceTimer;
function selectedSymbol() {
return (symInput && symInput.value || '').trim();
}
function refreshQuote() {
var sym = selectedSymbol();
var lots = lotsInput ? lotsInput.value : '1';
if (!sym) return;
fetch('/api/trade/quote?symbol=' + encodeURIComponent(sym) + '&lots=' + encodeURIComponent(lots))
.then(function (r) { return r.json(); })
.then(function (data) {
if (!data.ok) return;
if (priceInput && !priceInput.dataset.manual && data.price) {
priceInput.value = data.price;
}
var px = data.price != null ? data.price : '—';
['px-long', 'px-short'].forEach(function (id) {
var el = document.getElementById(id);
if (el) el.textContent = px;
});
var ml = document.getElementById('max-long');
var ms = document.getElementById('max-short');
if (ml) ml.textContent = '≤' + (data.max_open_long || '—');
if (ms) ms.textContent = '≤' + (data.max_open_short || '—');
document.getElementById('pos-long').textContent = '≤' + (data.pos_long || 0);
document.getElementById('pos-short').textContent = '≤' + (data.pos_short || 0);
if (footer && data.metrics) {
var m = data.metrics;
footer.innerHTML =
'<p><strong>' + (data.name || sym) + '</strong> ' + (data.footer_text || '') + '</p>' +
'<p>价格精度 <strong>' + m.price_precision + '</strong> 位 · ' +
'最小变动 <strong>' + m.tick_size + '</strong> · ' +
'每跳 <strong>' + m.tick_value_per_lot + '</strong> 元/手 · ' +
'当前 <strong>' + lots + '</strong> 手每跳合计 <strong class="text-accent">' + m.tick_value_total + '</strong> 元</p>' +
(m.margin_total ? '<p class="text-muted">预估保证金约 ' + m.margin_total + ' 元</p>' : '');
}
}).catch(function () {});
}
function scheduleRefresh() {
clearTimeout(debounceTimer);
debounceTimer = setTimeout(refreshQuote, 400);
}
if (symInput) symInput.addEventListener('input', scheduleRefresh);
if (lotsInput) lotsInput.addEventListener('input', scheduleRefresh);
if (priceInput) {
priceInput.addEventListener('input', function () {
priceInput.dataset.manual = '1';
});
}
function postOrder(offset, direction) {
var sym = selectedSymbol();
if (!sym) { alert('请选择品种'); return; }
var body = {
symbol: sym,
offset: offset,
direction: direction,
lots: parseInt(lotsInput.value, 10) || 1,
price: parseFloat(priceInput.value) || 0,
stop_loss: slInput ? parseFloat(slInput.value) : null,
take_profit: tpInput ? parseFloat(tpInput.value) : null
};
fetch('/api/trade/order', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body)
}).then(function (r) { return r.json(); }).then(function (data) {
if (!data.ok) { alert(data.error || '下单失败'); return; }
alert('已提交 ' + (data.lots || '') + ' 手');
location.reload();
});
}
var btnLong = document.getElementById('btn-open-long');
var btnShort = document.getElementById('btn-open-short');
var btnCloseL = document.getElementById('btn-close-long');
var btnCloseS = document.getElementById('btn-close-short');
if (btnLong) btnLong.addEventListener('click', function () { postOrder('open', 'long'); });
if (btnShort) btnShort.addEventListener('click', function () { postOrder('open', 'short'); });
if (btnCloseL) btnCloseL.addEventListener('click', function () { postOrder('close', 'long'); });
if (btnCloseS) btnCloseS.addEventListener('click', function () { postOrder('close', 'short'); });
var btnConnect = document.getElementById('btn-ctp-connect');
if (btnConnect) {
btnConnect.addEventListener('click', function () {
btnConnect.disabled = true;
btnConnect.textContent = '连接中…';
fetch('/api/ctp/connect', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{}' })
.then(function (r) { return r.json(); })
.then(function (d) {
if (!d.ok) { alert(d.error || '连接失败'); return; }
location.reload();
})
.finally(function () {
btnConnect.disabled = false;
btnConnect.textContent = '连接 CTP';
});
});
}
setInterval(function () {
fetch('/api/account_snapshot').then(function (r) { return r.json(); }).then(function (d) {
var cap = document.getElementById('cap-display');
if (cap && d.capital != null) cap.textContent = Number(d.capital).toFixed(2);
var badge = document.getElementById('risk-badge');
if (badge && d.risk_status) badge.textContent = d.risk_status.status_label;
var ctpBadge = document.getElementById('ctp-badge');
if (ctpBadge && d.ctp_status) {
ctpBadge.textContent = d.ctp_status.connected ? 'CTP 已连接' : 'CTP 未连接';
ctpBadge.className = 'badge ' + (d.ctp_status.connected ? 'profit' : 'planned');
}
}).catch(function () {});
}, 5000);
scheduleRefresh();
})();
View File
+18
View File
@@ -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
+131
View File
@@ -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)")
+159
View File
@@ -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
+70
View File
@@ -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
+108
View File
@@ -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
+3
View File
@@ -483,6 +483,9 @@
<h1 class="site-title">国内期货 · 交易监控 + 复盘<span class="site-title-sub">FUTURES MONITOR SYSTEM</span></h1>
<button type="button" class="nav-backdrop" id="nav-backdrop" aria-label="关闭菜单" hidden></button>
<nav class="site-nav" id="site-nav">
<a href="{{ url_for('trade_page') }}" class="{% if request.endpoint == 'trade_page' %}active{% endif %}">期货下单</a>
<a href="{{ url_for('recommend_page') }}" class="{% if request.endpoint == 'recommend_page' %}active{% endif %}">品种推荐</a>
<a href="{{ url_for('strategy_page') }}" class="{% if request.endpoint in ('strategy_page', 'strategy_records_page') %}active{% endif %}">策略交易</a>
<a href="{{ url_for('plans') }}" class="{% if request.endpoint == 'plans' %}active{% endif %}">开单计划</a>
<a href="{{ url_for('keys') }}" class="{% if request.endpoint == 'keys' %}active{% endif %}">关键位监控</a>
<a href="{{ url_for('positions') }}" class="{% if request.endpoint == 'positions' %}active{% endif %}">持仓监控</a>
+1 -1
View File
@@ -45,7 +45,7 @@
<div class="market-chart-empty" id="market-chart-empty">请选择合约并点击「查看」</div>
<div class="market-chart-loading" id="market-chart-loading">连接中…</div>
</div>
<p class="hint">数据来源:新浪财经。K 线由后台自动刷新并经 SSE 推送到前端;支持滚轮/拖拽缩放。可视区内自动标注最高/最低价。</p>
<p class="hint">数据来源:新浪财经。拖拽左右平移、滚轮缩放;按住图表上下拖动可平移价格轴。可视区内自动标注最高/最低价。</p>
</div>
<style>
+32
View File
@@ -0,0 +1,32 @@
{% extends "base.html" %}
{% block title %}品种推荐 - 国内期货监控系统{% endblock %}
{% block content %}
<div class="card">
<h2>品种推荐 · 按资金筛选</h2>
<p class="hint">当前权益 <strong class="text-accent">{{ '%.2f'|format(capital) }}</strong> 元({{ trading_mode_label }})。
优先展示可开 1 手且 1% 风险规则下较友好的品种;灰色为保证金不足。</p>
</div>
<div class="card">
<div class="trade-table-wrap">
<table class="trade-table">
<thead>
<tr>
<th>品种</th><th>交易所</th><th>参考价</th><th>1手保证金</th><th>建议最低资金</th><th>状态</th>
</tr>
</thead>
<tbody>
{% for r in rows %}
<tr class="rec-{{ r.status }}">
<td><strong>{{ r.name }}</strong> <span class="text-muted">{{ r.ths }}</span></td>
<td>{{ r.exchange }}</td>
<td>{% if r.price %}{{ r.price }}{% else %}—{% endif %}</td>
<td>{% if r.margin_one_lot %}{{ r.margin_one_lot }}{% else %}—{% endif %}</td>
<td>{% if r.min_capital_one_lot %}{{ r.min_capital_one_lot }}{% else %}—{% endif %}</td>
<td><span class="badge {% if r.status=='ok' %}profit{% elif r.status=='blocked' %}loss{% else %}planned{% endif %}">{{ r.status_label }}</span></td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% endblock %}
+38 -3
View File
@@ -3,13 +3,48 @@
{% block content %}
<div class="card">
<h2>实盘资金</h2>
<h2>交易模式</h2>
<form action="{{ url_for('settings') }}" method="post">
<input type="hidden" name="action" value="trading">
<div class="form-grid" style="max-width:640px">
<div class="field">
<label>交易通道</label>
<select name="trading_mode">
<option value="simulation" {% if trading_mode == 'simulation' %}selected{% endif %}>模拟盘 · SimNowvnpy CTP</option>
<option value="live" {% if trading_mode == 'live' %}selected{% endif %}>实盘 · 期货公司 CTP(后期接入)</option>
</select>
</div>
<div class="field">
<label>计仓模式</label>
<select name="position_sizing_mode">
<option value="risk" {% if position_sizing_mode == 'risk' %}selected{% endif %}>以损定仓</option>
<option value="fixed" {% if position_sizing_mode == 'fixed' %}selected{% endif %}>固定张数</option>
</select>
</div>
<div class="field">
<label>单笔风险比例(以损定仓,%</label>
<input name="risk_percent" type="number" step="0.1" min="0.1" max="100" value="{{ risk_percent }}">
</div>
</div>
<button type="submit" class="btn-primary" style="margin-top:.75rem">保存交易设置</button>
</form>
<p class="hint" style="margin-top:.75rem">
<strong>模拟盘</strong>连接上期 SimNow 仿真柜台(非本地假资金)。在 <code>.env</code> 配置
<code>SIMNOW_USER</code><code>SIMNOW_PASSWORD</code> 等,在「期货下单」页点击连接 CTP。<br>
<strong>实盘</strong>后期配置 <code>CTP_LIVE_*</code> 对接你的期货公司。
</p>
</div>
<div class="card">
<h2>参考资金</h2>
<form action="{{ url_for('settings') }}" method="post" class="form-row">
<input type="hidden" name="action" value="capital">
<input name="live_capital" type="number" step="0.01" min="0" placeholder="实盘资金(元)" value="{{ live_capital }}" style="flex:1;min-width:200px;max-width:320px">
<input name="live_capital" type="number" step="0.01" min="0" placeholder="参考权益(元)" value="{{ live_capital }}" style="flex:1;min-width:200px;max-width:320px">
<button type="submit" class="btn-primary">保存</button>
</form>
<p class="hint" style="margin-top:.75rem">用于持仓监控的风险比例、仓位占比计算,保存在数据库中。</p>
<p class="hint" style="margin-top:.75rem">
CTP 未连接时用于<strong>品种推荐</strong>与以损定仓估算;SimNow/实盘登录成功后自动改用<strong>柜台权益</strong>
</p>
</div>
<div class="card">
+64
View File
@@ -0,0 +1,64 @@
{% extends "base.html" %}
{% block title %}策略交易 - 国内期货监控系统{% endblock %}
{% block content %}
<div class="split-grid">
<div class="card">
<h2>趋势回调</h2>
{% if active_trend %}
<p class="hint">运行中 #{{ active_trend.id }} {{ active_trend.symbol }} {{ active_trend.direction }}
已开 {{ active_trend.lots_open or 0 }}/{{ active_trend.target_lots }} 手</p>
<form id="trend-stop-form" class="form-row">
<input type="hidden" name="plan_id" value="{{ active_trend.id }}">
<button type="button" class="btn-primary" id="btn-trend-stop">结束计划</button>
</form>
{% else %}
<form id="trend-form" class="form-compact">
<div class="form-line line-2">
<div class="symbol-wrap"><input class="symbol-input" name="symbol" placeholder="合约" required><div class="symbol-dropdown"></div></div>
<select name="direction"><option value="long">做多</option><option value="short">做空</option></select>
</div>
<div class="form-line line-3">
<input name="stop_loss" type="number" step="any" placeholder="止损" required>
<input name="add_upper" type="number" step="any" placeholder="补仓边界" required>
<input name="take_profit" type="number" step="any" placeholder="止盈" required>
</div>
<div class="form-line line-2">
<input name="risk_percent" type="number" step="0.1" value="{{ risk_percent }}" placeholder="风险%">
<button type="button" class="btn-primary" id="btn-trend-preview">预览</button>
</div>
</form>
<pre id="trend-preview" class="hint" style="white-space:pre-wrap;margin-top:.75rem"></pre>
<button type="button" class="btn-primary" id="btn-trend-exec" hidden>确认执行首仓</button>
{% endif %}
</div>
<div class="card">
<h2>顺势加仓(滚仓)</h2>
<p class="hint">须先有「下单监控」持仓;最多 3 腿;止盈锁首仓。</p>
{% if monitors %}
<form id="roll-form" class="form-compact">
<select name="monitor_id" required>
{% for m in monitors %}
<option value="{{ m.id }}">{{ m.symbol }} {{ m.direction }} {{ m.lots }}手</option>
{% endfor %}
</select>
<div class="form-line line-2">
<input name="new_stop_loss" type="number" step="any" placeholder="新统一止损" required>
<input name="risk_percent" type="number" step="0.1" value="2" placeholder="总风险%">
</div>
<div class="form-line line-2">
<input name="add_price" type="number" step="any" placeholder="加仓参考价">
<button type="button" class="btn-primary" id="btn-roll-preview">预览</button>
</div>
<pre id="roll-preview" class="hint" style="white-space:pre-wrap"></pre>
<button type="button" class="btn-primary" id="btn-roll-exec" hidden>执行滚仓</button>
</form>
{% else %}
<p class="empty-hint">请先在「期货下单」开仓并建立监控。</p>
{% endif %}
</div>
</div>
<p class="hint"><a href="{{ url_for('strategy_records_page') }}">策略交易记录 →</a></p>
{% endblock %}
{% block extra_js %}
<script src="{{ url_for('static', filename='js/strategy.js') }}"></script>
{% endblock %}
+22
View File
@@ -0,0 +1,22 @@
{% extends "base.html" %}
{% block title %}策略记录 - 国内期货监控系统{% endblock %}
{% block content %}
<div class="split-grid">
<div class="card card-scroll">
<h2>趋势回调</h2>
{% if trend_rows %}
<ul class="list">{% for r in trend_rows %}
<li class="list-item"><span>{{ r.symbol }} {{ r.result_label }} · {{ r.closed_at or r.created_at }}</span></li>
{% endfor %}</ul>
{% else %}<p class="empty-hint">暂无记录</p>{% endif %}
</div>
<div class="card card-scroll">
<h2>顺势加仓</h2>
{% if roll_rows %}
<ul class="list">{% for r in roll_rows %}
<li class="list-item"><span>{{ r.symbol }} {{ r.result_label }} · {{ r.closed_at or r.created_at }}</span></li>
{% endfor %}</ul>
{% else %}<p class="empty-hint">暂无记录</p>{% endif %}
</div>
</div>
{% endblock %}
+96
View File
@@ -0,0 +1,96 @@
{% extends "base.html" %}
{% block title %}期货下单 - 国内期货监控系统{% endblock %}
{% block extra_css %}
<link rel="stylesheet" href="{{ url_for('static', filename='css/trade.css') }}">
{% endblock %}
{% block content %}
<div class="trade-page">
<div class="trade-top-bar">
<span class="badge dir">{{ trading_mode_label }}</span>
<span class="badge {% if ctp_status.connected %}profit{% else %}planned{% endif %}" id="ctp-badge">
{% if ctp_status.connected %}CTP 已连接{% else %}CTP 未连接{% endif %}
</span>
<span class="badge {% if risk_status.can_trade %}profit{% else %}loss{% endif %}" id="risk-badge">{{ risk_status.status_label }}</span>
<span class="text-muted">权益 <strong id="cap-display">{{ '%.2f'|format(capital) }}</strong></span>
<button type="button" class="btn-primary" id="btn-ctp-connect" style="padding:.4rem .9rem;font-size:.8rem">连接 CTP</button>
</div>
<div class="card trade-order-card">
<div class="trade-tabs">
<span class="active">期货下单</span>
<a href="{{ url_for('recommend_page') }}">品种推荐</a>
<a href="{{ url_for('strategy_page') }}">策略交易</a>
</div>
<div class="trade-input-row">
<div class="symbol-wrap trade-field">
<label class="text-label">品种</label>
<input type="text" id="trade-symbol" class="symbol-input" placeholder="主力合约 rb2610" autocomplete="off" value="">
<div class="symbol-dropdown"></div>
<div class="symbol-selected" id="sym-selected"></div>
</div>
<div class="trade-field">
<label class="text-label">手数</label>
<input type="number" id="trade-lots" min="1" step="1" value="1">
</div>
<div class="trade-field">
<label class="text-label">价格</label>
<input type="number" id="trade-price" step="any" placeholder="限价">
</div>
</div>
<div id="risk-fields" class="trade-risk-row" {% if sizing_mode != 'risk' %}hidden{% endif %}>
<div class="trade-field"><label class="text-label">止损</label><input type="number" id="trade-sl" step="any"></div>
<div class="trade-field"><label class="text-label">止盈</label><input type="number" id="trade-tp" step="any"></div>
</div>
<div class="trade-btn-row">
<button type="button" class="trade-btn long" id="btn-open-long">
<span class="btn-price" id="px-long"></span>
<span class="btn-label">加多</span>
<span class="btn-sub" id="max-long">≤—</span>
</button>
<button type="button" class="trade-btn lock" id="btn-open-short">
<span class="btn-price" id="px-short"></span>
<span class="btn-label">加空</span>
<span class="btn-sub" id="max-short">≤—</span>
</button>
<button type="button" class="trade-btn close" id="btn-close-long">
<span class="btn-sub">平多</span>
<span class="btn-label">平多</span>
<span class="btn-sub" id="pos-long">≤0</span>
</button>
<button type="button" class="trade-btn close" id="btn-close-short">
<span class="btn-sub">平空</span>
<span class="btn-label">平空</span>
<span class="btn-sub" id="pos-short">≤0</span>
</button>
</div>
<div class="trade-footer" id="trade-footer">
<p class="hint">SimNow 模拟盘:请先连接 CTP。输入品种与手数后显示跳动价值与价格精度。</p>
{% if ctp_status.last_error %}<p class="text-loss" style="font-size:.78rem">{{ ctp_status.last_error }}</p>{% endif %}
</div>
</div>
{% if ctp_positions %}
<div class="card">
<h2>CTP 持仓(SimNow / 柜台)</h2>
<ul class="list">
{% for p in ctp_positions %}
<li class="list-item">
<span>{{ p.symbol }} {{ '多' if p.direction=='long' else '空' }} {{ p.lots }}手 @ {{ p.avg_price }}</span>
</li>
{% endfor %}
</ul>
</div>
{% endif %}
</div>
{% endblock %}
{% block extra_js %}
<script>
window.TRADE_SIZING_MODE = {{ sizing_mode|tojson }};
window.TRADE_RISK_PERCENT = {{ risk_percent }};
</script>
<script src="{{ url_for('static', filename='js/trade.js') }}"></script>
{% endblock %}
+48
View File
@@ -0,0 +1,48 @@
"""交易上下文:设置读取、资金、模式。"""
from __future__ import annotations
from typing import Callable, Optional
TRADING_MODE_SIM = "simulation" # SimNow CTP
TRADING_MODE_LIVE = "live" # 期货公司 CTP
def get_trading_mode(get_setting: Callable[[str, str], str]) -> str:
m = (get_setting("trading_mode", TRADING_MODE_SIM) or TRADING_MODE_SIM).strip().lower()
return m if m in (TRADING_MODE_SIM, TRADING_MODE_LIVE) else TRADING_MODE_SIM
def get_sizing_mode(get_setting: Callable[[str, str], str]) -> str:
from position_sizing import normalize_sizing_mode
return normalize_sizing_mode(get_setting("position_sizing_mode", "risk"))
def get_risk_percent(get_setting: Callable[[str, str], str]) -> float:
try:
return max(0.1, float(get_setting("risk_percent", "1") or 1))
except (TypeError, ValueError):
return 1.0
def get_account_capital(conn, get_setting: Callable[[str, str], str]) -> float:
"""优先 SimNow/期货公司 CTP 权益;未连接时用设置中的参考资金。"""
del conn
mode = get_trading_mode(get_setting)
try:
from vnpy_bridge import ctp_status, get_ctp_balance
st = ctp_status(mode)
if st.get("connected"):
bal = get_ctp_balance(mode)
if bal and bal > 0:
return float(bal)
except Exception:
pass
try:
return float(get_setting("live_capital", "0") or 0)
except (TypeError, ValueError):
return 0.0
def trading_mode_label(get_setting: Callable[[str, str], str]) -> str:
return "SimNow 模拟" if get_trading_mode(get_setting) == TRADING_MODE_SIM else "期货公司实盘"
+319
View File
@@ -0,0 +1,319 @@
"""CTP 执行层:模拟盘 → SimNow;实盘 → 期货公司(vnpy_ctp)。"""
from __future__ import annotations
import logging
import os
import threading
import time
from typing import Any, Optional
from ctp_symbol import ths_to_vnpy_symbol, to_vnpy_exchange
logger = logging.getLogger(__name__)
GATEWAY_NAME = "CTP"
_bridge: Optional["CtpBridge"] = None
_bridge_lock = threading.Lock()
def _env(key: str, default: str = "") -> str:
return (os.getenv(key) or default).strip()
def _simnow_setting() -> dict[str, str]:
"""SimNow 7×24 仿真默认前置(可在 .env 覆盖)。"""
return {
"用户名": _env("SIMNOW_USER"),
"密码": _env("SIMNOW_PASSWORD"),
"经纪商代码": _env("SIMNOW_BROKER_ID", "9999"),
"交易服务器": _env("SIMNOW_TD_ADDRESS", "tcp://180.168.146.187:10201"),
"行情服务器": _env("SIMNOW_MD_ADDRESS", "tcp://180.168.146.187:10211"),
"产品名称": _env("SIMNOW_APP_ID", "simnow_client_test"),
"授权编码": _env("SIMNOW_AUTH_CODE", "0000000000000000"),
"产品信息": _env("SIMNOW_PRODUCT_INFO", "simnow_client_test"),
}
def _live_setting() -> dict[str, str]:
return {
"用户名": _env("CTP_LIVE_USER"),
"密码": _env("CTP_LIVE_PASSWORD"),
"经纪商代码": _env("CTP_LIVE_BROKER_ID"),
"交易服务器": _env("CTP_LIVE_TD_ADDRESS"),
"行情服务器": _env("CTP_LIVE_MD_ADDRESS"),
"产品名称": _env("CTP_LIVE_APP_ID"),
"授权编码": _env("CTP_LIVE_AUTH_CODE"),
"产品信息": _env("CTP_LIVE_PRODUCT_INFO"),
}
def _setting_for_mode(mode: str) -> dict[str, str]:
return _simnow_setting() if mode == "simulation" else _live_setting()
def _mode_label(mode: str) -> str:
return "SimNow 模拟" if mode == "simulation" else "期货公司实盘"
class CtpBridge:
def __init__(self) -> None:
self._engine = None
self._ee = None
self._connected_mode: Optional[str] = None
self._last_error: str = ""
self._connect_lock = threading.Lock()
self._init_engine()
def _init_engine(self) -> None:
try:
from vnpy.event import EventEngine
from vnpy.trader.engine import MainEngine
from vnpy_ctp import CtpGateway
self._ee = EventEngine()
self._engine = MainEngine(self._ee)
self._engine.add_gateway(CtpGateway)
except ImportError:
self._last_error = "未安装 vnpy / vnpy_ctp,请 pip install vnpy vnpy_ctp"
except Exception as exc:
self._last_error = str(exc)
def available(self) -> bool:
return self._engine is not None
@property
def last_error(self) -> str:
return self._last_error
@property
def connected_mode(self) -> Optional[str]:
return self._connected_mode
def status(self, mode: str) -> dict[str, Any]:
st = _setting_for_mode(mode)
missing = [k for k in ("用户名", "密码", "交易服务器") if not st.get(k)]
return {
"vnpy_installed": self.available(),
"connected": self._connected_mode == mode,
"connected_mode": self._connected_mode,
"mode_label": _mode_label(mode),
"missing_config": missing,
"last_error": self._last_error,
"broker_id": st.get("经纪商代码", ""),
"td_address": st.get("交易服务器", ""),
}
def connect(self, mode: str, *, force: bool = False) -> None:
if not self._engine:
raise RuntimeError(self._last_error or "vnpy 引擎未初始化")
if self._connected_mode == mode and not force:
return
setting = _setting_for_mode(mode)
if not setting.get("用户名") or not setting.get("密码"):
raise ValueError(
f"{_mode_label(mode)}:请在 .env 配置 "
f"{'SIMNOW_USER / SIMNOW_PASSWORD' if mode == 'simulation' else 'CTP_LIVE_USER / CTP_LIVE_PASSWORD'}"
)
if not setting.get("交易服务器"):
raise ValueError(f"{_mode_label(mode)}:未配置交易服务器地址")
with self._connect_lock:
if self._connected_mode and self._connected_mode != mode:
try:
self._engine.close()
except Exception:
pass
self._connected_mode = None
time.sleep(1)
self._engine.connect(setting, GATEWAY_NAME)
# 等待登录与结算信息
for _ in range(30):
accounts = self._engine.get_all_accounts()
if accounts:
self._connected_mode = mode
self._last_error = ""
logger.info("CTP 已连接 [%s] account=%s", mode, len(accounts))
return
time.sleep(0.5)
self._last_error = "CTP 连接超时,请检查 SimNow 账号、前置地址与交易时段"
raise RuntimeError(self._last_error)
def ensure_connected(self, mode: str) -> None:
if self._connected_mode != mode:
self.connect(mode)
def get_account(self) -> dict[str, Any]:
if not self._engine:
return {}
accounts = self._engine.get_all_accounts()
if not accounts:
return {}
acc = accounts[0]
return {
"balance": float(getattr(acc, "balance", 0) or 0),
"available": float(getattr(acc, "available", 0) or 0),
"frozen": float(getattr(acc, "frozen", 0) or 0),
"accountid": getattr(acc, "accountid", ""),
}
def list_positions(self) -> list[dict[str, Any]]:
if not self._engine:
return []
out: list[dict[str, Any]] = []
for pos in self._engine.get_all_positions():
vol = int(getattr(pos, "volume", 0) or 0)
if vol <= 0:
continue
direction = getattr(pos, "direction", None)
d = "long"
if direction is not None and str(direction).endswith("SHORT"):
d = "short"
elif direction is not None and "" in str(direction):
d = "short"
sym = getattr(pos, "symbol", "") or ""
out.append({
"symbol": sym,
"direction": d,
"lots": vol,
"avg_price": float(getattr(pos, "price", 0) or 0),
"pnl": float(getattr(pos, "pnl", 0) or 0),
})
return out
def send_order(
self,
*,
ths_code: str,
offset: str,
direction: str,
lots: int,
price: float,
) -> str:
from vnpy.trader.constant import Direction, Offset, OrderType
from vnpy.trader.object import OrderRequest
if not self._engine:
raise RuntimeError("CTP 未初始化")
sym, ex_name = ths_to_vnpy_symbol(ths_code)
exchange = to_vnpy_exchange(ex_name)
lots = max(1, int(lots))
price = float(price)
offset = (offset or "open").lower()
direction = (direction or "long").lower()
if offset in ("open", "open_long", "open_short"):
d = Direction.LONG if direction == "long" or offset == "open_long" else Direction.SHORT
off = Offset.OPEN
elif offset in ("close", "close_long", "close_short"):
# 平多 = 卖;平空 = 买
if direction == "long" or offset == "close_long":
d = Direction.SHORT
else:
d = Direction.LONG
off = Offset.CLOSE
else:
raise ValueError(f"未知开平: {offset}")
req = OrderRequest(
symbol=sym,
exchange=exchange,
direction=d,
type=OrderType.LIMIT,
volume=lots,
price=price,
offset=off,
)
vt_orderid = self._engine.send_order(req, GATEWAY_NAME)
if not vt_orderid:
raise RuntimeError("CTP 拒单或未返回委托号")
return str(vt_orderid)
def get_bridge() -> CtpBridge:
global _bridge
with _bridge_lock:
if _bridge is None:
_bridge = CtpBridge()
return _bridge
def try_init_vnpy(_settings: dict | None = None) -> bool:
return get_bridge().available()
def vnpy_available() -> bool:
return get_bridge().available()
def ctp_connect(mode: str, *, force: bool = False) -> dict[str, Any]:
b = get_bridge()
b.connect(mode, force=force)
return b.status(mode)
def ctp_status(mode: str) -> dict[str, Any]:
return get_bridge().status(mode)
def ctp_get_account(mode: str) -> dict[str, Any]:
b = get_bridge()
b.ensure_connected(mode)
return b.get_account()
def ctp_list_positions(mode: str) -> list[dict[str, Any]]:
b = get_bridge()
b.ensure_connected(mode)
return b.list_positions()
def get_ctp_balance(mode: str) -> Optional[float]:
try:
acc = ctp_get_account(mode)
bal = acc.get("balance")
return float(bal) if bal else None
except Exception as exc:
logger.debug("get_ctp_balance: %s", exc)
return None
def execute_order(
conn,
*,
mode: str,
offset: str,
symbol: str,
direction: str,
lots: int,
price: float,
settings: dict | None = None,
) -> dict[str, Any]:
"""统一下单:simulation=SimNowlive=期货公司 CTP。"""
del conn, settings
if mode not in ("simulation", "live"):
raise ValueError("未知交易模式")
if not vnpy_available():
raise ValueError(
"请先安装 vnpy 与 vnpy_ctppip install vnpy vnpy_ctp\n"
f"模拟盘需配置 .env 中 SIMNOW_USER / SIMNOW_PASSWORD 等"
)
b = get_bridge()
b.ensure_connected(mode)
order_id = b.send_order(
ths_code=symbol,
offset=offset,
direction=direction,
lots=lots,
price=price,
)
return {
"order_id": order_id,
"mode": mode,
"mode_label": _mode_label(mode),
"symbol": symbol,
"lots": lots,
"price": price,
}