From 528d9811e3ad7731f993a81c1e380bbab2e23a06 Mon Sep 17 00:00:00 2001 From: dekun Date: Wed, 24 Jun 2026 12:19:56 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=AF=BC=E8=88=AA=E5=BC=80=E5=85=B3?= =?UTF-8?q?=E4=B8=8E=20CTP=20=E6=9F=9C=E5=8F=B0=E6=89=8B=E7=BB=AD=E8=B4=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 系统设置可开关五类导航;手续费默认从 CTP 查询同步,本地/AKShare 作离线兜底;补充 FEES.md。 Co-authored-by: Cursor --- app.py | 79 +++++++++++++++++++++++++-- ctp_fee_sync.py | 103 +++++++++++++++++++++++++++++++++++ docs/DEPLOY.md | 1 + docs/FEATURES.md | 4 +- docs/FEES.md | 74 ++++++++++++++++++++++++++ fee_specs.py | 115 ++++++++++++++++++++++++++++++---------- fee_sync.py | 1 + install_trading.py | 4 +- nav_settings.py | 46 ++++++++++++++++ templates/base.html | 10 ++-- templates/fees.html | 48 ++++++++++++++--- templates/settings.html | 17 ++++++ vnpy_bridge.py | 69 ++++++++++++++++++++++++ 13 files changed, 523 insertions(+), 48 deletions(-) create mode 100644 ctp_fee_sync.py create mode 100644 docs/FEES.md create mode 100644 nav_settings.py diff --git a/app.py b/app.py index b130e1a..8306a58 100644 --- a/app.py +++ b/app.py @@ -22,17 +22,21 @@ from flask import ( ) from werkzeug.security import check_password_hash, generate_password_hash +from functools import wraps + from symbols import search_symbols, ths_to_codes, list_main_contracts_grouped, refresh_main_index from contract_specs import calc_position_metrics from fee_specs import ( calc_fee_breakdown, calc_round_trip_fee, get_fee_multiplier, + get_fee_source_mode, list_all_fee_rates, load_fee_rates_from_json, upsert_fee_rate, ) from fee_sync import sync_fees_from_akshare +from nav_settings import NAV_TOGGLES, get_nav_items, nav_enabled, save_nav_items from contract_profile import get_contract_profile from stats_engine import STATS_VIEWS, load_stats_cache, refresh_stats_cache from kline_store import ensure_kline_tables @@ -183,6 +187,28 @@ def set_setting(key: str, value: str): conn.close() +def require_nav(key: str): + """导航项关闭时拒绝访问对应页面。""" + def decorator(f): + @wraps(f) + def wrapped(*args, **kwargs): + if not nav_enabled(get_setting, key): + flash("该页面已在系统设置中关闭") + return redirect(url_for("positions")) + return f(*args, **kwargs) + return wrapped + return decorator + + +@app.context_processor +def inject_globals(): + return {"nav_items": get_nav_items(get_setting)} + + +def _trading_mode() -> str: + return (get_setting("trading_mode", "simulation") or "simulation").strip() + + def touch_stats_cache(): try: conn = get_db() @@ -311,6 +337,13 @@ def init_db(): (key TEXT PRIMARY KEY, data_json TEXT NOT NULL, updated_at TEXT NOT NULL)''') + for sql in ( + "ALTER TABLE fee_rates ADD COLUMN source TEXT DEFAULT 'local'", + ): + try: + c.execute(sql) + except sqlite3.OperationalError: + pass ensure_kline_tables(conn) init_strategy_tables(conn) from risk.account_risk_lib import ensure_account_risk_schema @@ -339,6 +372,8 @@ def init_db(): set_setting("position_sizing_mode", "risk") if not get_setting("risk_percent"): set_setting("risk_percent", "1") + if not get_setting("fee_source_mode"): + set_setting("fee_source_mode", "ctp") conn = get_db() fee_cnt = conn.execute("SELECT COUNT(*) FROM fee_rates").fetchone()[0] conn.close() @@ -768,6 +803,7 @@ def api_position_live(): close_est = mark if mark is not None else entry fee_info = calc_fee_breakdown( sym, entry, close_est, lots, r["open_time"] or "", now_iso, + trading_mode=_trading_mode(), ) est_net = None if metrics.get("float_pnl") is not None: @@ -796,11 +832,14 @@ def api_position_live(): @login_required def index(): - return redirect(url_for("plans")) + if nav_enabled(get_setting, "plans"): + return redirect(url_for("plans")) + return redirect(url_for("positions")) @app.route("/plans") @login_required +@require_nav("plans") def plans(): today = today_str() start = request.args.get("start", "") @@ -962,7 +1001,7 @@ def close_position(pid): capital = float(get_setting("live_capital", "0") or 0) metrics = calc_position_metrics(direction, entry, sl, tp, lots, close_price, capital, sym) pnl = metrics.get("float_pnl") or 0.0 - fee = calc_round_trip_fee(sym, entry, close_price, lots, open_time, close_time) + fee = calc_round_trip_fee(sym, entry, close_price, lots, open_time, close_time, trading_mode=_trading_mode()) pnl_net = round(pnl - fee, 2) result = classify_close_result(direction, close_price, sl, tp) minutes = holding_to_minutes(open_time, close_time) @@ -1196,6 +1235,7 @@ def add_review(): gross_pnl = spec_mult.get("float_pnl") fee = calc_round_trip_fee( symbol, entry_price or 0, close_price or 0, lots, open_time, close_time, + trading_mode=_trading_mode(), ) pnl_net = round((gross_pnl or 0) - fee, 2) if gross_pnl is not None else None @@ -1337,6 +1377,7 @@ def api_stats_refresh(): @app.route("/market") @login_required +@require_nav("market") def market_page(): symbol = request.args.get("symbol", "").strip() period = request.args.get("period", "15m").strip() @@ -1425,6 +1466,7 @@ def api_market_quote(): @app.route("/contract") @login_required +@require_nav("contract") def contract_profile_page(): symbol = request.args.get("symbol", "").strip() profile = None @@ -1462,10 +1504,23 @@ def api_contract_profile(): @app.route("/fees", methods=["GET", "POST"]) @login_required +@require_nav("fees") def fees(): + from trading_context import get_trading_mode + from ctp_fee_sync import sync_fees_from_ctp + from vnpy_bridge import ctp_status + + mode = get_trading_mode(get_setting) if request.method == "POST": action = request.form.get("action") - if action == "multiplier": + if action == "fee_source": + fs = request.form.get("fee_source_mode", "ctp").strip() + set_setting("fee_source_mode", fs if fs in ("ctp", "local") else "ctp") + flash("手续费数据源已保存") + elif action == "sync_ctp": + count, msg = sync_fees_from_ctp(mode) + flash(msg) + elif action == "multiplier": try: mult = float(request.form.get("fee_multiplier", "2")) if mult < 0: @@ -1496,13 +1551,22 @@ def fees(): "close_yesterday_ratio": float(request.form.get("close_yesterday_ratio") or 0), "close_today_fixed": float(request.form.get("close_today_fixed") or 0), "close_today_ratio": float(request.form.get("close_today_ratio") or 0), + "source": "manual", }) flash(f"已保存 {product} 费率") return redirect(url_for("fees")) rates = list_all_fee_rates() multiplier = get_setting("fee_multiplier", "2") - return render_template("fees.html", rates=rates, multiplier=multiplier) + fee_source_mode = get_fee_source_mode() + ctp_st = ctp_status(mode) + return render_template( + "fees.html", + rates=rates, + multiplier=multiplier, + fee_source_mode=fee_source_mode, + ctp_connected=bool(ctp_st.get("connected")), + ) @app.route("/settings", methods=["GET", "POST"]) @@ -1541,6 +1605,10 @@ def settings(): flash("风险比例无效") return redirect(url_for("settings")) flash("交易模式已保存") + elif action == "nav": + items = {k: request.form.get(f"nav_{k}") == "on" for k in NAV_TOGGLES} + save_nav_items(set_setting, items) + flash("导航显示已保存") elif action == "password": old_p = request.form.get("old_password", "") new_p = request.form.get("new_password", "") @@ -1569,12 +1637,15 @@ def settings(): trading_mode=get_setting("trading_mode", "simulation"), position_sizing_mode=get_setting("position_sizing_mode", "risk"), risk_percent=get_setting("risk_percent", "1"), + nav_items=get_nav_items(get_setting), + nav_toggles=NAV_TOGGLES, ) install_trading( app, login_required=login_required, + require_nav=require_nav, get_db=get_db, get_setting=get_setting, set_setting=set_setting, diff --git a/ctp_fee_sync.py b/ctp_fee_sync.py new file mode 100644 index 0000000..f4d6da0 --- /dev/null +++ b/ctp_fee_sync.py @@ -0,0 +1,103 @@ +"""从 CTP 柜台同步手续费率(SimNow / 期货公司)。""" +from __future__ import annotations + +import logging +import re +import time +from typing import Optional + +from contract_specs import get_contract_spec +from fee_specs import upsert_fee_rate +from vnpy_bridge import get_bridge + +logger = logging.getLogger(__name__) + + +def _product_from_instrument(instrument_id: str) -> str: + m = re.match(r"^([A-Za-z]+)", instrument_id or "") + return m.group(1).lower() if m else "" + + +def ctp_commission_to_fee_fields(data: dict, ths_code: str) -> dict: + """CTP OnRspQryInstrumentCommissionRate → fee_rates 字段。""" + mult = int(get_contract_spec(ths_code)["mult"]) + exchange = str(data.get("ExchangeID") or "").strip() + return { + "exchange": exchange, + "mult": mult, + "open_fixed": float(data.get("OpenRatioByVolume") or 0), + "open_ratio": float(data.get("OpenRatioByMoney") or 0), + "close_yesterday_fixed": float(data.get("CloseRatioByVolume") or 0), + "close_yesterday_ratio": float(data.get("CloseRatioByMoney") or 0), + "close_today_fixed": float(data.get("CloseTodayRatioByVolume") or 0), + "close_today_ratio": float(data.get("CloseTodayRatioByMoney") or 0), + "source": "ctp", + } + + +def sync_fees_from_ctp(mode: str, *, max_symbols: int = 80) -> tuple[int, str]: + """CTP 已连接时,按主力合约查询手续费并写入 fee_rates(source=ctp)。""" + bridge = get_bridge() + if not bridge.available(): + return 0, "vnpy 未安装" + if bridge.connected_mode != mode: + return 0, "请先连接 CTP" + if not bridge.ping(): + return 0, "CTP 连接无效,请重连" + + from symbols import list_main_contracts_grouped + + mains = list_main_contracts_grouped() + symbols: list[str] = [] + for g in mains: + ths = (g.get("ths") or g.get("code") or "").strip() + if ths: + symbols.append(ths) + symbols = symbols[:max_symbols] + + if not symbols: + return 0, "无主力合约列表" + + seen: set[str] = set() + ok = 0 + errors = 0 + for ths in symbols: + product = _product_from_instrument(ths) + if not product or product in seen: + continue + seen.add(product) + try: + raw = bridge.query_instrument_commission(ths, mode=mode) + if not raw: + errors += 1 + continue + fields = ctp_commission_to_fee_fields(raw, ths) + upsert_fee_rate(product, fields) + ok += 1 + time.sleep(0.35) + except Exception as exc: + logger.debug("CTP fee sync %s: %s", ths, exc) + errors += 1 + + if ok == 0: + return 0, f"CTP 未返回手续费率(失败 {errors} 次),请确认柜台支持查询" + msg = f"已从 CTP 同步 {ok} 个品种手续费" + if errors: + msg += f"({errors} 个跳过)" + return ok, msg + + +def sync_fee_for_symbol(mode: str, ths_code: str) -> Optional[dict]: + """单品种按需从 CTP 拉取并缓存。""" + bridge = get_bridge() + if bridge.connected_mode != mode or not bridge.ping(): + return None + raw = bridge.query_instrument_commission(ths_code, mode=mode) + if not raw: + return None + product = _product_from_instrument(ths_code) + if not product: + return None + fields = ctp_commission_to_fee_fields(raw, ths_code) + upsert_fee_rate(product, fields) + return fields diff --git a/docs/DEPLOY.md b/docs/DEPLOY.md index c6a4c64..258eff4 100644 --- a/docs/DEPLOY.md +++ b/docs/DEPLOY.md @@ -403,5 +403,6 @@ pm2 restart qihuo - [功能说明文档](./FEATURES.md) - [SimNow 注册与接入说明](./SIMNOW.md) +- [手续费与导航设置](./FEES.md) - [交易与 SimNow 配置](./TRADING.md) - [README](../README.md) diff --git a/docs/FEATURES.md b/docs/FEATURES.md index 548eefe..9f9ffea 100644 --- a/docs/FEATURES.md +++ b/docs/FEATURES.md @@ -304,7 +304,9 @@ API:`GET /api/contract_profile?symbol=rb2510` 返回 JSON。 | `trade_logs` | 平仓交易记录(含 fee、pnl_net) | | `trade_records` | 计划/关键位自动止盈止损记录 | | `review_records` | 复盘记录(含 fee、pnl_net) | -| `fee_rates` | 品种手续费本地配置 | +| `fee_rates` | 品种手续费(`source`:ctp / akshare / json / manual) | + +手续费默认 **CTP 柜台** 费率,见 [FEES.md](./FEES.md)。 数据库文件:`futures.db`(项目根目录,运行后生成)。 diff --git a/docs/FEES.md b/docs/FEES.md new file mode 100644 index 0000000..62c16dd --- /dev/null +++ b/docs/FEES.md @@ -0,0 +1,74 @@ +# 手续费与导航设置 + +## 手续费数据源 + +| 模式 | 说明 | +|------|------| +| **CTP 柜台**(默认) | 连接 SimNow/实盘 CTP 后,通过 `ReqQryInstrumentCommissionRate` 查询柜台费率并缓存到 `fee_rates`(`source=ctp`) | +| **本地 / AKShare** | 使用 `data/fee_rates.json` 或 AKShare 交易所参考表 × 倍率,仅作离线估算 | + +### 计算公式 + +``` +单边手续费 = 固定(元/手) × 手数 + 比例 × 成交价 × 合约乘数 × 手数 +往返手续费 = 开仓费 + 平仓费(同日持仓用平今,否则平昨) +``` + +CTP 返回字段映射: + +| CTP 字段 | 本地字段 | +|----------|----------| +| OpenRatioByVolume | open_fixed | +| OpenRatioByMoney | open_ratio | +| CloseRatioByVolume | close_yesterday_fixed | +| CloseRatioByMoney | close_yesterday_ratio | +| CloseTodayRatioByVolume | close_today_fixed | +| CloseTodayRatioByMoney | close_today_ratio | + +### 同步时机 + +1. **连接 CTP 成功后** — 后台自动同步主力合约费率(约 60 个品种) +2. **手续费配置页** — 点击「从 CTP 同步费率」 +3. **按需查询** — 计算某品种手续费时,若缓存无 CTP 费率则单品种查询 + +### 配置路径 + +- 系统设置 → **手续费配置**(可在导航中开关显示) +- 选择「计费依据」→ 保存 +- CTP 已连接时点击「从 CTP 同步费率」 + +--- + +## 导航显示开关 + +**系统设置 → 导航显示** 可单独开关以下顶栏入口: + +| 开关 key | 菜单名 | +|----------|--------| +| `fees` | 手续费配置 | +| `contract` | 品种简介 | +| `plans` | 开单计划 | +| `market` | 行情K线 | +| `strategy` | 策略交易 | + +关闭后: + +- 顶栏不显示该链接 +- 直接访问对应 URL 会提示并跳转到 **持仓监控** + +始终显示的入口:持仓监控、关键位监控、交易记录与复盘、统计分析、系统设置。 + +设置保存在 SQLite `settings.nav_items`(JSON)。 + +--- + +## 相关文件 + +| 文件 | 说明 | +|------|------| +| `fee_specs.py` | 费率查询与计算 | +| `ctp_fee_sync.py` | CTP 费率同步 | +| `nav_settings.py` | 导航开关 | +| `vnpy_bridge.py` | CTP 连接与费率查询 | + +详见 [DEPLOY.md](./DEPLOY.md)、[TRADING.md](./TRADING.md)。 diff --git a/fee_specs.py b/fee_specs.py index 5559edc..520dbc6 100644 --- a/fee_specs.py +++ b/fee_specs.py @@ -1,4 +1,4 @@ -"""期货手续费:本地费率表 + 开平合计估算(模拟盘参考)。""" +"""期货手续费:优先 CTP 柜台费率,本地/AKShare 为离线兜底。""" import json import os import re @@ -51,31 +51,79 @@ def get_fee_multiplier() -> float: return 2.0 -def get_fee_spec(ths_code: str) -> dict: +def get_fee_source_mode() -> str: + """ctp=优先柜台同步费率;local=本地/AKShare 表。""" + conn = _get_db() + row = conn.execute( + "SELECT value FROM settings WHERE key='fee_source_mode'" + ).fetchone() + conn.close() + mode = (row["value"] if row else "ctp") or "ctp" + return mode if mode in ("ctp", "local") else "ctp" + + +def _row_to_spec(row, mult: int) -> dict: + return { + "product": row["product"], + "exchange": row["exchange"] or "", + "mult": int(row["mult"] or mult), + "open_fixed": float(row["open_fixed"] or 0), + "open_ratio": float(row["open_ratio"] or 0), + "close_yesterday_fixed": float(row["close_yesterday_fixed"] or 0), + "close_yesterday_ratio": float(row["close_yesterday_ratio"] or 0), + "close_today_fixed": float(row["close_today_fixed"] or 0), + "close_today_ratio": float(row["close_today_ratio"] or 0), + "source": row["source"] if "source" in row.keys() else "local", + } + + +def get_fee_spec(ths_code: str, *, trading_mode: str = "simulation") -> dict: product = product_from_code(ths_code) if not product: spec = get_contract_spec(ths_code) - return {**DEFAULT_FEE, "mult": spec["mult"], "product": "", "exchange": ""} - - conn = _get_db() - row = conn.execute( - "SELECT * FROM fee_rates WHERE product=?", (product,) - ).fetchone() - conn.close() + return {**DEFAULT_FEE, "mult": spec["mult"], "product": "", "exchange": "", "source": "default"} mult = get_contract_spec(ths_code)["mult"] - if row: - return { - "product": product, - "exchange": row["exchange"] or "", - "mult": int(row["mult"] or mult), - "open_fixed": float(row["open_fixed"] or 0), - "open_ratio": float(row["open_ratio"] or 0), - "close_yesterday_fixed": float(row["close_yesterday_fixed"] or 0), - "close_yesterday_ratio": float(row["close_yesterday_ratio"] or 0), - "close_today_fixed": float(row["close_today_fixed"] or 0), - "close_today_ratio": float(row["close_today_ratio"] or 0), - } + source_mode = get_fee_source_mode() + conn = _get_db() + + if source_mode == "ctp": + row = conn.execute( + "SELECT * FROM fee_rates WHERE product=? AND source='ctp'", + (product,), + ).fetchone() + if not row: + row = conn.execute( + "SELECT * FROM fee_rates WHERE product=? ORDER BY CASE source WHEN 'ctp' THEN 0 ELSE 1 END", + (product,), + ).fetchone() + conn.close() + if row: + return _row_to_spec(row, mult) + # 按需向 CTP 查询 + try: + from ctp_fee_sync import sync_fee_for_symbol + fields = sync_fee_for_symbol(trading_mode, ths_code) + if fields: + return {"product": product, **fields} + except Exception: + pass + conn = _get_db() + row = conn.execute( + "SELECT * FROM fee_rates WHERE product=?", (product,) + ).fetchone() + conn.close() + if row: + spec = _row_to_spec(row, mult) + spec["source"] = spec.get("source") or "local_fallback" + return spec + else: + row = conn.execute( + "SELECT * FROM fee_rates WHERE product=?", (product,) + ).fetchone() + conn.close() + if row: + return _row_to_spec(row, mult) if product in _INDEX_PRODUCTS: return { @@ -95,6 +143,7 @@ def get_fee_spec(ths_code: str) -> dict: "exchange": "", "mult": mult, **DEFAULT_FEE, + "source": "default", } @@ -126,10 +175,11 @@ def calc_round_trip_fee( lots: float, open_time: str = "", close_time: str = "", + trading_mode: str = "simulation", ) -> float: if not entry_price or not close_price: return 0.0 - spec = get_fee_spec(ths_code) + spec = get_fee_spec(ths_code, trading_mode=trading_mode) mult = spec["mult"] lots = lots or 1.0 @@ -157,8 +207,9 @@ def calc_fee_breakdown( lots: float, open_time: str = "", close_time: str = "", + trading_mode: str = "simulation", ) -> dict: - spec = get_fee_spec(ths_code) + spec = get_fee_spec(ths_code, trading_mode=trading_mode) mult = spec["mult"] lots = lots or 1.0 open_fee = calc_side_fee( @@ -184,6 +235,7 @@ def calc_fee_breakdown( "close_type": close_type, "total_fee": total, "same_day": same_day, + "fee_source": spec.get("source", "local"), } @@ -204,8 +256,8 @@ def load_fee_rates_from_json(path: Optional[str] = None) -> int: (product, exchange, mult, open_fixed, open_ratio, close_yesterday_fixed, close_yesterday_ratio, - close_today_fixed, close_today_ratio, updated_at) - VALUES (?,?,?,?,?,?,?,?,?,?) + close_today_fixed, close_today_ratio, updated_at, source) + VALUES (?,?,?,?,?,?,?,?,?,?,?) ON CONFLICT(product) DO UPDATE SET exchange=excluded.exchange, mult=excluded.mult, open_fixed=excluded.open_fixed, open_ratio=excluded.open_ratio, @@ -213,7 +265,8 @@ def load_fee_rates_from_json(path: Optional[str] = None) -> int: close_yesterday_ratio=excluded.close_yesterday_ratio, close_today_fixed=excluded.close_today_fixed, close_today_ratio=excluded.close_today_ratio, - updated_at=excluded.updated_at""", + updated_at=excluded.updated_at, + source=excluded.source""", ( product.lower(), item.get("exchange", ""), @@ -225,6 +278,7 @@ def load_fee_rates_from_json(path: Optional[str] = None) -> int: float(item.get("close_today_fixed") or 0), float(item.get("close_today_ratio") or 0), now, + item.get("source", "json"), ), ) count += 1 @@ -246,13 +300,14 @@ def upsert_fee_rate(product: str, fields: dict) -> None: product = product.lower().strip() conn = _get_db() now = datetime.now().isoformat(timespec="seconds") + source = fields.get("source", "manual") conn.execute( """INSERT INTO fee_rates (product, exchange, mult, open_fixed, open_ratio, close_yesterday_fixed, close_yesterday_ratio, - close_today_fixed, close_today_ratio, updated_at) - VALUES (?,?,?,?,?,?,?,?,?,?) + close_today_fixed, close_today_ratio, updated_at, source) + VALUES (?,?,?,?,?,?,?,?,?,?,?) ON CONFLICT(product) DO UPDATE SET exchange=excluded.exchange, mult=excluded.mult, open_fixed=excluded.open_fixed, open_ratio=excluded.open_ratio, @@ -260,7 +315,8 @@ def upsert_fee_rate(product: str, fields: dict) -> None: close_yesterday_ratio=excluded.close_yesterday_ratio, close_today_fixed=excluded.close_today_fixed, close_today_ratio=excluded.close_today_ratio, - updated_at=excluded.updated_at""", + updated_at=excluded.updated_at, + source=excluded.source""", ( product, fields.get("exchange", ""), @@ -272,6 +328,7 @@ def upsert_fee_rate(product: str, fields: dict) -> None: float(fields.get("close_today_fixed") or 0), float(fields.get("close_today_ratio") or 0), now, + source, ), ) conn.commit() diff --git a/fee_sync.py b/fee_sync.py index c67f044..2a16b7c 100644 --- a/fee_sync.py +++ b/fee_sync.py @@ -53,6 +53,7 @@ def _parse_akshare_row(row: dict, multiplier: float) -> Optional[dict]: "close_yesterday_ratio": round(close_y_ratio * multiplier, 8), "close_today_fixed": round(close_t_fixed * multiplier, 6), "close_today_ratio": round(close_t_ratio * multiplier, 8), + "source": "akshare", } diff --git a/install_trading.py b/install_trading.py index e070d53..8ff57a1 100644 --- a/install_trading.py +++ b/install_trading.py @@ -54,8 +54,9 @@ from vnpy_bridge import ( ) -def install_trading(app, *, login_required, get_db, get_setting, set_setting, fetch_price, send_wechat_msg): +def install_trading(app, *, login_required, require_nav, get_db, get_setting, set_setting, fetch_price, send_wechat_msg): """注册交易相关路由。""" + _nav = require_nav def _settings_dict() -> dict: return { @@ -291,6 +292,7 @@ def install_trading(app, *, login_required, get_db, get_setting, set_setting, fe @app.route("/strategy") @login_required + @_nav("strategy") def strategy_page(): conn = get_db() init_strategy_tables(conn) diff --git a/nav_settings.py b/nav_settings.py new file mode 100644 index 0000000..d3f3010 --- /dev/null +++ b/nav_settings.py @@ -0,0 +1,46 @@ +"""顶栏导航项显示开关(系统设置)。""" +from __future__ import annotations + +import json +from typing import Callable + +# 可在系统设置中开关的导航项 +NAV_TOGGLES: dict[str, str] = { + "fees": "手续费配置", + "contract": "品种简介", + "plans": "开单计划", + "market": "行情K线", + "strategy": "策略交易", +} + +DEFAULT_NAV: dict[str, bool] = {k: True for k in NAV_TOGGLES} + + +def get_nav_items(get_setting: Callable[[str, str], str]) -> dict[str, bool]: + raw = (get_setting("nav_items", "") or "").strip() + out = dict(DEFAULT_NAV) + if not raw: + return out + try: + data = json.loads(raw) + if isinstance(data, dict): + for k in NAV_TOGGLES: + if k in data: + out[k] = bool(data[k]) + except json.JSONDecodeError: + pass + return out + + +def save_nav_items(set_setting: Callable[[str, str], None], items: dict[str, bool]) -> None: + merged = dict(DEFAULT_NAV) + for k in NAV_TOGGLES: + if k in items: + merged[k] = bool(items[k]) + set_setting("nav_items", json.dumps(merged, ensure_ascii=False)) + + +def nav_enabled(get_setting: Callable[[str, str], str], key: str) -> bool: + if key not in NAV_TOGGLES: + return True + return get_nav_items(get_setting).get(key, True) diff --git a/templates/base.html b/templates/base.html index 21b98ec..ffe0326 100644 --- a/templates/base.html +++ b/templates/base.html @@ -484,14 +484,14 @@ diff --git a/templates/fees.html b/templates/fees.html index 3a0790b..81008af 100644 --- a/templates/fees.html +++ b/templates/fees.html @@ -2,7 +2,37 @@ {% block title %}手续费配置 - 国内期货监控系统{% endblock %} {% block content %}
-

手续费倍率

+

手续费数据源

+
+
+ + + + +
+

+ 默认使用 CTP 柜台 查询到的开仓/平仓费率(连接 CTP 后自动同步,与 SimNow/期货公司一致)。 + 离线或未连接时可改用本地表估算。 +

+
+
+ + +
+ {% if ctp_connected %} + CTP 已连接 + {% else %} + CTP 未连接 — 请先连接后再同步 + {% endif %} +
+
+
+ +
+

本地参考倍率(仅「本地数据源」时使用)

@@ -10,27 +40,26 @@
-

默认 2 倍:从第三方拉取交易所参考标准后自动乘以该倍率写入本地表。模拟盘估算用,非实盘账单。

- +
- +
-

品种费率表(已含倍率)

+

品种费率表

- + @@ -44,6 +73,7 @@ + @@ -57,11 +87,13 @@ {% else %} - + {% endfor %}
品种交易所乘数品种来源交易所乘数 开仓(元/手)开仓(比例) 平昨(元/手)平昨(比例) 平今(元/手)平今(比例) {{ r.product }}{{ r.source or 'local' }}
暂无费率,请同步或重载 JSON
暂无费率,请连接 CTP 后同步
-

比例按「成交价×乘数×手数×比例」计费;元/手为固定每手。开+平合计为一笔往返手续费。

+

+ 公式:单边手续费 = 固定(元/手)×手数 + 比例×价格×乘数×手数。往返 = 开仓 + 平仓(平今/平昨自动判断)。 +

{% endblock %} diff --git a/templates/settings.html b/templates/settings.html index be18fee..69717c3 100644 --- a/templates/settings.html +++ b/templates/settings.html @@ -2,6 +2,23 @@ {% block title %}系统设置 - 国内期货监控系统{% endblock %} {% block content %} +
+

导航显示

+
+ +

关闭后顶栏隐藏对应入口,直接访问 URL 也会跳转回持仓监控。

+
+ {% for key, label in nav_toggles.items() %} + + {% endfor %} +
+ +
+
+

交易模式

diff --git a/vnpy_bridge.py b/vnpy_bridge.py index 4bb7a76..ceef779 100644 --- a/vnpy_bridge.py +++ b/vnpy_bridge.py @@ -86,6 +86,9 @@ class CtpBridge: self._connected_mode: Optional[str] = None self._last_error: str = "" self._connect_lock = threading.Lock() + self._commission_waiters: dict[int, threading.Event] = {} + self._commission_results: dict[int, dict] = {} + self._commission_hooked = False self._init_engine() def _init_engine(self) -> None: @@ -184,6 +187,7 @@ class CtpBridge: self._connected_mode = mode self._last_error = "" logger.info("CTP 已连接 [%s] account=%s", mode, len(accounts)) + self._schedule_fee_sync(mode) return time.sleep(0.5) finally: @@ -213,6 +217,71 @@ class CtpBridge: def mark_disconnected(self) -> None: self._connected_mode = None + def _schedule_fee_sync(self, mode: str) -> None: + def _run() -> None: + try: + from ctp_fee_sync import sync_fees_from_ctp + n, msg = sync_fees_from_ctp(mode, max_symbols=60) + logger.info("CTP 手续费同步: %s", msg if n else msg) + except Exception as exc: + logger.debug("CTP 手续费后台同步: %s", exc) + + threading.Thread(target=_run, daemon=True, name="ctp-fee-sync").start() + + def _ensure_commission_callback(self) -> None: + if self._commission_hooked or not self._engine: + return + try: + gw = self._engine.get_gateway(GATEWAY_NAME) + td = gw.td_api + except Exception: + return + bridge = self + + def on_rsp(data: dict, error: dict, reqid: int, last: bool) -> None: + if data and data.get("InstrumentID"): + bridge._commission_results[reqid] = dict(data) + ev = bridge._commission_waiters.get(reqid) + if last and ev: + ev.set() + + td.onRspQryInstrumentCommissionRate = on_rsp # type: ignore[method-assign] + self._commission_hooked = True + + def query_instrument_commission(self, ths_code: str, *, mode: str) -> dict: + """查询单合约 CTP 手续费率(需已连接)。""" + if self._connected_mode != mode or not self._engine: + return {} + try: + from ctp_symbol import ths_to_vnpy_symbol + sym, _ = ths_to_vnpy_symbol(ths_code) + gw = self._engine.get_gateway(GATEWAY_NAME) + td = gw.td_api + except Exception as exc: + logger.debug("commission query init: %s", exc) + return {} + if not getattr(td, "login_status", False): + return {} + if not hasattr(td, "reqQryInstrumentCommissionRate"): + return {} + self._ensure_commission_callback() + reqid = int(getattr(td, "reqid", 0)) + 1 + td.reqid = reqid + ev = threading.Event() + self._commission_waiters[reqid] = ev + req = { + "BrokerID": td.brokerid, + "InvestorID": td.userid, + "InstrumentID": sym, + } + ret = td.reqQryInstrumentCommissionRate(req, reqid) + if ret != 0: + self._commission_waiters.pop(reqid, None) + return {} + ev.wait(timeout=8) + self._commission_waiters.pop(reqid, None) + return self._commission_results.pop(reqid, {}) + def get_account(self) -> dict[str, Any]: if not self._engine: return {}