From bea7804d477522db37423db85cd96ec7a8204d03 Mon Sep 17 00:00:00 2001 From: dekun Date: Mon, 15 Jun 2026 15:22:40 +0800 Subject: [PATCH] =?UTF-8?q?=E6=9C=AC=E5=9C=B0=E6=89=8B=E7=BB=AD=E8=B4=B9?= =?UTF-8?q?=E9=85=8D=E7=BD=AE=EF=BC=88=E6=A0=87=E5=87=86=C3=972=EF=BC=89?= =?UTF-8?q?=EF=BC=8C=E6=8C=81=E4=BB=93/=E4=BA=A4=E6=98=93=E8=AE=B0?= =?UTF-8?q?=E5=BD=95/=E5=A4=8D=E7=9B=98/=E7=BB=9F=E8=AE=A1=E5=B1=95?= =?UTF-8?q?=E7=A4=BA=E6=89=A3=E8=B4=B9=E5=90=8E=E7=9B=88=E4=BA=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Cursor --- app.py | 145 +++++++++++++++++++-- data/fee_rates.json | 36 ++++++ fee_specs.py | 278 +++++++++++++++++++++++++++++++++++++++++ fee_sync.py | 85 +++++++++++++ requirements.txt | 1 + static/js/positions.js | 4 + static/js/review.js | 6 +- templates/base.html | 3 +- templates/fees.html | 67 ++++++++++ templates/records.html | 21 +++- templates/stats.html | 43 ++++++- 11 files changed, 669 insertions(+), 20 deletions(-) create mode 100644 data/fee_rates.json create mode 100644 fee_specs.py create mode 100644 fee_sync.py create mode 100644 templates/fees.html diff --git a/app.py b/app.py index 1c78e71..2fc7505 100644 --- a/app.py +++ b/app.py @@ -19,6 +19,15 @@ from werkzeug.security import check_password_hash, generate_password_hash from symbols import search_symbols, ths_to_codes from contract_specs import calc_position_metrics +from fee_specs import ( + calc_fee_breakdown, + calc_round_trip_fee, + get_fee_multiplier, + list_all_fee_rates, + load_fee_rates_from_json, + upsert_fee_rate, +) +from fee_sync import sync_fees_from_akshare from kline_chart import generate_review_kline_chart from market import get_price as market_get_price, set_ths_refresh_token, get_quote_source_label @@ -216,6 +225,10 @@ def init_db(): "ALTER TABLE review_records ADD COLUMN symbol_name TEXT", "ALTER TABLE review_records ADD COLUMN market_code TEXT", "ALTER TABLE review_records ADD COLUMN sina_code TEXT", + "ALTER TABLE trade_logs ADD COLUMN fee REAL", + "ALTER TABLE trade_logs ADD COLUMN pnl_net REAL", + "ALTER TABLE review_records ADD COLUMN fee REAL", + "ALTER TABLE review_records ADD COLUMN pnl_net REAL", ] for sql in migrations: try: @@ -253,6 +266,17 @@ def init_db(): pnl REAL, result TEXT, verified INTEGER DEFAULT 0, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP)''') + c.execute('''CREATE TABLE IF NOT EXISTS fee_rates + (product TEXT PRIMARY KEY, + exchange TEXT, + mult INTEGER, + open_fixed REAL DEFAULT 0, + open_ratio REAL DEFAULT 0, + close_yesterday_fixed REAL DEFAULT 0, + close_yesterday_ratio REAL DEFAULT 0, + close_today_fixed REAL DEFAULT 0, + close_today_ratio REAL DEFAULT 0, + updated_at TEXT)''') conn.commit() conn.close() @@ -267,6 +291,14 @@ def init_db(): os.makedirs(UPLOAD_DIR, exist_ok=True) expire_old_plans() + if not get_setting("fee_multiplier"): + set_setting("fee_multiplier", "2") + 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() + def sync_admin_from_env(): """ @@ -616,6 +648,13 @@ def api_position_live(): direction, entry, sl, tp, lots, mark, capital, sym, ) holding = calc_holding_duration(r["open_time"] or "", now_iso) + 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, + ) + est_net = None + if metrics.get("float_pnl") is not None: + est_net = round(metrics["float_pnl"] - fee_info["total_fee"], 2) out.append({ "id": r["id"], "symbol": r["symbol_name"] or sym, @@ -628,6 +667,11 @@ def api_position_live(): "open_time": r["open_time"], "mark_price": mark, "holding_duration": holding, + "est_fee": fee_info["total_fee"], + "est_fee_open": fee_info["open_fee"], + "est_fee_close": fee_info["close_fee"], + "est_fee_close_type": fee_info["close_type"], + "est_pnl_net": est_net, **metrics, }) return jsonify(out) @@ -840,24 +884,26 @@ 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) + pnl_net = round(pnl - fee, 2) result = classify_close_result(direction, close_price, sl, tp) minutes = holding_to_minutes(open_time, close_time) conn.execute( """INSERT INTO trade_logs (symbol, symbol_name, market_code, sina_code, monitor_type, direction, entry_price, stop_loss, take_profit, close_price, lots, margin, - holding_minutes, open_time, close_time, pnl, result) - VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""", + holding_minutes, open_time, close_time, pnl, fee, pnl_net, result) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""", ( sym, row["symbol_name"], market, sina, "持仓监控", direction, entry, sl, tp, close_price, lots, metrics["margin"], - minutes, open_time, close_time, pnl, result, + minutes, open_time, close_time, pnl, fee, pnl_net, result, ), ) conn.execute("DELETE FROM position_monitors WHERE id=?", (pid,)) conn.commit() conn.close() - flash(f"已平仓,盈亏 {pnl:.2f} 元,已记入交易记录") + flash(f"已平仓,盈亏 {pnl:.2f} 元(扣费后 {pnl_net:.2f} 元),已记入交易记录") return redirect(url_for("positions")) @@ -1060,6 +1106,18 @@ def add_review(): initial_pnl = calc_rr_ratio(direction, entry_price, stop_loss, take_profit) actual_pnl = calc_rr_ratio(direction, entry_price, stop_loss, close_price) + gross_pnl = num("pnl") + if gross_pnl is None and entry_price and close_price: + spec_mult = calc_position_metrics( + direction, entry_price, stop_loss, take_profit, + lots, close_price, 0, symbol, + ) + 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, + ) + pnl_net = round((gross_pnl or 0) - fee, 2) if gross_pnl is not None else None + auto_kline = bool(d.get("auto_kline")) if auto_kline and not screenshot: try: @@ -1087,19 +1145,19 @@ def add_review(): (open_time, close_time, symbol, symbol_name, market_code, sina_code, timeframe, direction, entry_price, stop_loss, take_profit, close_price, lots, - holding_duration, initial_pnl, actual_pnl, pnl, + holding_duration, initial_pnl, actual_pnl, pnl, fee, pnl_net, open_type, expected_rr, actual_rr, exit_trigger, exit_supplement, watch_after_breakeven, new_position_while_occupied, screenshot, auto_kline, kline_period1, kline_period2, kline_count, kline_cutoff, behavior_tags, is_emotion, notes) - VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""", + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""", ( open_time, close_time, symbol, symbol_name, market_code, sina_code, d.get("timeframe", "").strip(), direction, entry_price, stop_loss, take_profit, close_price, lots, - holding, initial_pnl, actual_pnl, num("pnl"), + holding, initial_pnl, actual_pnl, gross_pnl, fee, pnl_net, open_type, None, None, @@ -1195,7 +1253,28 @@ def stats(): ).fetchall() recent = conn.execute( - "SELECT * FROM trade_records ORDER BY id DESC LIMIT 10" + "SELECT * FROM trade_logs ORDER BY id DESC LIMIT 10" + ).fetchall() + + fee_trade = conn.execute( + "SELECT COALESCE(SUM(fee),0), COALESCE(SUM(pnl),0), COALESCE(SUM(pnl_net),0), COUNT(*) " + "FROM trade_logs WHERE fee IS NOT NULL" + ).fetchone() + fee_review = conn.execute( + "SELECT COALESCE(SUM(fee),0), COALESCE(SUM(pnl),0), COALESCE(SUM(pnl_net),0), COUNT(*) " + "FROM review_records WHERE fee IS NOT NULL" + ).fetchone() + total_fee = round((fee_trade[0] or 0) + (fee_review[0] or 0), 2) + total_gross = round((fee_trade[1] or 0) + (fee_review[1] or 0), 2) + total_net = round((fee_trade[2] or 0) + (fee_review[2] or 0), 2) + fee_count = (fee_trade[3] or 0) + (fee_review[3] or 0) + + fee_by_symbol = conn.execute( + """SELECT symbol_name, symbol, + COALESCE(SUM(fee),0) as total_fee, + COUNT(*) as cnt + FROM trade_logs WHERE fee IS NOT NULL + GROUP BY symbol ORDER BY total_fee DESC LIMIT 20""" ).fetchall() conn.close() @@ -1204,9 +1283,59 @@ def stats(): total=total, win=win, loss=loss, rate=rate, by_symbol=by_symbol, by_type=by_type, by_direction=by_direction, recent=recent, + total_fee=total_fee, + total_gross=total_gross, + total_net=total_net, + fee_count=fee_count, + fee_by_symbol=fee_by_symbol, ) +@app.route("/fees", methods=["GET", "POST"]) +@login_required +def fees(): + if request.method == "POST": + action = request.form.get("action") + if 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), + }) + 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) + + @app.route("/settings", methods=["GET", "POST"]) @login_required def settings(): diff --git a/data/fee_rates.json b/data/fee_rates.json new file mode 100644 index 0000000..738c51d --- /dev/null +++ b/data/fee_rates.json @@ -0,0 +1,36 @@ +{ + "ag": {"exchange": "SHFE", "mult": 15, "open_fixed": 2.0, "open_ratio": 0, "close_yesterday_fixed": 2.0, "close_yesterday_ratio": 0, "close_today_fixed": 4.0, "close_today_ratio": 0}, + "au": {"exchange": "SHFE", "mult": 1000, "open_fixed": 4.0, "open_ratio": 0, "close_yesterday_fixed": 4.0, "close_yesterday_ratio": 0, "close_today_fixed": 0, "close_today_ratio": 0}, + "cu": {"exchange": "SHFE", "mult": 5, "open_fixed": 0.1, "open_ratio": 0, "close_yesterday_fixed": 0.1, "close_yesterday_ratio": 0, "close_today_fixed": 0.2, "close_today_ratio": 0}, + "al": {"exchange": "SHFE", "mult": 5, "open_fixed": 6.0, "open_ratio": 0, "close_yesterday_fixed": 6.0, "close_yesterday_ratio": 0, "close_today_fixed": 6.0, "close_today_ratio": 0}, + "zn": {"exchange": "SHFE", "mult": 5, "open_fixed": 6.0, "open_ratio": 0, "close_yesterday_fixed": 6.0, "close_yesterday_ratio": 0, "close_today_fixed": 0, "close_today_ratio": 0}, + "pb": {"exchange": "SHFE", "mult": 5, "open_fixed": 0.08, "open_ratio": 0, "close_yesterday_fixed": 0.08, "close_yesterday_ratio": 0, "close_today_fixed": 0, "close_today_ratio": 0}, + "ni": {"exchange": "SHFE", "mult": 1, "open_fixed": 6.0, "open_ratio": 0, "close_yesterday_fixed": 6.0, "close_yesterday_ratio": 0, "close_today_fixed": 6.0, "close_today_ratio": 0}, + "sn": {"exchange": "SHFE", "mult": 1, "open_fixed": 6.0, "open_ratio": 0, "close_yesterday_fixed": 6.0, "close_yesterday_ratio": 0, "close_today_fixed": 6.0, "close_today_ratio": 0}, + "rb": {"exchange": "SHFE", "mult": 10, "open_fixed": 2.0, "open_ratio": 0, "close_yesterday_fixed": 2.0, "close_yesterday_ratio": 0, "close_today_fixed": 4.0, "close_today_ratio": 0}, + "hc": {"exchange": "SHFE", "mult": 10, "open_fixed": 2.0, "open_ratio": 0, "close_yesterday_fixed": 2.0, "close_yesterday_ratio": 0, "close_today_fixed": 4.0, "close_today_ratio": 0}, + "ru": {"exchange": "SHFE", "mult": 10, "open_fixed": 6.0, "open_ratio": 0, "close_yesterday_fixed": 6.0, "close_yesterday_ratio": 0, "close_today_fixed": 12.0, "close_today_ratio": 0}, + "fu": {"exchange": "SHFE", "mult": 10, "open_fixed": 0.1, "open_ratio": 0, "close_yesterday_fixed": 0.1, "close_yesterday_ratio": 0, "close_today_fixed": 0, "close_today_ratio": 0}, + "bu": {"exchange": "SHFE", "mult": 10, "open_fixed": 0.1, "open_ratio": 0, "close_yesterday_fixed": 0.1, "close_yesterday_ratio": 0, "close_today_fixed": 0.2, "close_today_ratio": 0}, + "sp": {"exchange": "SHFE", "mult": 10, "open_fixed": 0.1, "open_ratio": 0, "close_yesterday_fixed": 0.1, "close_yesterday_ratio": 0, "close_today_fixed": 0.1, "close_today_ratio": 0}, + "sc": {"exchange": "INE", "mult": 1000, "open_fixed": 40.0, "open_ratio": 0, "close_yesterday_fixed": 40.0, "close_yesterday_ratio": 0, "close_today_fixed": 0, "close_today_ratio": 0}, + "i": {"exchange": "DCE", "mult": 100, "open_fixed": 2.0, "open_ratio": 0, "close_yesterday_fixed": 2.0, "close_yesterday_ratio": 0, "close_today_fixed": 4.0, "close_today_ratio": 0}, + "j": {"exchange": "DCE", "mult": 100, "open_fixed": 2.4, "open_ratio": 0, "close_yesterday_fixed": 2.4, "close_yesterday_ratio": 0, "close_today_fixed": 2.4, "close_today_ratio": 0}, + "jm": {"exchange": "DCE", "mult": 60, "open_fixed": 2.4, "open_ratio": 0, "close_yesterday_fixed": 2.4, "close_yesterday_ratio": 0, "close_today_fixed": 2.4, "close_today_ratio": 0}, + "m": {"exchange": "DCE", "mult": 10, "open_fixed": 2.4, "open_ratio": 0, "close_yesterday_fixed": 2.4, "close_yesterday_ratio": 0, "close_today_fixed": 2.4, "close_today_ratio": 0}, + "y": {"exchange": "DCE", "mult": 10, "open_fixed": 5.0, "open_ratio": 0, "close_yesterday_fixed": 5.0, "close_yesterday_ratio": 0, "close_today_fixed": 5.0, "close_today_ratio": 0}, + "p": {"exchange": "DCE", "mult": 10, "open_fixed": 5.0, "open_ratio": 0, "close_yesterday_fixed": 5.0, "close_yesterday_ratio": 0, "close_today_fixed": 5.0, "close_today_ratio": 0}, + "c": {"exchange": "DCE", "mult": 10, "open_fixed": 2.4, "open_ratio": 0, "close_yesterday_fixed": 2.4, "close_yesterday_ratio": 0, "close_today_fixed": 2.4, "close_today_ratio": 0}, + "l": {"exchange": "DCE", "mult": 5, "open_fixed": 2.0, "open_ratio": 0, "close_yesterday_fixed": 2.0, "close_yesterday_ratio": 0, "close_today_fixed": 2.0, "close_today_ratio": 0}, + "pp": {"exchange": "DCE", "mult": 5, "open_fixed": 2.0, "open_ratio": 0, "close_yesterday_fixed": 2.0, "close_yesterday_ratio": 0, "close_today_fixed": 2.0, "close_today_ratio": 0}, + "if": {"exchange": "CFFEX", "mult": 300, "open_fixed": 0, "open_ratio": 0.000092, "close_yesterday_fixed": 0, "close_yesterday_ratio": 0.000092, "close_today_fixed": 0, "close_today_ratio": 0.000276}, + "ih": {"exchange": "CFFEX", "mult": 300, "open_fixed": 0, "open_ratio": 0.000092, "close_yesterday_fixed": 0, "close_yesterday_ratio": 0.000092, "close_today_fixed": 0, "close_today_ratio": 0.000276}, + "ic": {"exchange": "CFFEX", "mult": 200, "open_fixed": 0, "open_ratio": 0.000092, "close_yesterday_fixed": 0, "close_yesterday_ratio": 0.000092, "close_today_fixed": 0, "close_today_ratio": 0.000276}, + "im": {"exchange": "CFFEX", "mult": 200, "open_fixed": 0, "open_ratio": 0.000092, "close_yesterday_fixed": 0, "close_yesterday_ratio": 0.000092, "close_today_fixed": 0, "close_today_ratio": 0.000276}, + "ma": {"exchange": "CZCE", "mult": 10, "open_fixed": 4.0, "open_ratio": 0, "close_yesterday_fixed": 4.0, "close_yesterday_ratio": 0, "close_today_fixed": 4.0, "close_today_ratio": 0}, + "ta": {"exchange": "CZCE", "mult": 5, "open_fixed": 6.0, "open_ratio": 0, "close_yesterday_fixed": 6.0, "close_yesterday_ratio": 0, "close_today_fixed": 0, "close_today_ratio": 0}, + "sr": {"exchange": "CZCE", "mult": 10, "open_fixed": 6.0, "open_ratio": 0, "close_yesterday_fixed": 6.0, "close_yesterday_ratio": 0, "close_today_fixed": 0, "close_today_ratio": 0}, + "cf": {"exchange": "CZCE", "mult": 5, "open_fixed": 8.6, "open_ratio": 0, "close_yesterday_fixed": 8.6, "close_yesterday_ratio": 0, "close_today_fixed": 0, "close_today_ratio": 0}, + "fg": {"exchange": "CZCE", "mult": 20, "open_fixed": 12.0, "open_ratio": 0, "close_yesterday_fixed": 12.0, "close_yesterday_ratio": 0, "close_today_fixed": 12.0, "close_today_ratio": 0}, + "sa": {"exchange": "CZCE", "mult": 20, "open_fixed": 7.2, "open_ratio": 0, "close_yesterday_fixed": 7.2, "close_yesterday_ratio": 0, "close_today_fixed": 7.2, "close_today_ratio": 0} +} diff --git a/fee_specs.py b/fee_specs.py new file mode 100644 index 0000000..d37aece --- /dev/null +++ b/fee_specs.py @@ -0,0 +1,278 @@ +"""期货手续费:本地费率表 + 开平合计估算(模拟盘参考)。""" +import json +import os +import re +import sqlite3 +from datetime import datetime +from typing import Optional + +from contract_specs import get_contract_spec + +DB_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), "futures.db") +DATA_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "data") +DEFAULT_JSON = os.path.join(DATA_DIR, "fee_rates.json") + +# 无配置时的兜底(已为交易所标准约 2 倍) +DEFAULT_FEE = { + "open_fixed": 2.0, + "open_ratio": 0.0, + "close_yesterday_fixed": 2.0, + "close_yesterday_ratio": 0.0, + "close_today_fixed": 4.0, + "close_today_ratio": 0.0, +} + +_INDEX_PRODUCTS = {"if", "ih", "ic", "im"} + + +def product_from_code(ths_code: str) -> str: + code = (ths_code or "").strip() + m = re.match(r"^([A-Za-z]+)", code) + return m.group(1).lower() if m else "" + + +def _get_db(): + conn = sqlite3.connect(DB_PATH) + conn.row_factory = sqlite3.Row + return conn + + +def get_fee_multiplier() -> float: + conn = _get_db() + row = conn.execute( + "SELECT value FROM settings WHERE key='fee_multiplier'" + ).fetchone() + conn.close() + if row and row["value"]: + try: + return max(0.0, float(row["value"])) + except ValueError: + pass + return 2.0 + + +def get_fee_spec(ths_code: str) -> 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() + + 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), + } + + if product in _INDEX_PRODUCTS: + return { + "product": product, + "exchange": "CFFEX", + "mult": mult, + "open_fixed": 0.0, + "open_ratio": 0.000092, + "close_yesterday_fixed": 0.0, + "close_yesterday_ratio": 0.000092, + "close_today_fixed": 0.0, + "close_today_ratio": 0.000276, + } + + return { + "product": product, + "exchange": "", + "mult": mult, + **DEFAULT_FEE, + } + + +def calc_side_fee( + price: float, + lots: float, + mult: int, + fixed: float, + ratio: float, +) -> float: + lots = lots or 1.0 + fixed = fixed or 0.0 + ratio = ratio or 0.0 + return fixed * lots + ratio * price * mult * lots + + +def is_same_day(open_time: str, close_time: str) -> bool: + if not open_time or not close_time: + return True + o = open_time.strip().replace(" ", "T")[:10] + c = close_time.strip().replace(" ", "T")[:10] + return o == c + + +def calc_round_trip_fee( + ths_code: str, + entry_price: float, + close_price: float, + lots: float, + open_time: str = "", + close_time: str = "", +) -> float: + if not entry_price or not close_price: + return 0.0 + spec = get_fee_spec(ths_code) + mult = spec["mult"] + lots = lots or 1.0 + + open_fee = calc_side_fee( + entry_price, lots, mult, + spec["open_fixed"], spec["open_ratio"], + ) + if is_same_day(open_time, close_time): + close_fee = calc_side_fee( + close_price, lots, mult, + spec["close_today_fixed"], spec["close_today_ratio"], + ) + else: + close_fee = calc_side_fee( + close_price, lots, mult, + spec["close_yesterday_fixed"], spec["close_yesterday_ratio"], + ) + return round(open_fee + close_fee, 2) + + +def calc_fee_breakdown( + ths_code: str, + entry_price: float, + close_price: float, + lots: float, + open_time: str = "", + close_time: str = "", +) -> dict: + spec = get_fee_spec(ths_code) + mult = spec["mult"] + lots = lots or 1.0 + open_fee = calc_side_fee( + entry_price, lots, mult, spec["open_fixed"], spec["open_ratio"], + ) + same_day = is_same_day(open_time, close_time) + if same_day: + close_fee = calc_side_fee( + close_price, lots, mult, + spec["close_today_fixed"], spec["close_today_ratio"], + ) + close_type = "平今" + else: + close_fee = calc_side_fee( + close_price, lots, mult, + spec["close_yesterday_fixed"], spec["close_yesterday_ratio"], + ) + close_type = "平昨" + total = round(open_fee + close_fee, 2) + return { + "open_fee": round(open_fee, 2), + "close_fee": round(close_fee, 2), + "close_type": close_type, + "total_fee": total, + "same_day": same_day, + } + + +def load_fee_rates_from_json(path: Optional[str] = None) -> int: + path = path or DEFAULT_JSON + if not os.path.isfile(path): + return 0 + with open(path, "r", encoding="utf-8") as f: + data = json.load(f) + conn = _get_db() + now = datetime.now().isoformat(timespec="seconds") + count = 0 + for product, item in data.items(): + if not isinstance(item, dict): + continue + 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 (?,?,?,?,?,?,?,?,?,?) + ON CONFLICT(product) DO UPDATE SET + exchange=excluded.exchange, mult=excluded.mult, + open_fixed=excluded.open_fixed, open_ratio=excluded.open_ratio, + close_yesterday_fixed=excluded.close_yesterday_fixed, + 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""", + ( + product.lower(), + item.get("exchange", ""), + int(item.get("mult") or get_contract_spec(product)["mult"]), + float(item.get("open_fixed") or 0), + float(item.get("open_ratio") or 0), + float(item.get("close_yesterday_fixed") or 0), + float(item.get("close_yesterday_ratio") or 0), + float(item.get("close_today_fixed") or 0), + float(item.get("close_today_ratio") or 0), + now, + ), + ) + count += 1 + conn.commit() + conn.close() + return count + + +def list_all_fee_rates() -> list: + conn = _get_db() + rows = conn.execute( + "SELECT * FROM fee_rates ORDER BY product" + ).fetchall() + conn.close() + return [dict(r) for r in rows] + + +def upsert_fee_rate(product: str, fields: dict) -> None: + product = product.lower().strip() + conn = _get_db() + now = datetime.now().isoformat(timespec="seconds") + 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 (?,?,?,?,?,?,?,?,?,?) + ON CONFLICT(product) DO UPDATE SET + exchange=excluded.exchange, mult=excluded.mult, + open_fixed=excluded.open_fixed, open_ratio=excluded.open_ratio, + close_yesterday_fixed=excluded.close_yesterday_fixed, + 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""", + ( + product, + fields.get("exchange", ""), + int(fields.get("mult") or 10), + float(fields.get("open_fixed") or 0), + float(fields.get("open_ratio") or 0), + float(fields.get("close_yesterday_fixed") or 0), + float(fields.get("close_yesterday_ratio") or 0), + float(fields.get("close_today_fixed") or 0), + float(fields.get("close_today_ratio") or 0), + now, + ), + ) + conn.commit() + conn.close() diff --git a/fee_sync.py b/fee_sync.py new file mode 100644 index 0000000..c67f044 --- /dev/null +++ b/fee_sync.py @@ -0,0 +1,85 @@ +"""从第三方(AKShare)同步交易所参考手续费,并按倍率写入本地表。""" +import re +from typing import Any, Optional + +from contract_specs import get_contract_spec +from fee_specs import get_fee_multiplier, upsert_fee_rate + + +def _to_float(val: Any) -> float: + if val is None: + return 0.0 + s = str(val).strip().replace(",", "") + if not s or s in ("-", "None", "nan"): + return 0.0 + try: + return float(s) + except ValueError: + return 0.0 + + +def _parse_akshare_row(row: dict, multiplier: float) -> Optional[dict]: + code = str(row.get("合约代码") or row.get("代码") or "").strip() + if not code: + return None + m = re.match(r"^([A-Za-z]+)", code) + if not m: + return None + product = m.group(1).lower() + + open_ratio = _to_float(row.get("手续费标准-开仓-万分之")) / 10000.0 + open_fixed = _to_float(row.get("手续费标准-开仓-元")) + if open_fixed == 0 and row.get("开仓"): + open_fixed = _to_float(row.get("开仓")) + close_y_ratio = _to_float(row.get("手续费标准-平昨-万分之")) / 10000.0 + close_y_fixed = _to_float(row.get("手续费标准-平昨-元")) + if close_y_fixed == 0 and row.get("平昨"): + close_y_fixed = _to_float(row.get("平昨")) + close_t_ratio = _to_float(row.get("手续费标准-平今-万分之")) / 10000.0 + close_t_fixed = _to_float(row.get("手续费标准-平今-元")) + if close_t_fixed == 0 and row.get("平今"): + close_t_fixed = _to_float(row.get("平今")) + + mult = int(get_contract_spec(code)["mult"]) + exchange = str(row.get("交易所名称") or row.get("交易所") or "").strip() + + return { + "product": product, + "exchange": exchange, + "mult": mult, + "open_fixed": round(open_fixed * multiplier, 6), + "open_ratio": round(open_ratio * multiplier, 8), + "close_yesterday_fixed": round(close_y_fixed * multiplier, 6), + "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), + } + + +def sync_fees_from_akshare(multiplier: Optional[float] = None) -> tuple[int, str]: + multiplier = multiplier if multiplier is not None else get_fee_multiplier() + try: + import akshare as ak + except ImportError: + return 0, "未安装 akshare,请执行 pip install akshare 后重试,或使用默认费率表" + + try: + df = ak.futures_comm_info(symbol="所有") + except Exception as exc: + return 0, f"拉取第三方数据失败: {exc}" + + if df is None or df.empty: + return 0, "第三方返回空数据" + + seen: set[str] = set() + count = 0 + for _, series in df.iterrows(): + row = series.to_dict() + parsed = _parse_akshare_row(row, multiplier) + if not parsed or parsed["product"] in seen: + continue + seen.add(parsed["product"]) + upsert_fee_rate(parsed["product"], parsed) + count += 1 + + return count, f"已同步 {count} 个品种(标准费率 × {multiplier})" diff --git a/requirements.txt b/requirements.txt index 47b704c..52eff1c 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,3 +3,4 @@ requests==2.32.3 python-dotenv==1.0.1 Werkzeug==3.0.3 matplotlib==3.9.2 +# 可选:第三方手续费同步 pip install akshare diff --git a/static/js/positions.js b/static/js/positions.js index c05f43e..1f3d1d9 100644 --- a/static/js/positions.js +++ b/static/js/positions.js @@ -37,6 +37,9 @@ '
' + rr + '
' + '
' + (row.mark_price != null ? fmtNum(row.mark_price) : '--') + '
' + '
' + pnlText + '
' + + '
' + fmtNum(row.est_fee) + '元
' + + '
' + + '
' + (row.est_pnl_net != null ? fmtNum(row.est_pnl_net) + '元' : '--') + '
' + '' + '' ); } diff --git a/static/js/review.js b/static/js/review.js index d687fce..2cbc3b1 100644 --- a/static/js/review.js +++ b/static/js/review.js @@ -92,7 +92,7 @@ var labels = [ '品种', '合约', '方向', '张数', '周期', '成交价', '止损', '止盈', '平仓价', - '开仓时间', '平仓时间', '持仓时长', '盈利金额', '盈亏比', + '开仓时间', '平仓时间', '持仓时长', '盈利金额', '手续费', '净盈亏', '盈亏比', '开仓类型', '行为标签' ]; var values = [ @@ -109,6 +109,8 @@ fmtTime(data.close_time), esc(data.holding_duration), esc(data.pnl), + esc(data.fee), + esc(data.pnl_net), fmtRR(data), esc(data.open_type), fmtTags(data) @@ -122,7 +124,7 @@ html += '
'; values.forEach(function (val, i) { var cls = ''; - if (i === 15 && data.is_emotion) cls = ' class="emotion-val"'; + if (i === 17 && data.is_emotion) cls = ' class="emotion-val"'; html += '' + val + ''; }); html += '
'; diff --git a/templates/base.html b/templates/base.html index f13d798..82f983a 100644 --- a/templates/base.html +++ b/templates/base.html @@ -341,7 +341,7 @@ .pos-card-head .title{font-size:1rem;font-weight:600;color:var(--text-title)} .pos-card-meta{font-size:.75rem;color:var(--text-muted);margin-bottom:.65rem} .pos-card-meta strong{color:var(--text-primary)} - .pos-metrics{display:grid;grid-template-columns:repeat(3,1fr);gap:.5rem .65rem;margin-bottom:.65rem} + .pos-metrics{display:grid;grid-template-columns:repeat(4,1fr);gap:.5rem .65rem;margin-bottom:.65rem} .pos-metrics .cell label{display:block;font-size:.68rem;color:var(--text-muted);margin-bottom:.15rem} .pos-metrics .cell div{font-size:.88rem;color:var(--text-primary)} .pos-metrics .cell.pnl-pos div{color:var(--profit)} @@ -405,6 +405,7 @@ 持仓监控 交易记录与复盘 统计分析 + 手续费配置 系统设置 diff --git a/templates/fees.html b/templates/fees.html new file mode 100644 index 0000000..3a0790b --- /dev/null +++ b/templates/fees.html @@ -0,0 +1,67 @@ +{% extends "base.html" %} +{% block title %}手续费配置 - 国内期货监控系统{% endblock %} +{% block content %} +
+

手续费倍率

+
+
+ + + + +
+

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

+
+
+ + +
+
+ + +
+
+
+
+ +
+

品种费率表(已含倍率)

+
+ + + + + + + + + + + + {% for r in rates %} + + + + + + + + + + + + + + + + + + {% else %} + + {% endfor %} + +
品种交易所乘数开仓(元/手)开仓(比例)平昨(元/手)平昨(比例)平今(元/手)平今(比例)更新操作
{{ r.product }}{{ (r.updated_at or '')[:16] }}
暂无费率,请同步或重载 JSON
+
+

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

+
+{% endblock %} diff --git a/templates/records.html b/templates/records.html index caa65fa..3e1e981 100644 --- a/templates/records.html +++ b/templates/records.html @@ -16,7 +16,7 @@ 成交止损(开仓)止盈 基数杠杆持仓分钟 开仓时间平仓时间 - 盈亏(元)结果操作 + 盈亏(元)手续费净盈亏结果操作 @@ -74,6 +74,12 @@ + {{ t.fee if t.fee is not none else '-' }} + + + {{ t.pnl_net if t.pnl_net is not none else '-' }} + + {% if t.result == '止盈' %}{{ t.result }} @@ -99,7 +105,7 @@ {% else %} - 暂无交易记录 + 暂无交易记录 {% endfor %} @@ -208,7 +214,7 @@ - + @@ -222,6 +228,12 @@ {% elif r.pnl and r.pnl < 0 %}{{ r.pnl }} {% else %}{{ r.actual_pnl or '-' }}{% endif %} + + {% else %} - + {% endfor %}
平仓品种方向盈亏情绪单详情平仓品种方向盈亏手续费净盈亏情绪单详情
{{ r.fee if r.fee is not none else '-' }} + {% if r.pnl_net and r.pnl_net > 0 %}{{ r.pnl_net }} + {% elif r.pnl_net and r.pnl_net < 0 %}{{ r.pnl_net }} + {% else %}-{% endif %} + {% if r.is_emotion %}情绪单{% else %}-{% endif %}
暂无复盘记录
暂无复盘记录
diff --git a/templates/stats.html b/templates/stats.html index 0eba552..6c59c43 100644 --- a/templates/stats.html +++ b/templates/stats.html @@ -9,6 +9,13 @@
胜率
{{ rate }}%
+
+
累计手续费
{{ total_fee }} 元
+
毛盈亏合计
{{ total_gross }} 元
+
净盈亏合计
{{ total_net }} 元
+
计费笔数
{{ fee_count }}
+
+

按品种统计

@@ -28,6 +35,24 @@
+
+

手续费按品种(交易记录)

+ + + + {% for f in fee_by_symbol %} + + + + + + {% else %} + + {% endfor %} + +
品种笔数累计手续费(元)
{{ f.symbol_name or f.symbol }}{{ f.cnt }}{{ round(f.total_fee, 2) }}
暂无手续费数据
+
+

按类型统计

@@ -67,22 +92,30 @@
-

最近 10 笔交易

+

最近 10 笔交易记录

- + {% for r in recent %} + + + - + {% else %} - + {% endfor %}
品种方向结果时间
品种方向毛盈亏手续费净盈亏结果时间
{{ r.symbol_name or r.symbol }} {{ '做多' if r.direction == 'long' else '做空' }}{{ r.pnl if r.pnl is not none else '-' }}{{ r.fee if r.fee is not none else '-' }} + {% if r.pnl_net and r.pnl_net > 0 %}{{ r.pnl_net }} + {% elif r.pnl_net and r.pnl_net < 0 %}{{ r.pnl_net }} + {% else %}-{% endif %} + {% if r.result == '止盈' %}止盈 - {% else %}止损{% endif %} + {% elif r.result == '止损' %}止损 + {% else %}{{ r.result or '-' }}{% endif %} {{ r.created_at[:16] if r.created_at else '' }}{{ r.close_time[:16] if r.close_time else (r.created_at[:16] if r.created_at else '') }}
暂无数据
暂无数据