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 @@
'
' +
'' + (row.mark_price != null ? fmtNum(row.mark_price) : '--') + '
' +
'' +
+ '' + 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 %}
+ | 暂无费率,请同步或重载 JSON |
+ {% endfor %}
+
+
+
+
比例按「成交价×乘数×手数×比例」计费;元/手为固定每手。开+平合计为一笔往返手续费。
+
+{% 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 %}
+ {{ 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 %} |
|
{% else %}
- | 暂无复盘记录 |
+ | 暂无复盘记录 |
{% endfor %}
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 @@
+
+
+
+
手续费按品种(交易记录)
+
+ | 品种 | 笔数 | 累计手续费(元) |
+
+ {% for f in fee_by_symbol %}
+
+ | {{ f.symbol_name or f.symbol }} |
+ {{ f.cnt }} |
+ {{ round(f.total_fee, 2) }} |
+
+ {% else %}
+ | 暂无手续费数据 |
+ {% endfor %}
+
+
+
+
按类型统计
@@ -67,22 +92,30 @@
-
最近 10 笔交易
+
最近 10 笔交易记录
- | 品种 | 方向 | 结果 | 时间 |
+ | 品种 | 方向 | 毛盈亏 | 手续费 | 净盈亏 | 结果 | 时间 |
{% for r in recent %}
| {{ 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 '') }} |
{% else %}
- | 暂无数据 |
+ | 暂无数据 |
{% endfor %}