From ae1b91fcc589866438bfe07d546a8a9413e368a9 Mon Sep 17 00:00:00 2001 From: dekun Date: Sat, 17 Oct 2026 22:31:17 +0800 Subject: [PATCH] Add trade stats board: win rate, hold buckets, by machine/side. Co-authored-by: Cursor --- app/db.py | 190 +++++++++++++++++++++++++++++++ app/routes.py | 31 ++++- app/templates/base.html | 3 +- app/templates/records.html | 7 +- app/templates/records_stats.html | 151 ++++++++++++++++++++++++ 5 files changed, 375 insertions(+), 7 deletions(-) create mode 100644 app/templates/records_stats.html diff --git a/app/db.py b/app/db.py index 159735f..706eefa 100644 --- a/app/db.py +++ b/app/db.py @@ -304,6 +304,196 @@ def list_trades( return rows +def _hold_seconds(open_time: Any, close_time: Any, now_ts: Optional[float] = None) -> Optional[float]: + if open_time in (None, ""): + return None + try: + start = float(open_time) + end = float(close_time) if close_time not in (None, "") else float(now_ts or now()) + except (TypeError, ValueError): + return None + return max(0.0, end - start) + + +def _fmt_hold_secs(secs: Optional[float]) -> str: + if secs is None: + return "—" + s = int(secs) + days, rem = divmod(s, 86400) + hours, rem = divmod(rem, 3600) + mins, sec = divmod(rem, 60) + if days > 0: + return f"{days}天{hours}小时{mins}分" + if hours > 0: + return f"{hours}小时{mins}分" + if mins > 0: + return f"{mins}分{sec}秒" + return f"{sec}秒" + + +def _bucket_key(secs: Optional[float]) -> str: + if secs is None: + return "unknown" + if secs < 300: + return "lt_5m" + if secs < 1800: + return "m5_30" + if secs < 7200: + return "m30_2h" + return "ge_2h" + + +def _empty_group(label: str) -> dict[str, Any]: + return { + "label": label, + "count": 0, + "wins": 0, + "losses": 0, + "breakeven": 0, + "profit": 0.0, + "hold_sum": 0.0, + "hold_n": 0, + } + + +def _finalize_group(g: dict[str, Any]) -> dict[str, Any]: + n = g["count"] + decided = g["wins"] + g["losses"] + g["win_rate"] = (g["wins"] / decided * 100.0) if decided else 0.0 + g["avg_profit"] = (g["profit"] / n) if n else 0.0 + g["avg_hold_secs"] = (g["hold_sum"] / g["hold_n"]) if g["hold_n"] else None + g["avg_hold"] = _fmt_hold_secs(g["avg_hold_secs"]) + return g + + +def _accumulate(g: dict[str, Any], profit: Optional[float], hold: Optional[float]) -> None: + g["count"] += 1 + p = float(profit) if profit is not None else 0.0 + g["profit"] += p + if profit is not None: + if p > 1e-9: + g["wins"] += 1 + elif p < -1e-9: + g["losses"] += 1 + else: + g["breakeven"] += 1 + if hold is not None: + g["hold_sum"] += hold + g["hold_n"] += 1 + + +def trade_stats( + conn: sqlite3.Connection, + account_id: Optional[int] = None, + machine_id: Optional[int] = None, +) -> dict[str, Any]: + """程序统计看板:盈亏/胜率/持仓时长分段/按机器与方向。""" + clause, params = _trades_where(account_id, machine_id) + sql = f""" + SELECT t.id, t.side, t.symbol, t.profit, t.open_time, t.close_time, + t.volume, t.machine_id, t.entry_style, m.name AS machine_name + FROM trades t + LEFT JOIN machines m ON m.id = t.machine_id + {clause} + ORDER BY COALESCE(t.close_time, t.open_time, t.created_at) DESC + """ + rows = [row_to_dict(r) for r in conn.execute(sql, tuple(params)).fetchall()] + now_ts = now() + + bucket_labels = { + "lt_5m": "< 5 分钟", + "m5_30": "5–30 分钟", + "m30_2h": "30 分钟–2 小时", + "ge_2h": "≥ 2 小时", + "unknown": "时长未知", + } + by_hold = {k: _empty_group(v) for k, v in bucket_labels.items()} + by_machine: dict[str, dict[str, Any]] = {} + by_side: dict[str, dict[str, Any]] = {} + + total = len(rows) + closed = 0 + open_pos = 0 + wins = losses = breakeven = 0 + profit_sum = 0.0 + gross_win = 0.0 + gross_loss = 0.0 + hold_sum = 0.0 + hold_n = 0 + annotated = 0 + + for t in rows: + profit = t.get("profit") + hold = _hold_seconds(t.get("open_time"), t.get("close_time"), now_ts) + closed_flag = t.get("close_time") not in (None, "") + if closed_flag: + closed += 1 + else: + open_pos += 1 + + if profit is not None: + p = float(profit) + profit_sum += p + if p > 1e-9: + wins += 1 + gross_win += p + elif p < -1e-9: + losses += 1 + gross_loss += abs(p) + else: + breakeven += 1 + + if hold is not None: + hold_sum += hold + hold_n += 1 + + if (t.get("entry_style") or "").strip(): + annotated += 1 + + bk = _bucket_key(hold) + _accumulate(by_hold[bk], profit, hold) + + mid = t.get("machine_id") + mkey = str(mid) if mid is not None else "none" + if mkey not in by_machine: + name = t.get("machine_name") or ("未绑定" if mid is None else f"#{mid}") + by_machine[mkey] = _empty_group(name) + _accumulate(by_machine[mkey], profit, hold) + + side = (t.get("side") or "—").lower() + if side not in by_side: + by_side[side] = _empty_group("买入" if side == "buy" else ("卖出" if side == "sell" else side)) + _accumulate(by_side[side], profit, hold) + + decided = wins + losses + avg_hold = (hold_sum / hold_n) if hold_n else None + return { + "total": total, + "closed": closed, + "open_pos": open_pos, + "wins": wins, + "losses": losses, + "breakeven": breakeven, + "win_rate": (wins / decided * 100.0) if decided else 0.0, + "profit": profit_sum, + "avg_profit": (profit_sum / total) if total else 0.0, + "gross_win": gross_win, + "gross_loss": gross_loss, + "profit_factor": (gross_win / gross_loss) if gross_loss > 1e-9 else (None if gross_win <= 0 else 999.0), + "avg_hold_secs": avg_hold, + "avg_hold": _fmt_hold_secs(avg_hold), + "annotated": annotated, + "unannotated": total - annotated, + "by_hold": [_finalize_group(by_hold[k]) for k in ("lt_5m", "m5_30", "m30_2h", "ge_2h", "unknown") if by_hold[k]["count"] or k != "unknown"], + "by_machine": sorted( + [_finalize_group(g) for g in by_machine.values()], + key=lambda x: x["profit"], + reverse=True, + ), + "by_side": [_finalize_group(g) for g in by_side.values()], + } + + def get_trade(conn: sqlite3.Connection, trade_id: int) -> Optional[dict]: r = conn.execute( """ diff --git a/app/routes.py b/app/routes.py index 8283e56..1c99720 100644 --- a/app/routes.py +++ b/app/routes.py @@ -621,21 +621,26 @@ def register_routes(app: Flask) -> None: bars = database.get_bars(conn, symbol, timeframe, start_ts, end_ts) return jsonify({"ok": True, "bars": bars}) + def _resolve_machine_filter(u: dict, machine_raw: str): + machine_id = int(machine_raw) if machine_raw.isdigit() else None + with database.get_db() as conn: + machines = database.list_machines_for_user(conn, u) + allowed_ids = {int(m["id"]) for m in machines} + if machine_id is not None and machine_id not in allowed_ids: + machine_id = None + return machines, machine_id + @app.get("/records") @login_required def records_page(): u = current_user() machine_raw = (request.args.get("machine_id") or "").strip() - machine_id = int(machine_raw) if machine_raw.isdigit() else None page_raw = (request.args.get("page") or "1").strip() page = int(page_raw) if page_raw.isdigit() and int(page_raw) >= 1 else 1 per_page = 10 account_id = None if u["role"] == "admin" else u.get("account_id") + machines, machine_id = _resolve_machine_filter(u, machine_raw) with database.get_db() as conn: - machines = database.list_machines_for_user(conn, u) - allowed_ids = {int(m["id"]) for m in machines} - if machine_id is not None and machine_id not in allowed_ids: - machine_id = None total = database.count_trades(conn, account_id=account_id, machine_id=machine_id) total_pages = max(1, (total + per_page - 1) // per_page) if page > total_pages: @@ -661,6 +666,22 @@ def register_routes(app: Flask) -> None: total_pages=total_pages, ) + @app.get("/records/stats") + @login_required + def records_stats_page(): + u = current_user() + machine_raw = (request.args.get("machine_id") or "").strip() + account_id = None if u["role"] == "admin" else u.get("account_id") + machines, machine_id = _resolve_machine_filter(u, machine_raw) + with database.get_db() as conn: + stats = database.trade_stats(conn, account_id=account_id, machine_id=machine_id) + return render_template( + "records_stats.html", + stats=stats, + machines=machines, + selected_machine_id=machine_id, + ) + @app.get("/records/") @login_required def record_detail(tid: int): diff --git a/app/templates/base.html b/app/templates/base.html index a871fdc..c5030d4 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -12,7 +12,8 @@
{{ app_name }}