# 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/", 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/", 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/") @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/") @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/") @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/") @login_required def uploaded_file(filename): from flask import send_from_directory return send_from_directory(UPLOAD_DIR, filename) @app.route("/del_record/") @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"))