Files
dekun e5a586f903 Restructure into modules/ with single-process CTP and config/ layout.
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>
2026-07-01 14:42:16 +08:00

555 lines
22 KiB
Python

# Copyright (c) 2025-2026 马建军. All rights reserved.
"""HTTP routes for records module."""
from __future__ import annotations
from datetime import date, datetime
from flask import (
Response,
flash,
jsonify,
redirect,
render_template,
request,
send_file,
session,
stream_with_context,
url_for,
)
def register(deps) -> None:
app = deps.app
login_required = deps.login_required
require_nav = deps.require_nav
get_db = deps.get_db
get_setting = deps.get_setting
set_setting = deps.set_setting
fetch_price = deps.fetch_price
send_wechat_msg = deps.send_wechat_msg
touch_stats_cache = deps.touch_stats_cache
get_stats_data = deps.get_stats_data
build_market_quote_payload = deps.build_market_quote_payload
today_str = deps.today_str
expire_old_plans = deps.expire_old_plans
TZ = deps.tz
DB_PATH = deps.db_path
UPLOAD_DIR = deps.upload_dir
OPEN_TYPES = deps.open_types
EXIT_TRIGGERS = deps.exit_triggers
BEHAVIOR_TAGS = deps.behavior_tags
KLINE_PERIODS = deps.kline_periods
KLINE_CUTOFFS = deps.kline_cutoffs
calc_holding_duration = deps.calc_holding_duration
holding_to_minutes = deps.holding_to_minutes
classify_close_result = deps.classify_close_result
calc_rr_ratio = deps.calc_rr_ratio
calc_theoretical_pnl = deps.calc_theoretical_pnl
parse_review_date_filter = deps.parse_review_date_filter
_trading_mode = deps.trading_mode
_ua_is_phone = deps.ua_is_phone
_static_asset_v = deps.static_asset_v
from werkzeug.utils import secure_filename
from modules.core.contract_specs import calc_position_metrics
from modules.fees.fee_specs import calc_fee_breakdown, calc_round_trip_fee
from modules.market.kline_chart import generate_review_kline_chart
@app.route("/api/position_live")
@login_required
def api_position_live():
capital = float(get_setting("live_capital", "0") or 0)
now_iso = datetime.now(TZ).strftime("%Y-%m-%dT%H:%M")
conn = get_db()
rows = conn.execute(
"SELECT * FROM position_monitors WHERE status='active' ORDER BY id DESC"
).fetchall()
conn.close()
out = []
for r in rows:
sym = r["symbol"]
market = r["market_code"] or ""
sina = r["sina_code"] or ""
direction = r["direction"]
entry = float(r["entry_price"])
sl = float(r["stop_loss"])
tp = float(r["take_profit"])
lots = float(r["lots"] or 1)
mark = fetch_price(sym, market, sina)
metrics = calc_position_metrics(
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,
trading_mode=_trading_mode(),
)
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,
"symbol_code": sym,
"direction": "做多" if direction == "long" else "做空",
"lots": lots,
"entry_price": entry,
"stop_loss": sl,
"take_profit": tp,
"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)
@app.route("/close_position/<int:pid>", methods=["POST"])
@login_required
def close_position(pid):
conn = get_db()
row = conn.execute("SELECT * FROM position_monitors WHERE id=?", (pid,)).fetchone()
if not row:
conn.close()
flash("持仓不存在")
return redirect(url_for("positions"))
sym = row["symbol"]
market = row["market_code"] or ""
sina = row["sina_code"] or ""
direction = row["direction"]
entry = float(row["entry_price"])
sl = float(row["stop_loss"])
tp = float(row["take_profit"])
lots = float(row["lots"] or 1)
open_time = row["open_time"] or ""
close_time = datetime.now(TZ).strftime("%Y-%m-%dT%H:%M")
close_price = fetch_price(sym, market, sina)
if close_price is None:
conn.close()
flash("无法获取现价,平仓失败")
return redirect(url_for("positions"))
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, 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)
margin_pct = metrics.get("position_pct")
from modules.trading.trade_log_lib import calc_equity_after, refresh_trade_log_equity_chain
equity_after = calc_equity_after(capital, pnl_net)
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,
margin_pct, holding_minutes, open_time, close_time, pnl, fee, pnl_net,
equity_after, result)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""",
(
sym, row["symbol_name"], market, sina, "持仓监控", direction,
entry, sl, tp, close_price, lots, metrics["margin"],
margin_pct,
minutes, open_time, close_time, pnl, fee, pnl_net, equity_after, result,
),
)
conn.execute("DELETE FROM position_monitors WHERE id=?", (pid,))
try:
refresh_trade_log_equity_chain(conn, capital if capital > 0 else None)
except Exception as exc:
app.logger.debug("equity chain refresh after close: %s", exc)
conn.commit()
conn.close()
touch_stats_cache()
flash(f"已平仓,盈亏 {pnl:.2f} 元(扣费后 {pnl_net:.2f} 元),已记入交易记录")
return redirect(url_for("positions"))
@app.route("/trades")
@login_required
def trades():
return redirect(url_for("records"))
@app.route("/update_trade/<int:tid>", methods=["POST"])
@login_required
def update_trade(tid):
d = request.form
conn = get_db()
row = conn.execute("SELECT * FROM trade_logs WHERE id=?", (tid,)).fetchone()
if not row:
conn.close()
flash("记录不存在")
return redirect(url_for("records"))
row = dict(row)
entry = float(d.get("entry_price") or 0)
close_px = float(d.get("close_price") or 0)
lots = float(d.get("lots") or 0)
sl_raw = d.get("stop_loss")
tp_raw = d.get("take_profit")
stop_loss = float(sl_raw) if sl_raw not in (None, "") else None
take_profit = float(tp_raw) if tp_raw not in (None, "") else None
open_time = (d.get("open_time") or row.get("open_time") or "").strip()
close_time = (d.get("close_time") or row.get("close_time") or "").strip()
direction = (d.get("direction") or row.get("direction") or "long").strip()
from modules.trading.trade_log_lib import recalc_trade_log_pnl, refresh_trade_log_equity_chain, _read_initial_capital
from modules.core.trading_context import get_trading_mode
pnl = float(row.get("pnl") or 0)
fee = float(row.get("fee") or 0)
pnl_net = float(row.get("pnl_net") or 0)
old_entry = float(row.get("entry_price") or 0)
old_close = float(row.get("close_price") or 0)
old_lots = float(row.get("lots") or 0)
prices_changed = (
abs(entry - old_entry) > 0.0001
or abs(close_px - old_close) > 0.0001
or abs(lots - old_lots) > 0.0001
)
if prices_changed and close_px > 0 and entry > 0 and lots > 0:
calc = recalc_trade_log_pnl(
symbol=row.get("symbol") or "",
direction=direction,
entry_price=entry,
close_price=close_px,
lots=lots,
stop_loss=stop_loss,
take_profit=take_profit,
open_time=open_time,
close_time=close_time,
trading_mode=get_trading_mode(get_setting),
)
pnl = calc["pnl"]
fee = calc["fee"]
pnl_net = calc["pnl_net"]
form_pnl_raw = d.get("pnl")
if form_pnl_raw not in (None, ""):
pnl = float(form_pnl_raw)
pnl_net = round(pnl - fee, 2)
try:
holding_to_minutes = deps.holding_to_minutes
minutes = int(holding_to_minutes(open_time, close_time) or 0)
except Exception:
minutes = int(d.get("holding_minutes") or row.get("holding_minutes") or 0)
conn.execute(
"""UPDATE trade_logs SET
symbol_name=?, monitor_type=?, direction=?,
entry_price=?, stop_loss=?, take_profit=?, close_price=?,
lots=?, margin=?, holding_minutes=?, open_time=?, close_time=?,
pnl=?, fee=?, pnl_net=?, result=?, verified=1
WHERE id=?""",
(
d.get("symbol_name", "").strip(),
d.get("monitor_type", "").strip(),
direction,
entry,
stop_loss,
take_profit,
close_px,
lots,
float(d.get("margin") or 0),
minutes,
open_time,
close_time,
pnl,
fee,
pnl_net,
d.get("result", "").strip(),
tid,
),
)
try:
refresh_trade_log_equity_chain(conn, _read_initial_capital(conn))
except Exception as exc:
app.logger.debug("equity chain refresh after trade edit: %s", exc)
conn.commit()
conn.close()
touch_stats_cache()
flash("交易记录已核对保存")
return redirect(url_for("records"))
@app.route("/del_trade/<int:tid>")
@login_required
def del_trade(tid):
conn = get_db()
conn.execute("DELETE FROM trade_logs WHERE id=?", (tid,))
conn.commit()
conn.close()
touch_stats_cache()
flash("已删除")
return redirect(url_for("records"))
@app.route("/fill_review/<int:tid>")
@login_required
def fill_review_from_trade(tid):
conn = get_db()
row = conn.execute("SELECT * FROM trade_logs WHERE id=?", (tid,)).fetchone()
conn.close()
if not row:
flash("记录不存在")
return redirect(url_for("records"))
q = {
"symbol": row["symbol"],
"symbol_name": row["symbol_name"] or row["symbol"],
"market_code": row["market_code"] or "",
"sina_code": row["sina_code"] or "",
"direction": row["direction"],
"entry_price": row["entry_price"],
"stop_loss": row["stop_loss"],
"take_profit": row["take_profit"],
"close_price": row["close_price"],
"lots": row["lots"],
"open_time": row["open_time"],
"close_time": row["close_time"],
"pnl": row["pnl"],
}
params = {k: v for k, v in q.items() if v is not None}
return redirect(url_for("records", **params) + "#review-panel")
@app.route("/records")
@login_required
def records():
preset = request.args.get("preset", "")
start = request.args.get("start", "")
end = request.args.get("end", "")
if preset:
start, end = parse_review_date_filter(preset, start, end)
conn = get_db()
ctp_sync_info = None
sql = "SELECT * FROM review_records WHERE 1=1"
params: list = []
if start:
sql += " AND date(close_time) >= ?"
params.append(start)
if end:
sql += " AND date(close_time) <= ?"
params.append(end)
sql += " ORDER BY id DESC LIMIT 200"
review_list = conn.execute(sql, params).fetchall()
auto_list = conn.execute(
"SELECT * FROM trade_records ORDER BY id DESC LIMIT 30"
).fetchall()
trade_list = conn.execute(
"SELECT * FROM trade_logs ORDER BY id DESC LIMIT 500"
).fetchall()
from modules.trading.trade_log_lib import enrich_trades_for_records, _read_initial_capital
try:
initial_capital = _read_initial_capital(conn)
except Exception:
initial_capital = 100_000.0
trades, equity_curve = enrich_trades_for_records(
[dict(r) for r in trade_list],
initial_capital=initial_capital,
)
conn.close()
trade_prefill_keys = (
"symbol", "symbol_name", "market_code", "sina_code", "direction",
"entry_price", "stop_loss", "take_profit", "close_price",
"lots", "open_time", "close_time", "pnl",
)
prefill = {k: request.args.get(k) for k in trade_prefill_keys if request.args.get(k)}
return render_template(
"records.html",
reviews=review_list,
trades=trades,
equity_curve=equity_curve,
auto_records=auto_list,
ctp_sync_info=ctp_sync_info,
preset=preset,
start=start,
end=end,
prefill=prefill,
open_types=OPEN_TYPES,
exit_triggers=EXIT_TRIGGERS,
behavior_tags=BEHAVIOR_TAGS,
kline_periods=KLINE_PERIODS,
kline_cutoffs=KLINE_CUTOFFS,
)
@app.route("/add_review", methods=["POST"])
@login_required
def add_review():
d = request.form
open_type = d.get("open_type", "").strip()
exit_trigger = d.get("exit_trigger", "").strip()
if not open_type:
flash("请选择开仓类型")
return redirect(url_for("records"))
if not exit_trigger:
flash("请选择离场触发")
return redirect(url_for("records"))
symbol = d.get("symbol", "").strip()
symbol_name = d.get("symbol_name", "").strip()
market_code = d.get("market_code", "").strip()
sina_code = d.get("sina_code", "").strip()
if not symbol or not market_code:
flash("请从下拉列表选择品种(同花顺合约代码)")
return redirect(url_for("records"))
screenshot = ""
f = request.files.get("screenshot")
if f and f.filename:
fname = secure_filename(f.filename)
ts = datetime.now(TZ).strftime("%Y%m%d%H%M%S")
screenshot = f"{ts}_{fname}"
f.save(os.path.join(UPLOAD_DIR, screenshot))
tags = [t for t in BEHAVIOR_TAGS if d.get(f"tag_{t}")]
is_emotion = 1 if tags else 0
def num(key: str) -> Optional[float]:
v = d.get(key, "").strip()
if not v:
return None
return float(v)
open_time = d.get("open_time", "").strip()
close_time = d.get("close_time", "").strip()
direction = d.get("direction", "").strip()
entry_price = num("entry_price")
stop_loss = num("stop_loss")
take_profit = num("take_profit")
close_price = num("close_price")
lots = num("lots") or 1.0
holding = calc_holding_duration(open_time, close_time)
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,
trading_mode=_trading_mode(),
)
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:
generated = generate_review_kline_chart(
symbol=symbol,
periods=[d.get("kline_period1", "15m"), d.get("kline_period2", "1h")],
count=int(d.get("kline_count") or 300),
cutoff_label=d.get("kline_cutoff", "平仓时间"),
open_time=open_time,
close_time=close_time,
entry_price=entry_price,
stop_loss=stop_loss,
take_profit=take_profit,
close_price=close_price,
upload_dir=UPLOAD_DIR,
)
if generated:
screenshot = generated
except Exception as exc:
app.logger.warning("auto kline failed: %s", exc)
conn = get_db()
conn.execute(
"""INSERT INTO review_records
(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, 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 (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""",
(
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, gross_pnl, fee, pnl_net,
open_type,
None,
None,
exit_trigger,
d.get("exit_supplement", "").strip(),
d.get("watch_after_breakeven", ""),
d.get("new_position_while_occupied", ""),
screenshot,
1 if auto_kline else 0,
d.get("kline_period1", "15m"),
d.get("kline_period2", "1h"),
int(d.get("kline_count") or 300),
d.get("kline_cutoff", "平仓时间"),
",".join(tags),
is_emotion,
d.get("notes", "").strip(),
),
)
hook = getattr(app, "_risk_review_hook", None)
if hook:
hook(
conn,
",".join(tags),
exit_trigger,
d.get("exit_supplement", "").strip(),
)
conn.commit()
conn.close()
touch_stats_cache()
flash("复盘记录已保存")
return redirect(url_for("records"))
@app.route("/del_review/<int:rid>")
@login_required
def del_review(rid):
conn = get_db()
row = conn.execute("SELECT screenshot FROM review_records WHERE id=?", (rid,)).fetchone()
if row and row["screenshot"]:
path = os.path.join(UPLOAD_DIR, row["screenshot"])
if os.path.isfile(path):
os.remove(path)
conn.execute("DELETE FROM review_records WHERE id=?", (rid,))
conn.commit()
conn.close()
touch_stats_cache()
flash("已删除")
return redirect(url_for("records"))
@app.route("/uploads/<path:filename>")
@login_required
def uploaded_file(filename):
from flask import send_from_directory
return send_from_directory(UPLOAD_DIR, filename)
@app.route("/del_record/<int:rid>")
@login_required
def del_record(rid):
conn = get_db()
conn.execute("DELETE FROM trade_records WHERE id=?", (rid,))
conn.commit()
conn.close()
flash("已删除")
return redirect(url_for("records"))