e5a586f903
Move business code under modules/, env template to config/, PM2 single qihuo process, and _legacy shims for old imports. Co-authored-by: Cursor <cursoragent@cursor.com>
219 lines
7.5 KiB
Python
219 lines
7.5 KiB
Python
# Copyright (c) 2025-2026 马建军. All rights reserved.
|
|
# 专有软件 — 未经授权禁止复制、传播、转售。
|
|
# 严禁用于:带单/代客理财、向他人推荐期货品种或买卖建议、融资配资等业务。
|
|
# 详见 LICENSE.zh-CN.txt 与 docs/软件购买与使用协议.md
|
|
|
|
"""交易记录:字段补全、资金曲线数据。"""
|
|
from __future__ import annotations
|
|
|
|
from typing import Any
|
|
|
|
|
|
TRADE_LOG_EXTRA_COLUMNS = (
|
|
"ALTER TABLE trade_logs ADD COLUMN margin_pct REAL",
|
|
"ALTER TABLE trade_logs ADD COLUMN equity_after REAL",
|
|
"ALTER TABLE trade_logs ADD COLUMN source TEXT DEFAULT 'local'",
|
|
"ALTER TABLE trade_logs ADD COLUMN ctp_trade_key TEXT",
|
|
)
|
|
|
|
|
|
def ensure_trade_log_columns(conn) -> None:
|
|
for sql in TRADE_LOG_EXTRA_COLUMNS:
|
|
try:
|
|
conn.execute(sql)
|
|
except Exception:
|
|
pass
|
|
|
|
|
|
def calc_equity_after(capital: float, pnl_net: float) -> float | None:
|
|
cap = float(capital or 0)
|
|
if cap <= 0:
|
|
return None
|
|
return round(cap + float(pnl_net or 0), 2)
|
|
|
|
|
|
def recalc_trade_log_pnl(
|
|
*,
|
|
symbol: str,
|
|
direction: str,
|
|
entry_price: float,
|
|
close_price: float,
|
|
lots: float,
|
|
stop_loss: float | None = None,
|
|
take_profit: float | None = None,
|
|
open_time: str = "",
|
|
close_time: str = "",
|
|
trading_mode: str = "simulation",
|
|
capital: float = 0.0,
|
|
) -> dict[str, float]:
|
|
"""按开/平仓价重算盈亏与手续费(跨日持仓可手动改价后核对)。"""
|
|
from modules.core.contract_specs import calc_position_metrics
|
|
from modules.fees.fee_specs import calc_round_trip_fee
|
|
|
|
sym = (symbol or "").strip()
|
|
direction = (direction or "long").strip().lower()
|
|
entry = float(entry_price or close_price or 0)
|
|
close_px = float(close_price or 0)
|
|
lots_f = float(lots or 0)
|
|
sl = float(stop_loss) if stop_loss is not None else entry
|
|
tp = float(take_profit) if take_profit is not None else entry
|
|
metrics = calc_position_metrics(
|
|
direction, entry, sl, tp, lots_f, close_px, capital, sym,
|
|
)
|
|
pnl = round(float(metrics.get("float_pnl") or 0), 2)
|
|
fee = calc_round_trip_fee(
|
|
sym, entry, close_px, lots_f, open_time, close_time, trading_mode=trading_mode,
|
|
)
|
|
pnl_net = round(pnl - fee, 2)
|
|
return {"pnl": pnl, "fee": round(fee, 2), "pnl_net": pnl_net}
|
|
|
|
|
|
def _read_initial_capital(conn, initial_capital: float | None = None) -> float:
|
|
if initial_capital is not None and initial_capital > 0:
|
|
return float(initial_capital)
|
|
try:
|
|
row = conn.execute("SELECT value FROM settings WHERE key='live_capital'").fetchone()
|
|
if row and row[0]:
|
|
val = float(row[0] or 0)
|
|
if val > 0:
|
|
return val
|
|
except (TypeError, ValueError):
|
|
pass
|
|
try:
|
|
from modules.trading.product_recommend import DISCONNECTED_RECOMMEND_CAPITAL
|
|
return float(DISCONNECTED_RECOMMEND_CAPITAL)
|
|
except Exception:
|
|
return 100_000.0
|
|
|
|
|
|
def refresh_trade_log_equity_chain(
|
|
conn,
|
|
initial_capital: float | None = None,
|
|
) -> int:
|
|
"""按平仓时间顺序重算 trade_logs.equity_after(起始=参考资金 live_capital)。"""
|
|
base = _read_initial_capital(conn, initial_capital)
|
|
rows = [
|
|
dict(r)
|
|
for r in conn.execute(
|
|
"SELECT id, close_time, pnl_net FROM trade_logs ORDER BY close_time ASC, id ASC"
|
|
).fetchall()
|
|
]
|
|
running = float(base or 0)
|
|
updated = 0
|
|
for row in rows:
|
|
if running <= 0:
|
|
break
|
|
running = round(running + float(row.get("pnl_net") or 0), 2)
|
|
conn.execute(
|
|
"UPDATE trade_logs SET equity_after=? WHERE id=?",
|
|
(running, int(row["id"])),
|
|
)
|
|
updated += 1
|
|
return updated
|
|
|
|
|
|
def _norm_symbol(symbol: str) -> str:
|
|
return (symbol or "").split(".")[0].strip().lower()
|
|
|
|
|
|
def _norm_close_minute(ts: str) -> str:
|
|
"""统一 close_time 到分钟粒度,兼容 ISO `T` 与空格分隔。"""
|
|
return (ts or "").strip().replace("T", " ")[:16]
|
|
|
|
|
|
def purge_duplicate_local_trade_logs(conn) -> int:
|
|
"""删除已被 CTP 柜台记录覆盖的本地重复成交。"""
|
|
removed = 0
|
|
ctp_rows = [
|
|
dict(r)
|
|
for r in conn.execute("SELECT * FROM trade_logs WHERE source='ctp'").fetchall()
|
|
]
|
|
local_rows = [
|
|
dict(r)
|
|
for r in conn.execute(
|
|
"""SELECT * FROM trade_logs
|
|
WHERE COALESCE(source, 'local') != 'ctp'
|
|
AND (ctp_trade_key IS NULL OR ctp_trade_key = '')"""
|
|
).fetchall()
|
|
]
|
|
for ctp in ctp_rows:
|
|
ct16 = _norm_close_minute(ctp.get("close_time") or "")
|
|
sym_n = _norm_symbol(ctp.get("symbol") or "")
|
|
lots = float(ctp.get("lots") or 0)
|
|
direction = (ctp.get("direction") or "long").strip().lower()
|
|
for loc in local_rows:
|
|
if loc.get("id") == ctp.get("id"):
|
|
continue
|
|
if _norm_symbol(loc.get("symbol") or "") != sym_n:
|
|
continue
|
|
if (loc.get("direction") or "long").strip().lower() != direction:
|
|
continue
|
|
if _norm_close_minute(loc.get("close_time") or "") != ct16:
|
|
continue
|
|
if abs(float(loc.get("lots") or 0) - lots) > 0.01:
|
|
continue
|
|
conn.execute("DELETE FROM trade_logs WHERE id=?", (loc["id"],))
|
|
removed += 1
|
|
return removed
|
|
|
|
|
|
def _attach_symbol_meta(t: dict[str, Any]) -> None:
|
|
try:
|
|
from modules.core.symbols import position_symbol_meta
|
|
|
|
sym = (t.get("symbol") or "").strip()
|
|
meta = position_symbol_meta(sym)
|
|
if not t.get("symbol_name"):
|
|
t["symbol_name"] = meta.get("name") or sym
|
|
t["symbol_exchange"] = meta.get("exchange") or ""
|
|
t["symbol_is_main"] = bool(meta.get("is_main"))
|
|
except Exception:
|
|
t.setdefault("symbol_exchange", "")
|
|
t.setdefault("symbol_is_main", False)
|
|
|
|
|
|
def enrich_trades_for_records(
|
|
trades: list[dict[str, Any]],
|
|
*,
|
|
initial_capital: float = 0.0,
|
|
) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
|
|
"""表格仍按 id 降序;资金曲线按平仓时间升序用最新资金绘制。"""
|
|
rows = [dict(t) for t in trades]
|
|
chrono = sorted(
|
|
rows,
|
|
key=lambda t: ((t.get("close_time") or ""), int(t.get("id") or 0)),
|
|
)
|
|
running = float(initial_capital or 0)
|
|
curve: list[dict[str, Any]] = []
|
|
equity_by_id: dict[int, float | None] = {}
|
|
|
|
for t in chrono:
|
|
_attach_symbol_meta(t)
|
|
pnl_net = float(t.get("pnl_net") or 0)
|
|
if running > 0:
|
|
running = round(running + pnl_net, 2)
|
|
eq: float | None = running
|
|
else:
|
|
eq = None
|
|
equity_by_id[int(t.get("id") or 0)] = eq
|
|
|
|
cap_before = float(eq or 0) - pnl_net if eq is not None else 0.0
|
|
if t.get("margin_pct") is None:
|
|
margin = float(t.get("margin") or 0)
|
|
if margin > 0 and cap_before > 0:
|
|
t["margin_pct"] = round(margin / cap_before * 100, 2)
|
|
|
|
if eq is not None:
|
|
curve.append({
|
|
"time": (t.get("close_time") or "")[:19],
|
|
"value": float(eq),
|
|
"id": int(t.get("id") or 0),
|
|
})
|
|
|
|
for t in rows:
|
|
tid = int(t.get("id") or 0)
|
|
if tid in equity_by_id:
|
|
t["equity_after"] = equity_by_id[tid]
|
|
|
|
return rows, curve
|