diff --git a/crypto_monitor_binance/app.py b/crypto_monitor_binance/app.py index a043fc4..4669a02 100644 --- a/crypto_monitor_binance/app.py +++ b/crypto_monitor_binance/app.py @@ -9617,7 +9617,7 @@ try: _repo_root = Path(__file__).resolve().parent.parent if str(_repo_root) not in sys.path: sys.path.insert(0, str(_repo_root)) - from hub_bridge import install_on_app + from hub_bridge import install_on_app, register_trade_stats_calendar_route install_on_app( app, @@ -9637,6 +9637,13 @@ try: render_main_page_fn=render_main_page, login_required_fn=login_required, ) + register_trade_stats_calendar_route( + app, + login_required_fn=login_required, + load_pnls_fn=_load_completed_trade_pnls, + row_matches_segment_fn=_pnl_row_matches_segment, + reset_hour=TRADING_DAY_RESET_HOUR, + ) except Exception as _hub_err: print(f"[hub_bridge] binance: {_hub_err}") diff --git a/crypto_monitor_gate/app.py b/crypto_monitor_gate/app.py index 4bfe51d..a8de8d4 100644 --- a/crypto_monitor_gate/app.py +++ b/crypto_monitor_gate/app.py @@ -9537,7 +9537,7 @@ try: _repo_root = Path(__file__).resolve().parent.parent if str(_repo_root) not in sys.path: sys.path.insert(0, str(_repo_root)) - from hub_bridge import install_on_app + from hub_bridge import install_on_app, register_trade_stats_calendar_route install_on_app( app, @@ -9558,6 +9558,13 @@ try: render_main_page_fn=render_main_page, login_required_fn=login_required, ) + register_trade_stats_calendar_route( + app, + login_required_fn=login_required, + load_pnls_fn=_load_completed_trade_pnls, + row_matches_segment_fn=_pnl_row_matches_segment, + reset_hour=TRADING_DAY_RESET_HOUR, + ) except Exception as _hub_err: print(f"[hub_bridge] gate: {_hub_err}") diff --git a/crypto_monitor_gate_bot/app.py b/crypto_monitor_gate_bot/app.py index 0a698c0..9ab569a 100644 --- a/crypto_monitor_gate_bot/app.py +++ b/crypto_monitor_gate_bot/app.py @@ -9533,7 +9533,7 @@ try: _repo_root = Path(__file__).resolve().parent.parent if str(_repo_root) not in sys.path: sys.path.insert(0, str(_repo_root)) - from hub_bridge import install_on_app + from hub_bridge import install_on_app, register_trade_stats_calendar_route install_on_app( app, @@ -9554,6 +9554,13 @@ try: render_main_page_fn=render_main_page, login_required_fn=login_required, ) + register_trade_stats_calendar_route( + app, + login_required_fn=login_required, + load_pnls_fn=_load_completed_trade_pnls, + row_matches_segment_fn=_pnl_row_matches_segment, + reset_hour=TRADING_DAY_RESET_HOUR, + ) except Exception as _hub_err: print(f"[hub_bridge] gate_bot: {_hub_err}") diff --git a/crypto_monitor_okx/app.py b/crypto_monitor_okx/app.py index 82e50ff..c68beb5 100644 --- a/crypto_monitor_okx/app.py +++ b/crypto_monitor_okx/app.py @@ -8998,7 +8998,7 @@ try: _repo_root = Path(__file__).resolve().parent.parent if str(_repo_root) not in sys.path: sys.path.insert(0, str(_repo_root)) - from hub_bridge import install_on_app + from hub_bridge import install_on_app, register_trade_stats_calendar_route install_on_app( app, @@ -9018,6 +9018,13 @@ try: render_main_page_fn=render_main_page, login_required_fn=login_required, ) + register_trade_stats_calendar_route( + app, + login_required_fn=login_required, + load_pnls_fn=_load_completed_trade_pnls, + row_matches_segment_fn=_pnl_row_matches_segment, + reset_hour=TRADING_DAY_RESET_HOUR, + ) except Exception as _hub_err: print(f"[hub_bridge] okx: {_hub_err}") diff --git a/embed_templates/embed_boot_scripts.html b/embed_templates/embed_boot_scripts.html index fb51acf..dd86113 100644 --- a/embed_templates/embed_boot_scripts.html +++ b/embed_templates/embed_boot_scripts.html @@ -664,6 +664,36 @@ function switchStatsSegment(){ q.set("stats_segment", key); const qs = q.toString(); history.replaceState(null, "", qs ? (window.location.pathname + "?" + qs) : window.location.pathname); + if(statsCalendarWidget) statsCalendarWidget.load(); +} + +let statsCalendarWidget = null; + +function initStatsCalendarWidget(){ + const grid = document.getElementById("stats-calendar"); + if(!grid || !window.TradeStatsCalendar) return; + statsCalendarWidget = new TradeStatsCalendar({ + gridEl: grid, + titleEl: document.getElementById("stats-cal-title"), + prevBtn: document.getElementById("stats-cal-prev"), + nextBtn: document.getElementById("stats-cal-next"), + apiUrl: "/api/stats/calendar", + showSick: false, + buildQuery: function(year, month){ + const q = new URLSearchParams(); + q.set("year", String(year)); + q.set("month", String(month)); + const sel = document.getElementById("stats-segment-select"); + if(sel) q.set("segment", sel.value || "all"); + return q; + }, + parseResponse: function(data){ + if(data && data.ok === false) return {}; + return (data && data.days) || {}; + } + }); + statsCalendarWidget.ensureMonth(new Date()); + statsCalendarWidget.load(); } function initStatsSegmentFromUrl(){ @@ -700,6 +730,7 @@ attachListWindowToExports(); toggleListWindowCustom(); bindListWindowDateAutoCustom(); initStatsSegmentFromUrl(); +initStatsCalendarWidget(); if(document.getElementById("journal-list")) loadJournals(); if(document.getElementById("review-list")) loadReviews(); const reviewToggle = document.getElementById("review-mode-toggle"); diff --git a/embed_templates/embed_page_fragment.html b/embed_templates/embed_page_fragment.html index 6f030dd..86f33f2 100644 --- a/embed_templates/embed_page_fragment.html +++ b/embed_templates/embed_page_fragment.html @@ -452,6 +452,14 @@ +
+
+ + + +
+
+
{% for seg in stats_bundle.segments %} +
+
+

统计分析

+
+
+
-
-
-

数据总览

+
+
+ + +
-
-
-
- - - -
-
-
-
+
+
K 线图表
@@ -1057,7 +1058,8 @@ - + + diff --git a/static/trade_stats_calendar.css b/static/trade_stats_calendar.css new file mode 100644 index 0000000..ed16542 --- /dev/null +++ b/static/trade_stats_calendar.css @@ -0,0 +1,127 @@ +/* 交易日历:内照明心 + 四所统计分析共用,随 data-theme 浅/深切换 */ +.trade-cal-wrap { + --trade-cal-wrap-bg: var(--inset-surface, rgba(0, 0, 0, 0.22)); + --trade-cal-cell-bg: var(--section-surface, var(--inset-surface, rgba(0, 0, 0, 0.32))); + --trade-cal-cell-hover-bg: color-mix(in srgb, var(--accent, #6366f1) 12%, var(--trade-cal-cell-bg)); + --trade-cal-cell-hover-border: color-mix(in srgb, var(--accent, #6366f1) 45%, transparent); + --trade-cal-selected-border: color-mix(in srgb, var(--accent, #6366f1) 75%, transparent); + --trade-cal-selected-shadow: color-mix(in srgb, var(--accent, #6366f1) 35%, transparent); + --trade-cal-sick-bg: color-mix(in srgb, var(--red, #ef4444) 14%, var(--trade-cal-cell-bg)); + --trade-cal-sick-border: color-mix(in srgb, var(--red, #ef4444) 55%, transparent); + --trade-cal-sick-shadow: color-mix(in srgb, var(--red, #ef4444) 45%, transparent); + --trade-cal-sick-tag-bg: color-mix(in srgb, var(--red, #ef4444) 25%, transparent); + --trade-cal-sick-tag-fg: color-mix(in srgb, var(--red, #ef4444) 70%, #fff); + --trade-cal-pos: var(--green, #22c55e); + --trade-cal-neg: var(--red, #ef4444); + margin-top: 4px; + padding: 10px 12px; + border-radius: 10px; + border: 1px solid var(--border-soft, rgba(120, 140, 200, 0.28)); + background: var(--trade-cal-wrap-bg); +} +.stats-calendar-wrap { + margin-bottom: 14px; +} +.trade-cal-head { + display: flex; + align-items: center; + justify-content: center; + gap: 12px; + margin-bottom: 8px; +} +.trade-cal-title { + font-size: 0.95rem; + font-weight: 600; + min-width: 120px; + text-align: center; + color: var(--text, #e8ecff); +} +.trade-cal-weekdays { + display: grid; + grid-template-columns: repeat(7, 1fr); + gap: 4px; + margin-bottom: 4px; +} +.trade-cal-wd { + text-align: center; + font-size: 0.72rem; + color: var(--muted, #8892b0); +} +.trade-cal-grid { + display: grid; + grid-template-columns: repeat(7, 1fr); + gap: 4px; +} +.trade-cal-cell { + min-height: 62px; + padding: 4px 3px; + border-radius: 8px; + border: 1px solid transparent; + background: var(--trade-cal-cell-bg); + color: inherit; + font: inherit; + cursor: default; + display: flex; + flex-direction: column; + align-items: center; + justify-content: flex-start; + gap: 2px; +} +.trade-cal-cell.has-trade { + cursor: pointer; +} +.trade-cal-cell.has-trade:hover { + border-color: var(--trade-cal-cell-hover-border); + background: var(--trade-cal-cell-hover-bg); +} +.trade-cal-cell.is-selected { + border-color: var(--trade-cal-selected-border); + box-shadow: 0 0 0 1px var(--trade-cal-selected-shadow); +} +.trade-cal-cell.is-sick-day { + border-color: var(--trade-cal-sick-border); + background: var(--trade-cal-sick-bg); +} +.trade-cal-cell.is-sick-day.is-selected { + box-shadow: 0 0 0 2px var(--trade-cal-sick-shadow); +} +.trade-cal-cell.pnl-pos .trade-cal-pnl { + color: var(--trade-cal-pos); +} +.trade-cal-cell.pnl-neg .trade-cal-pnl { + color: var(--trade-cal-neg); +} +.trade-cal-day-num { + font-size: 0.78rem; + font-weight: 600; + color: var(--text, #e8ecff); +} +.trade-cal-pnl { + font-size: 0.72rem; + font-weight: 600; + line-height: 1.1; +} +.trade-cal-cnt { + font-size: 0.65rem; + color: var(--muted, #8892b0); +} +.trade-cal-sick-tag { + font-size: 0.62rem; + padding: 1px 4px; + border-radius: 4px; + background: var(--trade-cal-sick-tag-bg); + color: var(--trade-cal-sick-tag-fg); + font-weight: 600; +} +.trade-cal-pad { + background: transparent; + border: none; + min-height: 0; +} + +html[data-theme="light"] .trade-cal-wrap { + --trade-cal-wrap-bg: var(--inset-surface, #eef3f8); + --trade-cal-cell-bg: var(--section-surface, #f6f9fc); + --trade-cal-cell-hover-bg: color-mix(in srgb, var(--accent, #2563eb) 10%, #f6f9fc); + --trade-cal-sick-tag-fg: #b91c1c; +} diff --git a/static/trade_stats_calendar.js b/static/trade_stats_calendar.js new file mode 100644 index 0000000..866478c --- /dev/null +++ b/static/trade_stats_calendar.js @@ -0,0 +1,203 @@ +/** + * 交易日历组件:内照明心档案 + 四所统计分析共用。 + */ +(function (global) { + "use strict"; + + var WEEKDAYS = ["日", "一", "二", "三", "四", "五", "六"]; + + function esc(s) { + return String(s == null ? "" : s) + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """); + } + + function monthLabel(y, m) { + return y + "年" + m + "月"; + } + + function TradeStatsCalendar(config) { + this.gridEl = config.gridEl; + this.titleEl = config.titleEl; + this.prevBtn = config.prevBtn || null; + this.nextBtn = config.nextBtn || null; + this.apiUrl = config.apiUrl || "/api/stats/calendar"; + this.buildQuery = + config.buildQuery || + function (year, month) { + var q = new URLSearchParams(); + q.set("year", String(year)); + q.set("month", String(month)); + return q; + }; + this.parseResponse = + config.parseResponse || + function (data) { + if (data && data.ok === false) return {}; + return (data && data.days) || {}; + }; + this.fetchFn = config.fetchFn || null; + this.showSick = config.showSick !== false; + this.selectedDay = config.selectedDay || ""; + this.onDayClick = config.onDayClick || null; + this.onMonthChange = config.onMonthChange || null; + this.year = config.year || 0; + this.month = config.month || 0; + this.days = {}; + this._navBound = false; + this._bindNav(); + } + + TradeStatsCalendar.prototype.ensureMonth = function (ref) { + if (this.year > 0 && this.month > 0) return; + var d; + if (ref instanceof Date) d = ref; + else if (typeof ref === "string" && ref.length >= 7) { + var p = ref.slice(0, 10).split("-"); + this.year = parseInt(p[0], 10) || new Date().getFullYear(); + this.month = parseInt(p[1], 10) || new Date().getMonth() + 1; + return; + } else d = new Date(); + this.year = d.getFullYear(); + this.month = d.getMonth() + 1; + }; + + TradeStatsCalendar.prototype.setSelectedDay = function (day) { + this.selectedDay = day || ""; + this.render(); + }; + + TradeStatsCalendar.prototype.render = function () { + if (!this.gridEl || !this.titleEl) return; + this.ensureMonth(new Date()); + this.titleEl.textContent = monthLabel(this.year, this.month); + var first = new Date(this.year, this.month - 1, 1); + var lastDay = new Date(this.year, this.month, 0).getDate(); + var startWd = first.getDay(); + var html = + '
' + + WEEKDAYS.map(function (w) { + return '' + w + ""; + }).join("") + + '
'; + var i; + for (i = 0; i < startWd; i++) { + html += ''; + } + for (var d = 1; d <= lastDay; d++) { + var dayStr = + this.year + + "-" + + String(this.month).padStart(2, "0") + + "-" + + String(d).padStart(2, "0"); + var info = this.days[dayStr]; + var hasTrade = info && info.open_count > 0; + var sick = this.showSick && info && info.has_sick; + var pnl = hasTrade ? Number(info.pnl_total) : null; + var cnt = hasTrade ? info.open_count : 0; + var cls = + "trade-cal-cell" + + (hasTrade ? " has-trade" : "") + + (sick ? " is-sick-day" : "") + + (this.selectedDay === dayStr ? " is-selected" : "") + + (pnl != null && pnl > 0.0001 + ? " pnl-pos" + : pnl != null && pnl < -0.0001 + ? " pnl-neg" + : ""); + var body = '' + d + ""; + if (hasTrade) { + var pnlTxt = (pnl >= 0 ? "+" : "") + pnl.toFixed(1); + body += + '' + + esc(pnlTxt) + + "" + + '' + + cnt + + "笔"; + if (sick) body += '犯病'; + } + html += + '"; + } + html += "
"; + this.gridEl.innerHTML = html; + var self = this; + this.gridEl.querySelectorAll(".trade-cal-cell[data-day]").forEach(function (btn) { + btn.addEventListener("click", function () { + var day = btn.getAttribute("data-day"); + if (!day || !self.onDayClick) return; + self.selectedDay = day; + self.render(); + self.onDayClick(day, btn.getAttribute("data-sick") === "1", self.days[day] || null); + }); + }); + }; + + TradeStatsCalendar.prototype.load = async function () { + this.ensureMonth(new Date()); + var q = this.buildQuery(this.year, this.month); + if (!q.has("year")) q.set("year", String(this.year)); + if (!q.has("month")) q.set("month", String(this.month)); + try { + var data; + if (this.fetchFn) { + data = await this.fetchFn(q); + } else { + var resp = await fetch(this.apiUrl + "?" + q.toString(), { + credentials: "same-origin", + }); + data = await resp.json(); + } + this.days = this.parseResponse(data) || {}; + this.render(); + if (this.onMonthChange) this.onMonthChange(this.year, this.month, this.days); + } catch (e) { + console.warn("[trade calendar]", e); + } + }; + + TradeStatsCalendar.prototype.shiftMonth = function (delta) { + this.ensureMonth(new Date()); + this.month += delta; + if (this.month > 12) { + this.month = 1; + this.year += 1; + } else if (this.month < 1) { + this.month = 12; + this.year -= 1; + } + void this.load(); + }; + + TradeStatsCalendar.prototype._bindNav = function () { + if (this._navBound) return; + var self = this; + if (this.prevBtn) { + this.prevBtn.addEventListener("click", function () { + self.shiftMonth(-1); + }); + } + if (this.nextBtn) { + this.nextBtn.addEventListener("click", function () { + self.shiftMonth(1); + }); + } + this._navBound = true; + }; + + global.TradeStatsCalendar = TradeStatsCalendar; +})(window); diff --git a/tests/test_trade_stats_calendar_lib.py b/tests/test_trade_stats_calendar_lib.py new file mode 100644 index 0000000..5ca608b --- /dev/null +++ b/tests/test_trade_stats_calendar_lib.py @@ -0,0 +1,59 @@ +import unittest +from types import SimpleNamespace + +from trade_stats_calendar_lib import build_trade_stats_calendar + + +def _row(**kwargs): + base = { + "monitor_type": "", + "key_signal_type": "", + "exchange_turnover_usdt": None, + "exchange_commission_usdt": None, + } + base.update(kwargs) + return SimpleNamespace(**base) + + +def _matches_all(row, segment_key): + return segment_key == "all" + + +def _matches_manual(row, segment_key): + if segment_key == "all": + return True + if segment_key == "manual": + return (row.monitor_type or "").strip() == "手动" and not (row.key_signal_type or "").strip() + return False + + +class TradeStatsCalendarLibTests(unittest.TestCase): + def test_groups_by_trading_day_and_segment(self): + pnls = [ + (10.0, None, "2026-06-18", _row(monitor_type="手动")), + (-3.0, None, "2026-06-18", _row(monitor_type="手动")), + (5.0, None, "2026-06-19", _row(monitor_type="自动", key_signal_type="箱体突破")), + ] + payload = build_trade_stats_calendar( + pnls, + 2026, + 6, + "manual", + _matches_manual, + reset_hour=8, + ) + self.assertEqual(payload["month"], 6) + self.assertEqual(payload["month_open_count"], 2) + days = payload["days"] + self.assertIn("2026-06-18", days) + self.assertNotIn("2026-06-19", days) + self.assertEqual(days["2026-06-18"]["open_count"], 2) + self.assertAlmostEqual(days["2026-06-18"]["pnl_total"], 7.0) + + def test_invalid_month_raises(self): + with self.assertRaises(ValueError): + build_trade_stats_calendar([], 2026, 13, "all", _matches_all) + + +if __name__ == "__main__": + unittest.main() diff --git a/trade_stats_calendar_lib.py b/trade_stats_calendar_lib.py new file mode 100644 index 0000000..be9862b --- /dev/null +++ b/trade_stats_calendar_lib.py @@ -0,0 +1,73 @@ +"""按交易日聚合实例 trade_records 盈亏,供统计分析页日历 API 使用。""" +from __future__ import annotations + +from datetime import datetime, timedelta +from typing import Any, Callable + + +def build_trade_stats_calendar( + pnls: list[tuple], + year: int, + month: int, + segment_key: str, + row_matches_fn: Callable[[Any, str], bool], + *, + reset_hour: int = 8, +) -> dict[str, Any]: + """pnls: _load_completed_trade_pnls 返回值 (pnl, close_dt, trading_day, row)。""" + y = int(year) + m = int(month) + if m < 1 or m > 12: + raise ValueError("month 无效") + first = f"{y:04d}-{m:02d}-01" + if m == 12: + next_first = datetime(y + 1, 1, 1) + else: + next_first = datetime(y, m + 1, 1) + last = (next_first - timedelta(days=1)).strftime("%Y-%m-%d") + seg = (segment_key or "all").strip() or "all" + days: dict[str, dict[str, Any]] = {} + for pnl, _close_dt, td, row in pnls: + if not td or td < first or td > last: + continue + if not row_matches_fn(row, seg): + continue + bucket = days.setdefault( + td, + { + "trading_day": td, + "open_count": 0, + "pnl_total": 0.0, + "turnover_total": 0.0, + "commission_total": 0.0, + "has_sick": False, + "sick_count": 0, + }, + ) + bucket["open_count"] += 1 + bucket["pnl_total"] += float(pnl or 0) + try: + bucket["turnover_total"] += float(row["exchange_turnover_usdt"] or 0) + except (TypeError, ValueError, KeyError): + pass + try: + bucket["commission_total"] += float(row["exchange_commission_usdt"] or 0) + except (TypeError, ValueError, KeyError): + pass + for d in days.values(): + d["pnl_total"] = round(float(d["pnl_total"]), 4) + d["turnover_total"] = round(float(d["turnover_total"]), 4) + d["commission_total"] = round(float(d["commission_total"]), 4) + month_pnl = sum(float(d["pnl_total"]) for d in days.values()) + month_count = sum(int(d["open_count"]) for d in days.values()) + return { + "year": y, + "month": m, + "date_from": first, + "date_to": last, + "segment": seg, + "reset_hour": int(reset_hour), + "days": days, + "month_pnl_total": round(month_pnl, 4), + "month_open_count": month_count, + }