From d9b8069906b0689942d543c7bd3074928c687ed2 Mon Sep 17 00:00:00 2001 From: dekun Date: Fri, 17 Jul 2026 11:52:02 +0800 Subject: [PATCH] Add shared records review pagination for all three exchanges. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Paginate trade records, journals, and AI history at 5 per page with soft in-card flips; hide the journal form until 填入复盘. Co-authored-by: Cursor --- crypto_monitor_binance/app.py | 15 + crypto_monitor_gate/app.py | 15 + crypto_monitor_okx/app.py | 15 + lib/common/static/instance_embed.js | 8 +- lib/common/static/records_review_page.js | 604 ++++++++++++++++++ lib/hub/hub_bridge.py | 1 + lib/instance/records_api_register.py | 54 ++ lib/instance/records_list_lib.py | 44 ++ .../templates/embed_boot_scripts.html | 3 +- .../templates/embed_page_fragment.html | 180 +----- lib/instance/templates/embed_shell.html | 3 +- lib/instance/templates/index.html | 183 +----- lib/instance/templates/records_panel.html | 152 +++++ tests/test_records_list_lib.py | 83 +++ 14 files changed, 999 insertions(+), 361 deletions(-) create mode 100644 lib/common/static/records_review_page.js create mode 100644 lib/instance/records_api_register.py create mode 100644 lib/instance/records_list_lib.py create mode 100644 lib/instance/templates/records_panel.html create mode 100644 tests/test_records_list_lib.py diff --git a/crypto_monitor_binance/app.py b/crypto_monitor_binance/app.py index 5d1b334..25a25cc 100644 --- a/crypto_monitor_binance/app.py +++ b/crypto_monitor_binance/app.py @@ -9425,6 +9425,21 @@ def api_journal_upload_slot(): return jsonify(payload), code +from lib.instance.records_api_register import register_trade_records_api + +register_trade_records_api( + app, + login_required=login_required, + get_db=get_db, + list_window_from_request=_list_window_from_request, + utc_window_to_bj_sql_strings=utc_window_to_bj_sql_strings, + sql_list_time_field=sql_list_time_field, + to_effective_trade_dict=to_effective_trade_dict, + filter_trade_records_excluding_miss=filter_trade_records_excluding_miss, + app_tz=APP_TZ, +) + + @app.route("/api/journals") @login_required def api_journals(): diff --git a/crypto_monitor_gate/app.py b/crypto_monitor_gate/app.py index 476e8b6..735a0c1 100644 --- a/crypto_monitor_gate/app.py +++ b/crypto_monitor_gate/app.py @@ -9292,6 +9292,21 @@ def api_journal_upload_slot(): return jsonify(payload), code +from lib.instance.records_api_register import register_trade_records_api + +register_trade_records_api( + app, + login_required=login_required, + get_db=get_db, + list_window_from_request=_list_window_from_request, + utc_window_to_bj_sql_strings=utc_window_to_bj_sql_strings, + sql_list_time_field=sql_list_time_field, + to_effective_trade_dict=to_effective_trade_dict, + filter_trade_records_excluding_miss=filter_trade_records_excluding_miss, + app_tz=APP_TZ, +) + + @app.route("/api/journals") @login_required def api_journals(): diff --git a/crypto_monitor_okx/app.py b/crypto_monitor_okx/app.py index b2e571e..3c114e1 100644 --- a/crypto_monitor_okx/app.py +++ b/crypto_monitor_okx/app.py @@ -8980,6 +8980,21 @@ def api_journal_upload_slot(): return jsonify(payload), code +from lib.instance.records_api_register import register_trade_records_api + +register_trade_records_api( + app, + login_required=login_required, + get_db=get_db, + list_window_from_request=_list_window_from_request, + utc_window_to_bj_sql_strings=utc_window_to_bj_sql_strings, + sql_list_time_field=sql_list_time_field, + to_effective_trade_dict=to_effective_trade_dict, + filter_trade_records_excluding_miss=filter_trade_records_excluding_miss, + app_tz=APP_TZ, +) + + @app.route("/api/journals") @login_required def api_journals(): diff --git a/lib/common/static/instance_embed.js b/lib/common/static/instance_embed.js index b6f5c9e..13f378a 100644 --- a/lib/common/static/instance_embed.js +++ b/lib/common/static/instance_embed.js @@ -103,8 +103,12 @@ global.initStrategyRollForm(); } if (tab === "records") { - if (!revisit && typeof global.loadJournals === "function") global.loadJournals(); - if (!revisit && typeof global.loadReviews === "function") global.loadReviews(); + if (global.RecordsReviewPage && typeof global.RecordsReviewPage.init === "function") { + global.RecordsReviewPage.init({ refresh: !!revisit }); + } else { + if (!revisit && typeof global.loadJournals === "function") global.loadJournals(); + if (!revisit && typeof global.loadReviews === "function") global.loadReviews(); + } if (global.InstanceTheme && typeof global.InstanceTheme.initReviewEditModeSync === "function") { global.InstanceTheme.initReviewEditModeSync(); } else if (typeof global.toggleReviewMode === "function") { diff --git a/lib/common/static/records_review_page.js b/lib/common/static/records_review_page.js new file mode 100644 index 0000000..689d91b --- /dev/null +++ b/lib/common/static/records_review_page.js @@ -0,0 +1,604 @@ +/** + * 三所 /records:交易记录分页 + 复盘表单显隐 + 复盘/AI 列表分页(soft,每页5). + */ +(function (global) { + "use strict"; + + var PAGE_SIZE = 5; + var tradesPage = 0; + var tradesPages = 1; + var journalsAll = []; + var journalsPage = 0; + var journalsPages = 1; + var reviewsAll = []; + var reviewsPage = 0; + var reviewsPages = 1; + var tradesCache = {}; + var booted = false; + + function $(id) { + return document.getElementById(id); + } + + function esc(s) { + return String(s == null ? "" : s) + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """); + } + + function listQs() { + if (typeof global.listWindowQueryString === "function") { + return global.listWindowQueryString() || ""; + } + return ""; + } + + function fmtNum(v, digits) { + if (v == null || v === "") return "—"; + var n = Number(v); + if (!Number.isFinite(n)) return esc(v); + return n.toFixed(digits == null ? 2 : digits); + } + + function fmtTime(s) { + if (!s) return "—"; + return esc(String(s).slice(0, 16)); + } + + function resultBadge(result) { + var er = String(result || "").trim(); + if (["止盈", "保本止盈", "移动止盈"].indexOf(er) >= 0) { + return '' + esc(er) + ""; + } + if (["止损", "强制清仓", "手动平仓"].indexOf(er) >= 0) { + return '' + esc(er) + ""; + } + if (er === "时间平仓") return '' + esc(er) + ""; + return '' + esc(er || "-") + ""; + } + + function pnlClass(v) { + var n = Number(v); + if (!Number.isFinite(n) || n === 0) return ""; + return n > 0 ? "pnl-profit" : "pnl-loss"; + } + + function beginSoft(wrapId, soft) { + var wrap = $(wrapId); + if (!wrap) return null; + if (soft) { + if (!wrap.style.minHeight) { + wrap.style.minHeight = Math.max(wrap.offsetHeight, 1) + "px"; + } + wrap.classList.add("rr-list-loading"); + } else { + wrap.classList.remove("rr-list-loading"); + wrap.style.minHeight = ""; + } + return wrap; + } + + function endSoft(wrap) { + if (!wrap) return; + wrap.classList.remove("rr-list-loading"); + wrap.style.minHeight = ""; + } + + function updatePager(kind) { + var map = { + trades: { + page: tradesPage, + pages: tradesPages, + label: "rr-trades-page-label", + prev: "rr-trades-prev", + next: "rr-trades-next", + }, + journals: { + page: journalsPage, + pages: journalsPages, + label: "rr-journals-page-label", + prev: "rr-journals-prev", + next: "rr-journals-next", + }, + reviews: { + page: reviewsPage, + pages: reviewsPages, + label: "rr-reviews-page-label", + prev: "rr-reviews-prev", + next: "rr-reviews-next", + }, + }; + var m = map[kind]; + if (!m) return; + var label = $(m.label); + var prev = $(m.prev); + var next = $(m.next); + if (label) label.textContent = "第 " + (m.page + 1) + " / " + m.pages + " 页"; + if (prev) prev.disabled = m.page <= 0; + if (next) next.disabled = m.page + 1 >= m.pages; + } + + function fillPayload(t) { + return { + symbol: t.symbol, + monitor_type: t.monitor_type, + key_signal_type: t.key_signal_type || "", + direction: t.direction, + trigger_price: t.trigger_price, + stop_loss: t.display_open_stop_loss || t.initial_stop_loss || t.stop_loss, + take_profit: t.effective_take_profit || t.take_profit, + opened_at: t.effective_opened_at, + closed_at: t.effective_closed_at, + pnl_amount: t.effective_pnl_amount, + result: t.effective_result, + risk_amount: t.risk_amount, + effective_entry_reason: t.effective_entry_reason || "", + }; + } + + function editPayload(t) { + return { + id: t.id, + opened_at: t.effective_opened_at, + closed_at: t.effective_closed_at, + stop_loss: t.effective_stop_loss || t.initial_stop_loss || t.stop_loss, + take_profit: t.effective_take_profit || t.take_profit, + pnl_amount: t.effective_pnl_amount, + result: t.effective_result, + miss_reason: t.effective_miss_reason, + effective_entry_reason: t.effective_entry_reason || "", + }; + } + + function renderTradesRows(rows) { + var tbody = $("rr-trades-tbody"); + if (!tbody) return; + tradesCache = {}; + if (!rows || !rows.length) { + tbody.innerHTML = '暂无交易记录'; + return; + } + tbody.innerHTML = rows + .map(function (t) { + tradesCache[t.id] = t; + var mon = esc(t.monitor_type || ""); + if (t.key_signal_type) mon += " · " + esc(t.key_signal_type); + var stopShow = t.display_open_stop_loss || t.initial_stop_loss || t.stop_loss; + var tpShow = t.effective_take_profit || t.take_profit; + var pnl = t.effective_pnl_amount; + var pnlSrc = ""; + if (t.display_pnl_source === "exchange") { + pnlSrc = ''; + } else if (t.display_pnl_source !== "reviewed") { + pnlSrc = ''; + } + var dirCls = t.direction === "long" ? "direction-long" : "direction-short"; + var dirTxt = t.direction === "long" ? "做多" : "做空"; + var margin = + t.margin_capital != null && t.margin_capital !== "" + ? fmtNum(t.margin_capital, 2) + : "-"; + return ( + '' + + "" + + esc(t.symbol) + + "" + + "" + + mon + + "" + + "" + + esc(t.effective_entry_reason || "-") + + "" + + '' + + dirTxt + + "" + + "" + + fmtNum(t.trigger_price, 4) + + "" + + "" + + fmtNum(stopShow, 4) + + "" + + "" + + fmtNum(tpShow, 4) + + "" + + "" + + margin + + "" + + "" + + esc(t.leverage != null ? t.leverage : "-") + + "" + + "" + + esc(t.effective_hold_minutes || 0) + + "" + + "" + + fmtTime(t.effective_opened_at) + + "" + + "" + + fmtTime(t.effective_closed_at || t.created_at) + + "" + + '' + + fmtNum(pnl, 2) + + "" + + pnlSrc + + "" + + "" + + resultBadge(t.effective_result) + + "" + + "" + + ' ' + + ' ' + + '' + + "" + + "" + ); + }) + .join(""); + + tbody.querySelectorAll(".rr-fill-btn").forEach(function (btn) { + btn.addEventListener("click", function () { + var id = btn.getAttribute("data-id"); + var t = tradesCache[id]; + if (!t) return; + showJournalCard(); + if (typeof global.fillJournalFromTrade === "function") { + global.fillJournalFromTrade(fillPayload(t)); + } + }); + }); + tbody.querySelectorAll(".review-edit-btn").forEach(function (btn) { + btn.addEventListener("click", function () { + var id = btn.getAttribute("data-id"); + var t = tradesCache[id]; + if (!t) return; + if (typeof global.editTradeRecordReview === "function") { + global.editTradeRecordReview(editPayload(t)); + } + }); + }); + if (typeof global.toggleReviewMode === "function") { + global.toggleReviewMode(); + } + } + + function loadTradeRecords(opts) { + opts = opts || {}; + var soft = !!opts.soft; + var tbody = $("rr-trades-tbody"); + if (!tbody) return; + var wrap = beginSoft("rr-trades-wrap", soft); + if (!soft) { + tbody.innerHTML = '加载中…'; + } + var qs = listQs(); + var p = new URLSearchParams(qs || ""); + p.set("limit", String(PAGE_SIZE)); + p.set("offset", String(tradesPage * PAGE_SIZE)); + fetch("/api/trade_records?" + p.toString(), { credentials: "same-origin" }) + .then(function (r) { + return r.json(); + }) + .then(function (data) { + if (!data || !data.ok) { + tbody.innerHTML = '加载失败'; + endSoft(wrap); + return; + } + tradesPages = Math.max(1, Number(data.pages) || 1); + if (tradesPage >= tradesPages) { + tradesPage = Math.max(0, tradesPages - 1); + updatePager("trades"); + if (Number(data.total || 0) > 0) { + loadTradeRecords(opts); + return; + } + } + updatePager("trades"); + renderTradesRows(data.items || []); + endSoft(wrap); + }) + .catch(function () { + tbody.innerHTML = '加载失败'; + endSoft(wrap); + }); + } + + function renderJournalsPage(soft) { + var box = $("journal-list"); + if (!box) return; + var wrap = beginSoft("journal-list-wrap", soft); + var total = journalsAll.length; + journalsPages = Math.max(1, Math.ceil(total / PAGE_SIZE) || 1); + if (journalsPage >= journalsPages) journalsPage = Math.max(0, journalsPages - 1); + updatePager("journals"); + var slice = journalsAll.slice( + journalsPage * PAGE_SIZE, + journalsPage * PAGE_SIZE + PAGE_SIZE + ); + if (global.InstanceUI && typeof InstanceUI.renderJournalListHtml === "function") { + var html = InstanceUI.renderJournalListHtml(slice); + box.innerHTML = html || "
暂无数据
"; + } else { + box.innerHTML = "
暂无数据
"; + } + endSoft(wrap); + } + + function renderReviewsPage(soft) { + var box = $("review-list"); + if (!box) return; + var wrap = beginSoft("review-list-wrap", soft); + var total = reviewsAll.length; + reviewsPages = Math.max(1, Math.ceil(total / PAGE_SIZE) || 1); + if (reviewsPage >= reviewsPages) reviewsPage = Math.max(0, reviewsPages - 1); + updatePager("reviews"); + var slice = reviewsAll.slice( + reviewsPage * PAGE_SIZE, + reviewsPage * PAGE_SIZE + PAGE_SIZE + ); + if (!slice.length) { + box.innerHTML = "
暂无数据
"; + endSoft(wrap); + return; + } + var html = ""; + slice.forEach(function (r) { + if (global.reviewCache) global.reviewCache[r.id] = r; + var preview = (r.content || "").replace(/\s+/g, " ").trim(); + var shortText = preview.length > 90 ? preview.slice(0, 90) + "..." : preview; + html += + '
' + + "
" + + (r.review_type === "daily" ? "日复盘" : "周复盘") + + " | " + + esc(r.target_date) + + "
" + + '
' + + esc(r.created_at || "") + + "
" + + '
' + + esc(shortText || "(空)") + + "
" + + '
' + + '" + + '" + + '导出MD' + + '" + + "
"; + }); + box.innerHTML = html; + endSoft(wrap); + } + + function loadJournalsPaged() { + var qs = listQs(); + fetch("/api/journals" + (qs ? "?" + qs : ""), { credentials: "same-origin" }) + .then(function (r) { + return r.json(); + }) + .then(function (data) { + journalsAll = Array.isArray(data) ? data : []; + if (global.journalCache) { + Object.keys(global.journalCache).forEach(function (k) { + delete global.journalCache[k]; + }); + journalsAll.forEach(function (o) { + global.journalCache[o.id] = o; + }); + } + journalsPage = 0; + renderJournalsPage(false); + }); + } + + function loadReviewsPaged() { + var qs = listQs(); + fetch("/api/reviews" + (qs ? "?" + qs : ""), { credentials: "same-origin" }) + .then(function (r) { + return r.json(); + }) + .then(function (data) { + reviewsAll = Array.isArray(data) ? data : []; + if (global.reviewCache) { + Object.keys(global.reviewCache).forEach(function (k) { + delete global.reviewCache[k]; + }); + } else { + global.reviewCache = {}; + } + reviewsAll.forEach(function (r) { + global.reviewCache[r.id] = r; + }); + reviewsPage = 0; + renderReviewsPage(false); + }); + } + + function showJournalCard() { + var card = $("journal-card"); + if (card) card.classList.remove("hidden"); + var hint = $("rr-journal-fill-hint"); + if (hint) hint.style.display = ""; + } + + function hideJournalCard() { + var card = $("journal-card"); + if (card) card.classList.add("hidden"); + var hint = $("rr-journal-fill-hint"); + if (hint) hint.style.display = "none"; + } + + function patchFillJournalFromTrade() { + var prev = global.fillJournalFromTrade; + if (typeof prev !== "function") return; + if (prev.__rrPatched) return; + global.fillJournalFromTrade = function (t) { + showJournalCard(); + prev(t); + var hint = $("rr-journal-fill-hint"); + if (hint) hint.style.display = ""; + }; + global.fillJournalFromTrade.__rrPatched = true; + } + + function patchDeleteTradeRecord() { + var prev = global.deleteTradeRecord; + if (typeof prev !== "function") return; + if (prev.__rrPatched) return; + global.deleteTradeRecord = function (id) { + if (!confirm("确定删除这条交易记录?")) return; + fetch("/delete_trade_record/" + id, { method: "POST", credentials: "same-origin" }) + .then(function (r) { + return r.json(); + }) + .then(function (data) { + if (data && data.ok) { + loadTradeRecords({ soft: true }); + return; + } + if (typeof prev === "function") { + /* fallthrough reload */ + } + global.location.href = + (global.location.pathname || "/records") + "?_ts=" + Date.now(); + }) + .catch(function () { + global.location.href = + (global.location.pathname || "/records") + "?_ts=" + Date.now(); + }); + }; + global.deleteTradeRecord.__rrPatched = true; + } + + function bindPagers() { + var tp = $("rr-trades-prev"); + var tn = $("rr-trades-next"); + var jp = $("rr-journals-prev"); + var jn = $("rr-journals-next"); + var rp = $("rr-reviews-prev"); + var rn = $("rr-reviews-next"); + var hideBtn = $("rr-journal-hide-btn"); + if (tp) { + tp.addEventListener("click", function (ev) { + ev.preventDefault(); + if (tradesPage <= 0) return; + tradesPage -= 1; + updatePager("trades"); + loadTradeRecords({ soft: true }); + }); + } + if (tn) { + tn.addEventListener("click", function (ev) { + ev.preventDefault(); + if (tradesPage + 1 >= tradesPages) return; + tradesPage += 1; + updatePager("trades"); + loadTradeRecords({ soft: true }); + }); + } + if (jp) { + jp.addEventListener("click", function (ev) { + ev.preventDefault(); + if (journalsPage <= 0) return; + journalsPage -= 1; + renderJournalsPage(true); + }); + } + if (jn) { + jn.addEventListener("click", function (ev) { + ev.preventDefault(); + if (journalsPage + 1 >= journalsPages) return; + journalsPage += 1; + renderJournalsPage(true); + }); + } + if (rp) { + rp.addEventListener("click", function (ev) { + ev.preventDefault(); + if (reviewsPage <= 0) return; + reviewsPage -= 1; + renderReviewsPage(true); + }); + } + if (rn) { + rn.addEventListener("click", function (ev) { + ev.preventDefault(); + if (reviewsPage + 1 >= reviewsPages) return; + reviewsPage += 1; + renderReviewsPage(true); + }); + } + if (hideBtn) { + hideBtn.addEventListener("click", function (ev) { + ev.preventDefault(); + hideJournalCard(); + }); + } + } + + function init(opts) { + opts = opts || {}; + if (!$("records-panel-root")) return; + if (booted) { + if (opts.refresh) { + loadTradeRecords({ soft: true }); + loadJournalsPaged(); + loadReviewsPaged(); + } + patchFillJournalFromTrade(); + patchDeleteTradeRecord(); + return; + } + booted = true; + if (!global.journalCache) global.journalCache = {}; + if (!global.reviewCache) global.reviewCache = {}; + global.loadJournals = loadJournalsPaged; + global.loadReviews = loadReviewsPaged; + global.loadTradeRecords = loadTradeRecords; + patchFillJournalFromTrade(); + patchDeleteTradeRecord(); + bindPagers(); + updatePager("trades"); + updatePager("journals"); + updatePager("reviews"); + loadTradeRecords({ soft: false }); + loadJournalsPaged(); + loadReviewsPaged(); + } + + global.RecordsReviewPage = { + init: init, + loadTradeRecords: loadTradeRecords, + loadJournals: loadJournalsPaged, + loadReviews: loadReviewsPaged, + showJournalCard: showJournalCard, + hideJournalCard: hideJournalCard, + }; + + if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", init); + } else { + init(); + } +})(typeof window !== "undefined" ? window : globalThis); diff --git a/lib/hub/hub_bridge.py b/lib/hub/hub_bridge.py index ed4e817..ae62133 100644 --- a/lib/hub/hub_bridge.py +++ b/lib/hub/hub_bridge.py @@ -58,6 +58,7 @@ def install_instance_theme_static(app) -> None: "account_risk_badge.js": "application/javascript; charset=utf-8", "instance_ui.js": "application/javascript; charset=utf-8", "instance_records_mobile.js": "application/javascript; charset=utf-8", + "records_review_page.js": "application/javascript; charset=utf-8", "ai_review_render.js": "application/javascript; charset=utf-8", "form_submit_guard.js": "application/javascript; charset=utf-8", "key_monitor_form.js": "application/javascript; charset=utf-8", diff --git a/lib/instance/records_api_register.py b/lib/instance/records_api_register.py new file mode 100644 index 0000000..fb7d541 --- /dev/null +++ b/lib/instance/records_api_register.py @@ -0,0 +1,54 @@ +"""注册 /api/trade_records(三所共用).""" + +from __future__ import annotations + +from typing import Any, Callable + +from flask import Flask, jsonify, request + + +def register_trade_records_api( + app: Flask, + *, + login_required: Callable, + get_db: Callable, + list_window_from_request: Callable[[], dict[str, Any]], + utc_window_to_bj_sql_strings: Callable[..., tuple[str, str]], + sql_list_time_field: Callable[..., str], + to_effective_trade_dict: Callable[[Any], dict[str, Any]], + filter_trade_records_excluding_miss: Callable[[list], list], + app_tz: Any, +) -> None: + from lib.instance.records_list_lib import list_trade_records_page + + @app.route("/api/trade_records") + @login_required + def api_trade_records(): + win = list_window_from_request() + start_bj, end_bj = utc_window_to_bj_sql_strings( + win["start_utc"], win["end_utc"], app_tz + ) + tr_ts = sql_list_time_field("closed_at", "created_at", "opened_at") + try: + limit = int(request.args.get("limit") or 5) + except (TypeError, ValueError): + limit = 5 + try: + offset = int(request.args.get("offset") or 0) + except (TypeError, ValueError): + offset = 0 + conn = get_db() + try: + payload = list_trade_records_page( + conn, + start_bj, + end_bj, + tr_ts=tr_ts, + to_effective_fn=to_effective_trade_dict, + filter_fn=filter_trade_records_excluding_miss, + limit=limit, + offset=offset, + ) + return jsonify(payload) + finally: + conn.close() diff --git a/lib/instance/records_list_lib.py b/lib/instance/records_list_lib.py new file mode 100644 index 0000000..dbda60d --- /dev/null +++ b/lib/instance/records_list_lib.py @@ -0,0 +1,44 @@ +"""交易记录列表分页(三所 /records 共用).""" + +from __future__ import annotations + +from typing import Any, Callable + + +def list_trade_records_page( + conn: Any, + start_bj: str, + end_bj: str, + *, + tr_ts: str, + to_effective_fn: Callable[[Any], dict[str, Any]], + filter_fn: Callable[[list[dict[str, Any]]], list[dict[str, Any]]], + limit: int = 5, + offset: int = 0, + fetch_cap: int = 1000, +) -> dict[str, Any]: + """按列表窗拉取、enrich、过滤「错过」后分页.""" + limit = max(1, min(100, int(limit or 5))) + offset = max(0, int(offset or 0)) + raw_records = conn.execute( + f"SELECT * FROM trade_records WHERE {tr_ts} >= ? AND {tr_ts} <= ? " + f"ORDER BY id DESC LIMIT ?", + (start_bj, end_bj, int(fetch_cap)), + ).fetchall() + records = filter_fn([to_effective_fn(r) for r in raw_records]) + total = len(records) + pages = max(1, (total + limit - 1) // limit) if total else 1 + page = (offset // limit) + 1 if limit else 1 + if page > pages: + page = pages + offset = (page - 1) * limit + items = records[offset : offset + limit] + return { + "ok": True, + "items": items, + "total": total, + "limit": limit, + "offset": offset, + "page": page, + "pages": pages, + } diff --git a/lib/instance/templates/embed_boot_scripts.html b/lib/instance/templates/embed_boot_scripts.html index 990312b..b8fd8f5 100644 --- a/lib/instance/templates/embed_boot_scripts.html +++ b/lib/instance/templates/embed_boot_scripts.html @@ -95,6 +95,8 @@ function openAiInlineResultFullscreen(title, elementId){ const journalCache = {}; const reviewCache = {}; +window.journalCache = journalCache; +window.reviewCache = reviewCache; function formatJournalExitOneLine(o){ const t = (o.early_exit_trigger || "").trim(); @@ -579,7 +581,6 @@ function fillJournalFromTrade(t){ } recomputeJournalRealRr(); if(typeof syncEarlyExitNoteRequired === "function") syncEarlyExitNoteRequired(); - alert("已填入下方复盘表单,请手动补充主观原因."); } function recomputeJournalRealRr(){ diff --git a/lib/instance/templates/embed_page_fragment.html b/lib/instance/templates/embed_page_fragment.html index 9ba7d14..2e135ea 100644 --- a/lib/instance/templates/embed_page_fragment.html +++ b/lib/instance/templates/embed_page_fragment.html @@ -302,185 +302,8 @@ {% if page == 'records' %} -
-

交易记录

-
- -
-
- - - {% for r in record %} - - {% set pnl_val = (r.pnl_amount or 0)|float %} - - - - - - {% set stop_show = r.display_open_stop_loss or r.initial_stop_loss or r.stop_loss %} - {% set tp_show = r.effective_take_profit or r.take_profit %} - - - - - - - - {% set pnl_val = (r.effective_pnl_amount or 0)|float %} - - - - - {% endfor %} -
品种下单类型开仓类型方向成交止损(开仓)止盈基数杠杆持仓分钟开仓时间(北京)平仓时间(北京)盈亏U结果操作
{{ r.symbol }}{{ r.monitor_type }}{% if r.key_signal_type %} · {{ r.key_signal_type }}{% endif %}{{ r.effective_entry_reason or '-' }}{{ '做多' if r.direction == 'long' else '做空' }}{{ price_fmt(r.symbol, r.trigger_price) }}{{ price_fmt(r.symbol, stop_show) }}{{ price_fmt(r.symbol, tp_show) }}{% if r.margin_capital is not none and r.margin_capital != '' %}{{ funds_fmt(r.margin_capital) }}{% else %}-{% endif %}{{ r.leverage or '-' }}{{ r.effective_hold_minutes or 0 }}{{ (r.effective_opened_at or '-')[:16] }}{{ (r.effective_closed_at or r.created_at or '-')[:16] }}{{ funds_fmt(r.effective_pnl_amount or 0) }}{% if r.display_pnl_source == 'exchange' %}{% elif r.display_pnl_source != 'reviewed' %}{% endif %} - {% set effective_result = r.effective_result %} - {% if effective_result in ["止盈","保本止盈","移动止盈"] %}{{ effective_result }} - {% elif effective_result in ["止损","强制清仓","手动平仓"] %}{{ effective_result }} - {% elif effective_result == "时间平仓" %}{{ effective_result }} - {% else %}{{ effective_result or '-' }}{% endif %} - - - - -
-
-
- -
-

交易复盘记录上传(含截图)

-
- - - - - - {% from 'journal_form_fields.html' import journal_form_fields %} - {{ journal_form_fields(entry_reason_options, order_type_options) }} - {% from 'journal_upload_slots.html' import journal_upload_slots %} - {{ journal_upload_slots() }} -
- - - - - - - - - -
-
双周期上下排列;截止=平仓时间:开仓前背景至平仓;截止=当前时间:最近 N 根至此刻(可看平仓后走势);标注开仓,平仓与止损位
-
- - - - - - -
- - -
-
- -
-
-

AI复盘(按交易记录)

- -
-
- - - - - - - -
- - -
-
- 交易复盘记录 -
-
-
- AI历史复盘 -
-
-
-
- - + {% include 'records_panel.html' %} {% endif %} - {% if page == 'env_config' %} {% include 'env_config_panel.html' %} {% endif %} @@ -529,3 +352,4 @@ {% endif %} + diff --git a/lib/instance/templates/embed_shell.html b/lib/instance/templates/embed_shell.html index c9dbebe..b8af073 100644 --- a/lib/instance/templates/embed_shell.html +++ b/lib/instance/templates/embed_shell.html @@ -108,11 +108,12 @@ const ORDER_ENTRY_MODEL_CODE_TO_CATEGORY = {{ entry_model_code_to_category | toj {% include 'embed_boot_scripts.html' %} + - + diff --git a/lib/instance/templates/index.html b/lib/instance/templates/index.html index 684995d..bce24ff 100644 --- a/lib/instance/templates/index.html +++ b/lib/instance/templates/index.html @@ -376,185 +376,8 @@ {% if page == 'records' %} -
-

交易记录

-
- -
-
- - - {% for r in record %} - - {% set pnl_val = (r.pnl_amount or 0)|float %} - - - - - - {% set stop_show = r.display_open_stop_loss or r.initial_stop_loss or r.stop_loss %} - {% set tp_show = r.effective_take_profit or r.take_profit %} - - - - - - - - {% set pnl_val = (r.effective_pnl_amount or 0)|float %} - - - - - {% endfor %} -
品种下单类型开仓类型方向成交止损(开仓)止盈基数杠杆持仓分钟开仓时间(北京)平仓时间(北京)盈亏U结果操作
{{ r.symbol }}{{ r.monitor_type }}{% if r.key_signal_type %} · {{ r.key_signal_type }}{% endif %}{{ r.effective_entry_reason or '-' }}{{ '做多' if r.direction == 'long' else '做空' }}{{ price_fmt(r.symbol, r.trigger_price) }}{{ price_fmt(r.symbol, stop_show) }}{{ price_fmt(r.symbol, tp_show) }}{% if r.margin_capital is not none and r.margin_capital != '' %}{{ funds_fmt(r.margin_capital) }}{% else %}-{% endif %}{{ r.leverage or '-' }}{{ r.effective_hold_minutes or 0 }}{{ (r.effective_opened_at or '-')[:16] }}{{ (r.effective_closed_at or r.created_at or '-')[:16] }}{{ funds_fmt(r.effective_pnl_amount or 0) }}{% if r.display_pnl_source == 'exchange' %}{% elif r.display_pnl_source != 'reviewed' %}{% endif %} - {% set effective_result = r.effective_result %} - {% if effective_result in ["止盈","保本止盈","移动止盈"] %}{{ effective_result }} - {% elif effective_result in ["止损","强制清仓","手动平仓"] %}{{ effective_result }} - {% elif effective_result == "时间平仓" %}{{ effective_result }} - {% else %}{{ effective_result or '-' }}{% endif %} - - - - -
-
-
- -
-

交易复盘记录上传(含截图)

-
- - - - - - {% from 'journal_form_fields.html' import journal_form_fields %} - {{ journal_form_fields(entry_reason_options, order_type_options) }} - {% from 'journal_upload_slots.html' import journal_upload_slots %} - {{ journal_upload_slots() }} -
- - - - - - - - - -
-
双周期上下排列;截止=平仓时间:开仓前背景至平仓;截止=当前时间:最近 N 根至此刻(可看平仓后走势);标注开仓,平仓与止损位
-
- - - - - - -
- - -
-
- -
-
-

AI复盘(按交易记录)

- -
-
- - - - - - - -
- - -
-
- 交易复盘记录 -
-
-
- AI历史复盘 -
-
-
-
- - + {% include 'records_panel.html' %} {% endif %} - {% if page == 'env_config' %} {% include 'env_config_panel.html' %} @@ -729,6 +552,8 @@ function openAiInlineResultFullscreen(title, elementId){ const journalCache = {}; const reviewCache = {}; +window.journalCache = journalCache; +window.reviewCache = reviewCache; function formatJournalExitOneLine(o){ const t = (o.early_exit_trigger || "").trim(); @@ -1213,7 +1038,6 @@ function fillJournalFromTrade(t){ } recomputeJournalRealRr(); if(typeof syncEarlyExitNoteRequired === "function") syncEarlyExitNoteRequired(); - alert("已填入下方复盘表单,请手动补充主观原因."); } function recomputeJournalRealRr(){ @@ -2162,6 +1986,7 @@ setInterval(tickOrderHoldDurations, 1000); tickOrderHoldDurations(); setInterval(refreshPriceSnapshotConditional, {{ price_refresh_seconds * 1000 }}); + diff --git a/lib/instance/templates/records_panel.html b/lib/instance/templates/records_panel.html new file mode 100644 index 0000000..22221a4 --- /dev/null +++ b/lib/instance/templates/records_panel.html @@ -0,0 +1,152 @@ +{# 三所共用:交易记录(5/页) → 填入复盘出表单 → 交易复盘记录 / AI历史复盘 #} + +
+
+

交易记录

+

每页5条.点「填入复盘」打开下方复盘表单.

+
+ +
+
+ + + + + + + + + + + +
品种下单类型开仓类型方向成交止损(开仓)止盈基数杠杆持仓分钟开仓时间(北京)平仓时间(北京)盈亏U结果操作
加载中…
+
+
+ + 第 1 / 1 页 + +
+
+ + + +
+
+

AI复盘(按交易记录)

+ +
+
+ + + + + + + +
+ + +
+ +
+

交易复盘记录

+

已保存的复盘(每页5条).

+
+
+
+
+ + 第 1 / 1 页 + +
+
+ +
+

AI历史复盘

+

日/周 AI 复盘历史(每页5条).

+
+
+
+
+ + 第 1 / 1 页 + +
+
+
diff --git a/tests/test_records_list_lib.py b/tests/test_records_list_lib.py new file mode 100644 index 0000000..bde81b0 --- /dev/null +++ b/tests/test_records_list_lib.py @@ -0,0 +1,83 @@ +"""records_list_lib pagination.""" + +from __future__ import annotations + +import sqlite3 +import unittest + +from lib.instance.records_list_lib import list_trade_records_page +from lib.trade.trade_result_lib import filter_trade_records_excluding_miss + + +def _to_effective(row): + d = dict(row) + d["effective_result"] = d.get("result") + d["effective_pnl_amount"] = d.get("pnl_amount") + return d + + +class RecordsListLibTest(unittest.TestCase): + def setUp(self): + self.conn = sqlite3.connect(":memory:") + self.conn.row_factory = sqlite3.Row + self.conn.execute( + """ + CREATE TABLE trade_records ( + id INTEGER PRIMARY KEY, + closed_at TEXT, + created_at TEXT, + opened_at TEXT, + result TEXT, + pnl_amount REAL + ) + """ + ) + for i in range(12): + self.conn.execute( + "INSERT INTO trade_records(id, closed_at, created_at, opened_at, result, pnl_amount) " + "VALUES (?,?,?,?,?,?)", + (i + 1, f"2026-07-1{i % 9}-10:00:00", None, None, "止盈", 1.0), + ) + self.conn.execute( + "INSERT INTO trade_records(id, closed_at, created_at, opened_at, result, pnl_amount) " + "VALUES (?,?,?,?,?,?)", + (99, "2026-07-15-10:00:00", None, None, "错过", 0), + ) + self.conn.commit() + + def tearDown(self): + self.conn.close() + + def test_pages_exclude_miss(self): + out = list_trade_records_page( + self.conn, + "2026-07-01", + "2026-07-31", + tr_ts="COALESCE(closed_at, created_at, opened_at)", + to_effective_fn=_to_effective, + filter_fn=filter_trade_records_excluding_miss, + limit=5, + offset=0, + ) + self.assertTrue(out["ok"]) + self.assertEqual(out["total"], 12) + self.assertEqual(out["pages"], 3) + self.assertEqual(len(out["items"]), 5) + + def test_second_page(self): + out = list_trade_records_page( + self.conn, + "2026-07-01", + "2026-07-31", + tr_ts="COALESCE(closed_at, created_at, opened_at)", + to_effective_fn=_to_effective, + filter_fn=filter_trade_records_excluding_miss, + limit=5, + offset=5, + ) + self.assertEqual(out["page"], 2) + self.assertEqual(len(out["items"]), 5) + + +if __name__ == "__main__": + unittest.main()