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>
555 lines
22 KiB
Python
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"))
|