feat: 手续费仅CTP每日后台同步入库,前端只读展示

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
dekun
2026-06-24 13:33:14 +08:00
parent de6815d481
commit e01c011df5
7 changed files with 240 additions and 214 deletions
+17 -56
View File
@@ -29,15 +29,10 @@ 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,
list_fee_rates_for_ui,
count_fee_rates_by_source,
load_fee_rates_from_json,
upsert_fee_rate,
purge_non_ctp_fee_rates,
)
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
@@ -376,11 +371,11 @@ def init_db():
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()
if fee_cnt == 0:
load_fee_rates_from_json()
set_setting("fee_source_mode", "ctp")
try:
purge_non_ctp_fee_rates()
except Exception:
pass
def sync_admin_from_env():
@@ -1565,66 +1560,32 @@ def api_contract_profile():
@require_nav("fees")
def fees():
from trading_context import get_trading_mode
from ctp_fee_sync import sync_fees_from_ctp
from ctp_fee_worker import try_daily_ctp_fee_sync, get_fee_last_sync, fees_synced_today
from vnpy_bridge import ctp_status
mode = get_trading_mode(get_setting)
if request.method == "POST":
action = request.form.get("action")
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)
if action == "sync_ctp":
force = request.form.get("force") == "1"
count, msg = try_daily_ctp_fee_sync(
mode,
get_setting=get_setting,
set_setting=set_setting,
force=force,
)
flash(msg)
elif action == "multiplier":
try:
mult = float(request.form.get("fee_multiplier", "2"))
if mult < 0:
flash("倍率不能为负数")
else:
set_setting("fee_multiplier", str(mult))
flash(f"手续费倍率已保存:标准 × {mult}")
except ValueError:
flash("请输入有效倍率")
elif action == "sync":
mult = float(get_setting("fee_multiplier", "2") or 2)
count, msg = sync_fees_from_akshare(mult)
flash(msg if count else msg)
elif action == "reload_json":
n = load_fee_rates_from_json()
flash(f"已从本地 JSON 加载 {n} 个品种费率")
elif action == "save_row":
product = request.form.get("product", "").strip().lower()
if not product:
flash("品种代码不能为空")
else:
upsert_fee_rate(product, {
"exchange": request.form.get("exchange", "").strip(),
"mult": int(request.form.get("mult") or 10),
"open_fixed": float(request.form.get("open_fixed") or 0),
"open_ratio": float(request.form.get("open_ratio") or 0),
"close_yesterday_fixed": float(request.form.get("close_yesterday_fixed") or 0),
"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_fee_rates_for_ui()
fee_counts = count_fee_rates_by_source()
multiplier = get_setting("fee_multiplier", "2")
fee_source_mode = get_fee_source_mode()
ctp_st = ctp_status(mode)
return render_template(
"fees.html",
rates=rates,
fee_counts=fee_counts,
multiplier=multiplier,
fee_source_mode=fee_source_mode,
fee_last_sync=get_fee_last_sync(get_setting),
fee_synced_today=fees_synced_today(get_setting),
ctp_connected=bool(ctp_st.get("connected")),
)
+1 -1
View File
@@ -58,7 +58,7 @@ def _collect_main_ths_codes() -> list[str]:
def sync_fees_from_ctp(mode: str, *, max_symbols: int = 80) -> tuple[int, str]:
"""CTP 已连接时,按主力合约查询手续费并写入 fee_ratessource=ctp)。"""
"""CTP 已连接时查询手续费并写入 fee_ratessource=ctp,覆盖同品种旧数据)。"""
bridge = get_bridge()
if not bridge.available():
return 0, "vnpy 未安装"
+90
View File
@@ -0,0 +1,90 @@
"""CTP 手续费后台同步:每日一次写入数据库,前端只读展示。"""
from __future__ import annotations
import logging
import threading
import time
from datetime import date, datetime
from typing import Callable, Optional
from zoneinfo import ZoneInfo
logger = logging.getLogger(__name__)
TZ = ZoneInfo("Asia/Shanghai")
FEE_SYNC_KEY = "ctp_fee_last_sync"
CHECK_INTERVAL_SEC = 3600
_sync_lock = threading.Lock()
def _today_str() -> str:
return datetime.now(TZ).date().isoformat()
def get_fee_last_sync(get_setting: Callable[[str, str], str]) -> str:
return (get_setting(FEE_SYNC_KEY, "") or "").strip()
def fees_synced_today(get_setting: Callable[[str, str], str]) -> bool:
last = get_fee_last_sync(get_setting)
return bool(last) and last[:10] == _today_str()
def mark_fees_synced(set_setting: Callable[[str, str], None]) -> None:
set_setting(FEE_SYNC_KEY, datetime.now(TZ).isoformat(timespec="seconds"))
def try_daily_ctp_fee_sync(
mode: str,
*,
get_setting: Callable[[str, str], str],
set_setting: Callable[[str, str], None],
force: bool = False,
) -> tuple[int, str]:
"""CTP 已连接且今日未同步时拉取费率入库;force=True 忽略日期限制。"""
if not force and fees_synced_today(get_setting):
return 0, "今日已从 CTP 同步过,无需重复(可点「立即同步」强制刷新)"
with _sync_lock:
if not force and fees_synced_today(get_setting):
return 0, "今日已从 CTP 同步过"
from ctp_fee_sync import sync_fees_from_ctp
count, msg = sync_fees_from_ctp(mode)
if count > 0:
mark_fees_synced(set_setting)
logger.info("CTP 手续费每日同步: %s", msg)
elif force:
logger.warning("CTP 手续费强制同步未写入: %s", msg)
return count, msg
def start_ctp_fee_worker(
*,
get_mode_fn: Callable[[], str],
get_setting_fn: Callable[[str, str], str],
set_setting_fn: Callable[[str, str], None],
interval: int = CHECK_INTERVAL_SEC,
) -> None:
"""后台线程:每小时检查,CTP 已连接且当日未同步则自动同步。"""
def _loop() -> None:
time.sleep(20)
while True:
try:
from vnpy_bridge import ctp_status
mode = get_mode_fn()
st = ctp_status(mode)
if st.get("connected") and not fees_synced_today(get_setting_fn):
try_daily_ctp_fee_sync(
mode,
get_setting=get_setting_fn,
set_setting=set_setting_fn,
force=False,
)
except Exception as exc:
logger.warning("CTP fee worker: %s", exc)
time.sleep(max(300, interval))
threading.Thread(target=_loop, daemon=True, name="ctp-fee-worker").start()
+62 -63
View File
@@ -1,4 +1,4 @@
"""期货手续费:优先 CTP 柜台费率,本地/AKShare 为离线兜底"""
"""期货手续费: CTP 柜台同步入库,前端只读展示"""
import json
import os
import re
@@ -37,6 +37,26 @@ def _get_db():
return connect_db()
def get_setting(key: str, default: str = "") -> str:
conn = _get_db()
row = conn.execute("SELECT value FROM settings WHERE key=?", (key,)).fetchone()
conn.close()
if not row:
return default
return (row["value"] or default) if row["value"] is not None else default
def set_setting(key: str, value: str) -> None:
conn = _get_db()
conn.execute(
"""INSERT INTO settings (key, value) VALUES (?,?)
ON CONFLICT(key) DO UPDATE SET value=excluded.value""",
(key, value),
)
conn.commit()
conn.close()
def get_fee_multiplier() -> float:
conn = _get_db()
row = conn.execute(
@@ -52,14 +72,20 @@ def get_fee_multiplier() -> float:
def get_fee_source_mode() -> str:
"""ctp=优先柜台同步费率;local=本地/AKShare 表"""
"""固定 CTP 柜台"""
return "ctp"
def purge_non_ctp_fee_rates() -> int:
"""删除非 CTP 来源的费率缓存。"""
conn = _get_db()
row = conn.execute(
"SELECT value FROM settings WHERE key='fee_source_mode'"
).fetchone()
cur = conn.execute(
"DELETE FROM fee_rates WHERE COALESCE(source, '') != 'ctp'"
)
n = cur.rowcount
conn.commit()
conn.close()
mode = (row["value"] if row else "ctp") or "ctp"
return mode if mode in ("ctp", "local") else "ctp"
return n
def _row_to_spec(row, mult: int) -> dict:
@@ -84,46 +110,21 @@ def get_fee_spec(ths_code: str, *, trading_mode: str = "simulation") -> dict:
return {**DEFAULT_FEE, "mult": spec["mult"], "product": "", "exchange": "", "source": "default"}
mult = get_contract_spec(ths_code)["mult"]
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)
row = conn.execute(
"SELECT * FROM fee_rates WHERE product=? AND source='ctp'",
(product,),
).fetchone()
conn.close()
if row:
return _row_to_spec(row, mult)
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
if product in _INDEX_PRODUCTS:
return {
@@ -287,6 +288,16 @@ def load_fee_rates_from_json(path: Optional[str] = None) -> int:
return count
def list_ctp_fee_rates() -> list:
"""手续费页:仅展示 CTP 同步结果。"""
conn = _get_db()
rows = conn.execute(
"SELECT * FROM fee_rates WHERE source='ctp' ORDER BY product"
).fetchall()
conn.close()
return [dict(r) for r in rows]
def list_all_fee_rates() -> list:
conn = _get_db()
rows = conn.execute(
@@ -297,28 +308,16 @@ def list_all_fee_rates() -> list:
def list_fee_rates_for_ui() -> list:
"""手续费页展示:CTP 模式下 ctp 来源优先排前。"""
rows = list_all_fee_rates()
if get_fee_source_mode() == "ctp":
rows.sort(
key=lambda r: (
0 if (r.get("source") or "") == "ctp" else 1,
r.get("product") or "",
)
)
return rows
return list_ctp_fee_rates()
def count_fee_rates_by_source() -> dict[str, int]:
conn = _get_db()
rows = conn.execute(
"SELECT source, COUNT(*) AS n FROM fee_rates GROUP BY source"
).fetchall()
n = conn.execute(
"SELECT COUNT(*) FROM fee_rates WHERE source='ctp'"
).fetchone()[0]
conn.close()
out: dict[str, int] = {}
for row in rows:
out[str(row["source"] or "local")] = int(row["n"] or 0)
return out
return {"ctp": int(n or 0)}
def upsert_fee_rate(product: str, fields: dict) -> None:
+6
View File
@@ -20,6 +20,7 @@ from position_sizing import (
from recommend_store import load_recommend_cache, refresh_recommend_cache
from recommend_stream import recommend_hub, start_recommend_worker
from ctp_reconnect import start_ctp_reconnect_worker
from ctp_fee_worker import start_ctp_fee_worker
from risk.account_risk_lib import (
assert_can_open,
get_risk_status,
@@ -1016,3 +1017,8 @@ def install_trading(app, *, login_required, require_nav, get_db, get_setting, se
init_tables_fn=_init_tables,
)
start_ctp_reconnect_worker(get_mode_fn=lambda: get_trading_mode(get_setting))
start_ctp_fee_worker(
get_mode_fn=lambda: get_trading_mode(get_setting),
get_setting_fn=get_setting,
set_setting_fn=set_setting,
)
+43 -88
View File
@@ -2,82 +2,46 @@
{% block title %}手续费配置 - 国内期货监控系统{% endblock %}
{% block extra_css %}
<style>
.fees-split{margin-bottom:1.25rem}
.fees-split .card{margin-bottom:0;min-height:auto}
.fees-status-card .card-body{display:flex;flex-wrap:wrap;gap:.75rem 1.25rem;align-items:center}
.fees-status-card .fees-meta{font-size:.85rem;color:var(--text-muted)}
.fees-table-card .trade-table-wrap{
max-height:min(70vh,560px);
width:100%;
border:none;
border-radius:10px;
}
.fees-table-card .trade-table{min-width:1100px}
.fees-table-card .trade-table{min-width:960px}
.fees-table-card .card-body{padding:.75rem 1rem 1rem}
.fees-source-stats{font-size:.78rem;margin-top:.35rem}
</style>
{% endblock %}
{% block content %}
<div class="split-grid fees-split">
<div class="card">
<h2>手续费数据源</h2>
<div class="card-body">
<form action="{{ url_for('fees') }}" method="post">
<input type="hidden" name="action" value="fee_source">
<div class="field" style="margin-bottom:.75rem">
<label>计费依据</label>
<select name="fee_source_mode">
<option value="ctp" {% if fee_source_mode == 'ctp' %}selected{% endif %}>CTP 柜台(SimNow/实盘,推荐)</option>
<option value="local" {% if fee_source_mode == 'local' %}selected{% endif %}>本地 / AKShare 参考表</option>
</select>
</div>
<button type="submit" class="btn-primary">保存</button>
</form>
<p class="hint" style="margin-top:.75rem">
默认使用 <strong>CTP 柜台</strong> 费率(连接后自动同步,与 SimNow/期货公司一致)。
</p>
<div class="form-row" style="margin-top:.75rem;flex-wrap:wrap;gap:.5rem">
<form action="{{ url_for('fees') }}" method="post" style="display:inline">
<input type="hidden" name="action" value="sync_ctp">
<button type="submit" class="btn-primary" {% if not ctp_connected %}disabled title="请先连接 CTP"{% endif %}>从 CTP 同步费率</button>
</form>
{% if ctp_connected %}
<span class="badge profit">CTP 已连接</span>
{% else %}
<span class="badge planned">CTP 未连接</span>
{% endif %}
</div>
{% if fee_counts %}
<p class="fees-source-stats text-muted">
已缓存:
{% if fee_counts.get('ctp') %}<span class="badge profit">CTP {{ fee_counts.ctp }}</span>{% endif %}
{% if fee_counts.get('local') %}<span class="badge planned">local {{ fee_counts.local }}</span>{% endif %}
{% if fee_counts.get('json') %}<span class="badge planned">json {{ fee_counts.json }}</span>{% endif %}
{% if fee_counts.get('manual') %}<span class="badge planned">manual {{ fee_counts.manual }}</span>{% endif %}
</p>
{% endif %}
</div>
</div>
<div class="card">
<h2>本地参考倍率</h2>
<div class="card-body">
<p class="hint" style="margin-bottom:.65rem;font-size:.78rem">仅「本地数据源」时使用</p>
<form action="{{ url_for('fees') }}" method="post" class="form-row" style="flex-wrap:wrap;gap:.5rem;align-items:center;margin-bottom:.75rem">
<input type="hidden" name="action" value="multiplier">
<label class="text-muted" style="font-size:.85rem">标准费率 ×</label>
<input name="fee_multiplier" type="number" step="0.1" min="0" value="{{ multiplier }}" style="width:88px">
<button type="submit" class="btn-primary">保存倍率</button>
</form>
<div class="form-row" style="flex-wrap:wrap;gap:.5rem">
<form action="{{ url_for('fees') }}" method="post" style="display:inline">
<input type="hidden" name="action" value="sync">
<button type="submit" class="btn-secondary">AKShare 同步</button>
</form>
<form action="{{ url_for('fees') }}" method="post" style="display:inline">
<input type="hidden" name="action" value="reload_json">
<button type="submit" class="btn-link" style="padding:.45rem .85rem;border:1px solid var(--card-border);border-radius:8px">重载 JSON</button>
</form>
</div>
</div>
<div class="card fees-status-card">
<h2>CTP 手续费</h2>
<div class="card-body">
<p class="fees-meta" style="margin:0;flex:1;min-width:220px">
费率由后台从 <strong>CTP 柜台</strong> 同步写入数据库,<strong>每日自动更新一次</strong>,本页只读展示。
</p>
{% if ctp_connected %}
<span class="badge profit">CTP 已连接</span>
{% else %}
<span class="badge planned">CTP 未连接</span>
{% endif %}
{% if fee_synced_today %}
<span class="badge profit">今日已同步</span>
{% else %}
<span class="badge planned">今日未同步</span>
{% endif %}
{% if fee_last_sync %}
<span class="text-muted" style="font-size:.8rem">上次:{{ fee_last_sync[:16] }}</span>
{% endif %}
{% if fee_counts.get('ctp') %}
<span class="text-muted" style="font-size:.8rem">共 {{ fee_counts.ctp }} 个品种</span>
{% endif %}
<form action="{{ url_for('fees') }}" method="post" style="display:inline">
<input type="hidden" name="action" value="sync_ctp">
<input type="hidden" name="force" value="1">
<button type="submit" class="btn-primary" {% if not ctp_connected %}disabled title="请先连接 CTP"{% endif %}>立即同步</button>
</form>
</div>
</div>
@@ -88,47 +52,38 @@
<table class="trade-table">
<thead>
<tr>
<th>品种</th><th>来源</th><th>交易所</th><th>乘数</th>
<th>品种</th><th>交易所</th><th>乘数</th>
<th>开仓(元/手)</th><th>开仓(比例)</th>
<th>平昨(元/手)</th><th>平昨(比例)</th>
<th>平今(元/手)</th><th>平今(比例)</th>
<th>更新</th><th>操作</th>
<th>更新</th>
</tr>
</thead>
<tbody>
{% for r in rates %}
{% set fid = 'fee-row-' ~ r.product %}
<tr>
<td><strong>{{ r.product }}</strong></td>
<td><span class="badge {% if r.source == 'ctp' %}profit{% else %}planned{% endif %}">{{ r.source or 'local' }}</span></td>
<td><input name="exchange" form="{{ fid }}" value="{{ r.exchange or '' }}" style="width:72px;padding:.3rem"></td>
<td><input name="mult" form="{{ fid }}" type="number" value="{{ r.mult }}" style="width:56px;padding:.3rem"></td>
<td><input name="open_fixed" form="{{ fid }}" type="number" step="0.0001" value="{{ r.open_fixed }}" style="width:72px;padding:.3rem"></td>
<td><input name="open_ratio" form="{{ fid }}" type="number" step="0.0000001" value="{{ r.open_ratio }}" style="width:88px;padding:.3rem"></td>
<td><input name="close_yesterday_fixed" form="{{ fid }}" type="number" step="0.0001" value="{{ r.close_yesterday_fixed }}" style="width:72px;padding:.3rem"></td>
<td><input name="close_yesterday_ratio" form="{{ fid }}" type="number" step="0.0000001" value="{{ r.close_yesterday_ratio }}" style="width:88px;padding:.3rem"></td>
<td><input name="close_today_fixed" form="{{ fid }}" type="number" step="0.0001" value="{{ r.close_today_fixed }}" style="width:72px;padding:.3rem"></td>
<td><input name="close_today_ratio" form="{{ fid }}" type="number" step="0.0000001" value="{{ r.close_today_ratio }}" style="width:88px;padding:.3rem"></td>
<td class="cell-readonly">{{ r.exchange or '—' }}</td>
<td class="cell-readonly">{{ r.mult }}</td>
<td class="cell-readonly">{{ r.open_fixed }}</td>
<td class="cell-readonly">{{ r.open_ratio }}</td>
<td class="cell-readonly">{{ r.close_yesterday_fixed }}</td>
<td class="cell-readonly">{{ r.close_yesterday_ratio }}</td>
<td class="cell-readonly">{{ r.close_today_fixed }}</td>
<td class="cell-readonly">{{ r.close_today_ratio }}</td>
<td class="text-muted" style="font-size:.72rem">{{ (r.updated_at or '')[:16] }}</td>
<td><button type="submit" form="{{ fid }}" class="btn-link">保存</button></td>
</tr>
{% else %}
<tr><td colspan="12" class="text-muted">暂无费率,请连接 CTP 后同步</td></tr>
<tr><td colspan="10" class="text-muted">暂无 CTP 费率,请连接 CTP 后等待自动同步或点击「立即同步」</td></tr>
{% endfor %}
</tbody>
</table>
</div>
{% for r in rates %}
<form id="fee-row-{{ r.product }}" action="{{ url_for('fees') }}" method="post" hidden>
<input type="hidden" name="action" value="save_row">
<input type="hidden" name="product" value="{{ r.product }}">
</form>
{% endfor %}
</div>
<p class="hint" style="margin-top:.75rem;padding:0 1rem 1rem">
公式:单边 = 固定(元/手)×手数 + 比例×价格×乘数×手数;往返 = 开仓 + 平仓(平今/平昨自动判断)。
{% if fee_source_mode == 'ctp' and ctp_connected and not fee_counts.get('ctp') %}
<br><strong class="text-loss">当前无 CTP 费率缓存,请点击「从 CTP 同步费率」</strong>
{% if ctp_connected and not fee_counts.get('ctp') %}
<br><strong class="text-loss">数据库尚无 CTP 费率,请点击「立即同步」或等待后台每日任务</strong>
{% endif %}
</p>
</div>
+21 -6
View File
@@ -223,15 +223,30 @@ class CtpBridge:
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)
from ctp_fee_worker import try_daily_ctp_fee_sync
threading.Thread(target=_run, daemon=True, name="ctp-fee-sync").start()
def _gs(key: str, default: str = "") -> str:
from fee_specs import get_setting
return get_setting(key, default)
def _ss(key: str, val: str) -> None:
from fee_specs import set_setting
set_setting(key, val)
try_daily_ctp_fee_sync(
mode,
get_setting=_gs,
set_setting=_ss,
force=False,
)
except Exception as exc:
logger.debug("CTP 手续费连接后检查: %s", exc)
threading.Thread(target=_run, daemon=True, name="ctp-fee-sync-check").start()
def _ensure_commission_callback(self) -> None:
if self._commission_hooked or not self._engine: