diff --git a/hub_symbol_archive_lib.py b/hub_symbol_archive_lib.py index ca0ee46..bf87432 100644 --- a/hub_symbol_archive_lib.py +++ b/hub_symbol_archive_lib.py @@ -6,7 +6,7 @@ import json import os import sqlite3 import time -from datetime import datetime, timezone +from datetime import datetime, timedelta, timezone from pathlib import Path from typing import Any, Callable, Optional from zoneinfo import ZoneInfo @@ -37,6 +37,9 @@ ARCHIVE_MAX_CANDLES: dict[str, int] = { ARCHIVE_SYNC_INTERVAL_SEC = int(os.getenv("HUB_ARCHIVE_SYNC_INTERVAL_SEC", str(4 * 3600))) ARCHIVE_TRADE_DAYS = int(os.getenv("HUB_ARCHIVE_TRADE_DAYS", "365")) ARCHIVE_TRADE_LIMIT = int(os.getenv("HUB_ARCHIVE_TRADE_LIMIT", "2000")) +ARCHIVE_QUOTES_MAX = int(os.getenv("HUB_ARCHIVE_QUOTES_MAX", "100")) +TRADING_DAY_RESET_HOUR = int(os.getenv("TRADING_DAY_RESET_HOUR", "8")) +ARCHIVE_QUOTE_MAX_LEN = 5000 BEHAVIOR_TAGS = frozenset({"", "sick", "emotion"}) @@ -138,6 +141,23 @@ def init_db(db_path: Path | None = None) -> None: ) """ ) + conn.execute( + """ + CREATE TABLE IF NOT EXISTS archive_review_quotes ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + quote_date TEXT NOT NULL UNIQUE, + content TEXT NOT NULL DEFAULT '', + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL + ) + """ + ) + conn.execute( + """ + CREATE INDEX IF NOT EXISTS idx_archive_quotes_date + ON archive_review_quotes (quote_date DESC) + """ + ) finally: conn.close() @@ -1121,3 +1141,236 @@ def sync_exchange_symbol_archives( "appended_bars": appended, "trades": len(trades or []), } + + +def ms_to_trading_day( + ms: int | None, + *, + reset_hour: int = TRADING_DAY_RESET_HOUR, + tz: ZoneInfo = CHART_DISPLAY_TZ, +) -> str | None: + if ms is None: + return None + try: + dt = datetime.fromtimestamp(int(ms) / 1000.0, tz=timezone.utc).astimezone(tz) + except (TypeError, ValueError, OSError): + return None + if dt.hour < reset_hour: + dt = dt - timedelta(days=1) + return dt.strftime("%Y-%m-%d") + + +def today_trading_day(*, reset_hour: int = TRADING_DAY_RESET_HOUR) -> str: + return ms_to_trading_day(_now_ms(), reset_hour=reset_hour) or datetime.now( + CHART_DISPLAY_TZ + ).strftime("%Y-%m-%d") + + +def trading_day_bounds_ms( + trading_day: str, + *, + reset_hour: int = TRADING_DAY_RESET_HOUR, + tz: ZoneInfo = CHART_DISPLAY_TZ, +) -> tuple[int, int]: + day = datetime.strptime((trading_day or "").strip()[:10], "%Y-%m-%d") + start = day.replace(hour=reset_hour, minute=0, second=0, microsecond=0, tzinfo=tz) + end = start + timedelta(days=1) + return int(start.timestamp() * 1000), int(end.timestamp() * 1000) + + +def list_review_quotes(*, db_path: Path | None = None) -> list[dict[str, Any]]: + init_db(db_path) + conn = _connect(db_path) + try: + rows = conn.execute( + """ + SELECT id, quote_date, content, created_at, updated_at + FROM archive_review_quotes + ORDER BY quote_date DESC + LIMIT ? + """, + (ARCHIVE_QUOTES_MAX,), + ).fetchall() + return [dict(r) for r in rows] + finally: + conn.close() + + +def create_review_quote( + quote_date: str, + content: str, + *, + db_path: Path | None = None, +) -> dict[str, Any]: + init_db(db_path) + qd = (quote_date or "").strip()[:10] + if not qd: + raise ValueError("缺少 quote_date") + text = (content or "").strip() + if not text: + raise ValueError("语录内容不能为空") + if len(text) > ARCHIVE_QUOTE_MAX_LEN: + raise ValueError(f"语录最长 {ARCHIVE_QUOTE_MAX_LEN} 字") + conn = _connect(db_path) + try: + cnt = conn.execute("SELECT COUNT(*) AS c FROM archive_review_quotes").fetchone() + if int(cnt["c"] or 0) >= ARCHIVE_QUOTES_MAX: + raise ValueError(f"复盘语录最多保存 {ARCHIVE_QUOTES_MAX} 条") + now = _now_ms() + try: + cur = conn.execute( + """ + INSERT INTO archive_review_quotes (quote_date, content, created_at, updated_at) + VALUES (?,?,?,?) + """, + (qd, text, now, now), + ) + except sqlite3.IntegrityError as e: + raise ValueError("该日期已有语录,请展开编辑") from e + rid = int(cur.lastrowid) + row = conn.execute( + "SELECT id, quote_date, content, created_at, updated_at FROM archive_review_quotes WHERE id=?", + (rid,), + ).fetchone() + return dict(row) + finally: + conn.close() + + +def update_review_quote( + quote_id: int, + *, + quote_date: str | None = None, + content: str | None = None, + db_path: Path | None = None, +) -> dict[str, Any] | None: + init_db(db_path) + conn = _connect(db_path) + try: + row = conn.execute( + "SELECT id, quote_date, content FROM archive_review_quotes WHERE id=?", + (int(quote_id),), + ).fetchone() + if not row: + return None + qd = (quote_date or row["quote_date"] or "").strip()[:10] + text = (content if content is not None else row["content"] or "").strip() + if not qd or not text: + raise ValueError("日期与内容均不能为空") + if len(text) > ARCHIVE_QUOTE_MAX_LEN: + raise ValueError(f"语录最长 {ARCHIVE_QUOTE_MAX_LEN} 字") + now = _now_ms() + conn.execute( + """ + UPDATE archive_review_quotes + SET quote_date=?, content=?, updated_at=? + WHERE id=? + """, + (qd, text, now, int(quote_id)), + ) + out = conn.execute( + "SELECT id, quote_date, content, created_at, updated_at FROM archive_review_quotes WHERE id=?", + (int(quote_id),), + ).fetchone() + return dict(out) if out else None + finally: + conn.close() + + +def delete_review_quote(quote_id: int, *, db_path: Path | None = None) -> bool: + init_db(db_path) + conn = _connect(db_path) + try: + cur = conn.execute( + "DELETE FROM archive_review_quotes WHERE id=?", + (int(quote_id),), + ) + return int(cur.rowcount or 0) > 0 + finally: + conn.close() + + +def list_daily_trades( + trading_day: str = "", + *, + exchange_key: str = "", + filter_profit: bool = False, + filter_loss: bool = False, + filter_sick: bool = False, + search: str = "", + db_path: Path | None = None, +) -> dict[str, Any]: + """按交易日列出开仓记录(默认当日),含各所开仓统计。""" + init_db(db_path) + td = (trading_day or "").strip()[:10] or today_trading_day() + start_ms, end_ms = trading_day_bounds_ms(td) + ex_filter = (exchange_key or "").strip().lower() + conn = _connect(db_path) + try: + params: list[Any] = [start_ms, end_ms] + where = "opened_at_ms >= ? AND opened_at_ms < ?" + if ex_filter: + where += " AND exchange_key=?" + params.append(ex_filter) + stat_rows = conn.execute( + f""" + SELECT exchange_key, COUNT(*) AS open_count + FROM archive_trade_cache + WHERE {where} + GROUP BY exchange_key + """, + params, + ).fetchall() + rows = conn.execute( + f""" + SELECT * FROM archive_trade_cache + WHERE {where} + ORDER BY opened_at_ms DESC, trade_id DESC + """, + params, + ).fetchall() + overlays_by_ex: dict[str, dict[int, dict]] = {} + trades: list[dict[str, Any]] = [] + q = (search or "").strip().lower() + for r in rows: + ex_k = r["exchange_key"] + if ex_k not in overlays_by_ex: + overlays_by_ex[ex_k] = load_overlays(ex_k, db_path=db_path) + td_row = _trade_row_to_dict(r, overlays_by_ex[ex_k].get(int(r["trade_id"]))) + pnl = float(td_row.get("pnl_amount") or 0) + tag = td_row.get("behavior_tag") or "" + if filter_profit and pnl <= 0.0001: + continue + if filter_loss and pnl >= -0.0001: + continue + if filter_sick and tag != "sick": + continue + if q: + blob = " ".join( + str(td_row.get(k) or "") + for k in ( + "symbol", + "exchange_key", + "direction", + "result", + "note", + "monitor_type", + "entry_reason", + ) + ).lower() + if q not in blob: + continue + trades.append(td_row) + by_exchange = { + str(sr["exchange_key"]): int(sr["open_count"] or 0) for sr in stat_rows + } + return { + "trading_day": td, + "trades": trades, + "stats": { + "open_count": sum(by_exchange.values()), + "by_exchange": by_exchange, + }, + } + finally: + conn.close() diff --git a/manual_trading_hub/hub.py b/manual_trading_hub/hub.py index 6a21cc1..6d3c7a1 100644 --- a/manual_trading_hub/hub.py +++ b/manual_trading_hub/hub.py @@ -39,18 +39,25 @@ from hub_volume_rank_lib import ( ) from hub_symbol_archive_lib import ( ARCHIVE_DEFAULT_TIMEFRAME, + ARCHIVE_QUOTES_MAX, ARCHIVE_SEED_LOOKBACK_DAYS, ARCHIVE_SYNC_INTERVAL_SEC, ARCHIVE_TIMEFRAMES, ARCHIVE_TRADE_DAYS, ARCHIVE_TRADE_LIMIT, ARCHIVE_VISIBLE_BARS_DEFAULT, + create_review_quote, + delete_review_quote, init_db as init_archive_db, + list_daily_trades, + list_review_quotes, list_symbol_rows, load_symbol_trades, parse_wall_clock_ms, resolve_archive_chart, sync_exchange_symbol_archives, + today_trading_day, + update_review_quote, upsert_trade_overlay, ) from env_load import load_hub_dotenv @@ -2048,6 +2055,73 @@ def api_archive_list( return {"ok": True, "rows": rows, "count": len(rows)} +@app.get("/api/archive/daily-trades") +def api_archive_daily_trades( + trading_day: str = "", + exchange_key: str = "", + filter_profit: str = "", + filter_loss: str = "", + filter_sick: str = "", + search: str = "", +): + init_archive_db() + payload = list_daily_trades( + trading_day=trading_day or today_trading_day(), + exchange_key=exchange_key, + filter_profit=(filter_profit or "").lower() in ("1", "true", "yes", "on"), + filter_loss=(filter_loss or "").lower() in ("1", "true", "yes", "on"), + filter_sick=(filter_sick or "").lower() in ("1", "true", "yes", "on"), + search=search, + ) + return {"ok": True, **payload} + + +@app.get("/api/archive/quotes") +def api_archive_quotes(): + init_archive_db() + rows = list_review_quotes() + return {"ok": True, "quotes": rows, "count": len(rows), "max": ARCHIVE_QUOTES_MAX} + + +class ArchiveQuoteBody(BaseModel): + quote_date: str = "" + content: str = "" + + +@app.post("/api/archive/quotes") +def api_archive_quote_create(body: ArchiveQuoteBody = Body(...)): + init_archive_db() + try: + row = create_review_quote(body.quote_date, body.content) + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) from e + return {"ok": True, "quote": row} + + +@app.patch("/api/archive/quotes/{quote_id}") +def api_archive_quote_update(quote_id: int, body: ArchiveQuoteBody = Body(...)): + init_archive_db() + try: + row = update_review_quote( + int(quote_id), + quote_date=body.quote_date or None, + content=body.content if body.content is not None else None, + ) + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) from e + if not row: + raise HTTPException(status_code=404, detail="语录不存在") + return {"ok": True, "quote": row} + + +@app.delete("/api/archive/quotes/{quote_id}") +def api_archive_quote_delete(quote_id: int): + init_archive_db() + if not delete_review_quote(int(quote_id)): + raise HTTPException(status_code=404, detail="语录不存在") + return {"ok": True, "id": int(quote_id)} + + @app.get("/api/archive/detail") def api_archive_detail(exchange_key: str = "", symbol: str = ""): ex_k = (exchange_key or "").strip().lower() diff --git a/manual_trading_hub/static/app.css b/manual_trading_hub/static/app.css index d1ab671..df8ae3b 100644 --- a/manual_trading_hub/static/app.css +++ b/manual_trading_hub/static/app.css @@ -5396,11 +5396,19 @@ body.funds-fullscreen-open { } } -/* —— 币种档案 —— */ +/* —— 内照明心 —— */ .archive-toolbar { flex-wrap: wrap; gap: 10px 14px; - margin-bottom: 12px; + margin-bottom: 10px; +} +.archive-search-field input { + min-width: 160px; +} +#archive-btn-chart-toggle.is-active { + color: var(--accent); + border-color: var(--accent); + background: var(--accent-dim); } .archive-field { display: inline-flex; @@ -5421,79 +5429,166 @@ body.funds-fullscreen-open { } .archive-layout { display: grid; - grid-template-columns: minmax(220px, 280px) minmax(0, 1fr); + grid-template-columns: minmax(240px, 300px) minmax(0, 1fr); gap: 14px; min-height: 520px; + align-items: start; } -.archive-list-panel { +.archive-quotes-panel, +.archive-main-panel { background: var(--panel); border: 1px solid var(--border-soft); border-radius: var(--radius); - overflow: auto; - max-height: calc(100vh - 200px); + min-width: 0; } -.archive-list { - display: flex; - flex-direction: column; -} -.archive-row { - display: grid; - grid-template-columns: 1fr auto; - grid-template-rows: auto auto; - gap: 2px 8px; - width: 100%; - text-align: left; - padding: 10px 12px; - border: none; - border-bottom: 1px solid var(--border-soft); - background: transparent; - color: var(--text); - cursor: pointer; - font-family: var(--font); -} -.archive-row:hover, -.archive-row.is-active { - background: var(--inset-surface); -} -.archive-row-sym { - font-weight: 600; - font-size: 0.95rem; -} -.archive-row-ex { - font-size: 0.75rem; - color: var(--muted); - text-transform: uppercase; -} -.archive-row-stat { - grid-column: 1 / -1; - font-size: 0.8rem; - color: var(--muted); -} -.archive-row-meta { - font-size: 0.72rem; - color: var(--accent); - align-self: start; -} -.archive-detail-panel { +.archive-quotes-panel { display: flex; flex-direction: column; gap: 10px; - min-width: 0; + padding: 12px; + max-height: calc(100vh - 180px); + overflow: hidden; } -.archive-detail-head { +.archive-panel-head { display: flex; - flex-wrap: wrap; - align-items: baseline; - gap: 8px 16px; + align-items: center; + justify-content: space-between; + gap: 8px; } -.archive-detail-head h2 { +.archive-panel-head h2 { margin: 0; - font-size: 1.1rem; + font-size: 0.95rem; } -.archive-detail-stats { - font-size: 0.82rem; +.archive-panel-meta { + font-size: 0.72rem; color: var(--muted); } +.archive-quote-form { + display: flex; + flex-direction: column; + gap: 8px; +} +.archive-quote-form input[type="date"], +.archive-quote-form textarea { + width: 100%; + padding: 8px 10px; + border-radius: 8px; + border: 1px solid var(--border-soft); + background: var(--inset-surface); + color: var(--text); + font-family: var(--font); + font-size: 0.82rem; + resize: vertical; +} +.archive-quotes-list { + flex: 1 1 auto; + min-height: 0; + overflow: auto; + display: flex; + flex-direction: column; + gap: 8px; +} +.archive-quote-card { + border: 1px solid var(--border-soft); + border-radius: 8px; + background: var(--inset-surface); + overflow: hidden; +} +.archive-quote-summary { + display: grid; + grid-template-columns: auto 1fr; + gap: 8px; + align-items: center; + padding: 8px 10px; + cursor: pointer; + list-style: none; +} +.archive-quote-summary::-webkit-details-marker { + display: none; +} +.archive-quote-date { + font-weight: 600; + font-size: 0.78rem; + color: var(--accent); + white-space: nowrap; +} +.archive-quote-preview { + font-size: 0.74rem; + color: var(--muted); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.archive-quote-body { + padding: 0 10px 10px; + display: flex; + flex-direction: column; + gap: 8px; +} +.archive-quote-edit { + width: 100%; + min-height: 72px; + padding: 8px 10px; + border-radius: 8px; + border: 1px solid var(--border-soft); + background: var(--panel); + color: var(--text); + font-family: var(--font); + font-size: 0.8rem; + resize: vertical; +} +.archive-quote-actions { + display: flex; + gap: 8px; +} +.archive-main-panel { + display: flex; + flex-direction: column; + gap: 10px; + padding: 12px; + min-height: 520px; +} +.archive-stats-bar { + padding: 10px 12px; + border-radius: 8px; + border: 1px solid var(--border-soft); + background: var(--inset-surface); + font-size: 0.82rem; + color: var(--text); + line-height: 1.45; +} +.archive-acc-section { + border: 1px solid var(--border-soft); + border-radius: var(--radius); + background: var(--inset-surface); + overflow: hidden; +} +.archive-acc-summary { + padding: 10px 12px; + font-weight: 600; + font-size: 0.86rem; + cursor: pointer; + list-style: none; + display: flex; + align-items: center; + gap: 8px; +} +.archive-acc-summary::-webkit-details-marker { + display: none; +} +.archive-acc-sub { + font-weight: 400; + font-size: 0.76rem; + color: var(--muted); +} +.archive-chart-section > :not(summary) { + padding: 0 10px 10px; +} +.archive-trades-section > .archive-trades { + border: none; + border-radius: 0; + max-height: min(56vh, 560px); +} .archive-chart-toolbar { flex-wrap: wrap; } @@ -5553,7 +5648,7 @@ body.funds-fullscreen-open { } .archive-trades { overflow: auto; - max-height: 280px; + max-height: min(56vh, 560px); border: 1px solid var(--border-soft); border-radius: var(--radius); background: var(--panel); @@ -5601,6 +5696,27 @@ body.funds-fullscreen-open { .archive-trade-row.is-active { background: var(--inset-surface); } +.archive-trade-row.archive-trade-sick { + background: rgba(239, 68, 68, 0.12); +} +.archive-trade-row.archive-trade-sick.is-active { + background: rgba(239, 68, 68, 0.18); +} +.archive-trade-row.archive-trade-sick td { + border-bottom-color: rgba(239, 68, 68, 0.22); +} +.archive-actions-cell { + white-space: nowrap; +} +.archive-actions-cell .archive-chart-btn, +.archive-actions-cell .archive-del-btn { + margin-right: 6px; +} +.archive-chart-btn { + padding: 3px 8px; + font-size: 0.72rem; + border-radius: 6px; +} .archive-trades-table td.pos { color: #22c55e; } @@ -5639,8 +5755,8 @@ body.funds-fullscreen-open { .archive-layout { grid-template-columns: 1fr; } - .archive-list-panel { - max-height: 240px; + .archive-quotes-panel { + max-height: 280px; } } diff --git a/manual_trading_hub/static/archive.js b/manual_trading_hub/static/archive.js index a4e9100..0b0ca16 100644 --- a/manual_trading_hub/static/archive.js +++ b/manual_trading_hub/static/archive.js @@ -1,5 +1,5 @@ /** - * 中控币种档案:列表筛选、交易时间线、永久 K 线(lightweight-charts)。 + * 内照明心:复盘语录 + 当日交易记录 + 按需 K 线。 */ (function () { const page = document.getElementById("page-archive"); @@ -9,14 +9,20 @@ const elFilterProfit = document.getElementById("archive-filter-profit"); const elFilterLoss = document.getElementById("archive-filter-loss"); const elFilterSick = document.getElementById("archive-filter-sick"); - const elFilterEmotion = document.getElementById("archive-filter-emotion"); + const elTradingDay = document.getElementById("archive-trading-day"); + const elSearch = document.getElementById("archive-search"); + const elBtnChartToggle = document.getElementById("archive-btn-chart-toggle"); const elBtnRefresh = document.getElementById("archive-btn-refresh"); const elBtnSync = document.getElementById("archive-btn-sync"); const elStatus = document.getElementById("archive-status"); - const elList = document.getElementById("archive-list"); - const elDetailPanel = document.getElementById("archive-detail-panel"); - const elDetailTitle = document.getElementById("archive-detail-title"); - const elDetailStats = document.getElementById("archive-detail-stats"); + const elStats = document.getElementById("archive-stats"); + const elQuotesList = document.getElementById("archive-quotes-list"); + const elQuotesCount = document.getElementById("archive-quotes-count"); + const elQuoteForm = document.getElementById("archive-quote-form"); + const elQuoteDate = document.getElementById("archive-quote-date"); + const elQuoteContent = document.getElementById("archive-quote-content"); + const elChartSection = document.getElementById("archive-chart-section"); + const elChartTitle = document.getElementById("archive-chart-title"); const elTfTabs = document.getElementById("archive-tf-tabs"); const elViewMode = document.getElementById("archive-view-mode"); const elJumpAt = document.getElementById("archive-jump-at"); @@ -36,7 +42,10 @@ const CHART_TZ_OFFSET_SEC = 8 * 60 * 60; let meta = null; - let listRows = []; + let quotes = []; + let dailyTrades = []; + let dailyStats = { open_count: 0, by_exchange: {} }; + let tradingDay = ""; let selected = null; let trades = []; let selectedTradeId = null; @@ -47,6 +56,15 @@ let inited = false; let markAuto = true; let lastCandles = []; + let searchTimer = null; + + function esc(s) { + return String(s == null ? "" : s) + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """); + } function loadMarkAutoPref() { try { @@ -89,8 +107,7 @@ function fmtPnl(v) { const n = Number(v); if (!Number.isFinite(n)) return "—"; - const s = (n >= 0 ? "+" : "") + n.toFixed(2); - return s; + return (n >= 0 ? "+" : "") + n.toFixed(2); } function pad2(n) { @@ -174,14 +191,10 @@ const raw = String( tr.entry_type || tr.entry_reason || tr.reviewed_entry_reason || "" ).trim(); - if (raw) { - return ENTRY_TYPE_LABELS[raw] || raw; - } + if (raw) return ENTRY_TYPE_LABELS[raw] || raw; const mt = String(tr.monitor_type || "").trim(); - if (mt && mt !== "下单监控") { - return ENTRY_TYPE_LABELS[mt] || mt; - } - return "—"; + if (mt && mt !== "下单监控") return ENTRY_TYPE_LABELS[mt] || mt; + return mt || "—"; } function reviewMark(tr) { @@ -198,6 +211,36 @@ if (elStatus) elStatus.textContent = text || ""; } + function exchangeLabel(exKey) { + const key = String(exKey || "").toLowerCase(); + const hit = (meta && meta.exchanges || []).find(function (ex) { + return String(ex.key || "").toLowerCase() === key; + }); + return hit ? hit.name || hit.key : exKey || "—"; + } + + function isChartOpen() { + return !!(elChartSection && elChartSection.open); + } + + function setChartOpen(on) { + if (!elChartSection) return; + elChartSection.open = !!on; + if (elBtnChartToggle) { + elBtnChartToggle.classList.toggle("is-active", !!on); + } + if (!on) destroyChart(); + } + + function updateChartTitle() { + if (!elChartTitle) return; + if (!selected) { + elChartTitle.textContent = "—"; + return; + } + elChartTitle.textContent = selected.symbol + " · " + exchangeLabel(selected.exchange_key); + } + async function apiFetch(url, opts) { const r = await fetch(url, opts); if (r.status === 401) { @@ -207,14 +250,15 @@ return r; } - function queryListParams() { + function queryDailyParams() { const q = new URLSearchParams(); + if (elTradingDay && elTradingDay.value) q.set("trading_day", elTradingDay.value); const ex = (elExchange && elExchange.value) || ""; if (ex) q.set("exchange_key", ex); if (elFilterProfit && elFilterProfit.checked) q.set("filter_profit", "1"); if (elFilterLoss && elFilterLoss.checked) q.set("filter_loss", "1"); if (elFilterSick && elFilterSick.checked) q.set("filter_sick", "1"); - if (elFilterEmotion && elFilterEmotion.checked) q.set("filter_emotion", "1"); + if (elSearch && elSearch.value.trim()) q.set("search", elSearch.value.trim()); return q.toString(); } @@ -231,52 +275,147 @@ if (cur) elExchange.value = cur; } - function renderList() { - if (!elList) return; - if (!listRows.length) { - elList.innerHTML = '
暂无档案数据。点击「同步交易与 K 线」从四所拉取。
'; + function renderStats() { + if (!elStats) return; + const st = dailyStats || { open_count: 0, by_exchange: {} }; + const parts = ["今日开仓 " + (st.open_count || 0) + " 次"]; + const byEx = st.by_exchange || {}; + Object.keys(byEx) + .sort() + .forEach(function (ex) { + parts.push(exchangeLabel(ex) + " " + byEx[ex]); + }); + elStats.textContent = parts.join(" · "); + } + + function quotePreview(text) { + const s = String(text || "").replace(/\s+/g, " ").trim(); + if (!s) return "(空)"; + return s.length > 36 ? s.slice(0, 36) + "…" : s; + } + + function renderQuotes() { + if (!elQuotesList) return; + if (elQuotesCount) { + elQuotesCount.textContent = quotes.length ? quotes.length + " 条" : ""; + } + if (!quotes.length) { + elQuotesList.innerHTML = '暂无复盘语录,可在上方添加。
'; return; } - elList.innerHTML = listRows - .map(function (row) { - const active = - selected && - selected.exchange_key === row.exchange_key && - selected.symbol === row.symbol - ? " is-active" - : ""; - const seed = row.seed_complete ? "已建档" : "待种子"; + elQuotesList.innerHTML = quotes + .map(function (q) { return ( - '" + "" + + '该币种暂无已平仓记录。
'; + if (!dailyTrades.length) { + elTrades.innerHTML = + '该日暂无交易记录。可调整日期或点击「同步」拉取数据。
'; return; } elTrades.innerHTML = '| 开仓类型 | 开仓时间 | 平仓时间 | 持仓时长 | " + + "交易所 | 开仓类型 | 开仓时间 | 平仓时间 | 持仓时长 | " + "方向 | 结果 | 盈亏 | 标签 | 备注 | 操作 | " + "
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| " + - (rev ? '' + rev + "" : "") + - fmtEntryType(t) + + " | " + + esc(exchangeLabel(exKey)) + " | " + - '" + + " | " + (rev ? '' + rev + "" : "") + + esc(fmtEntryType(t)) + + " | " + + '' + fmtDt(t.opened_at) + " | " + - '" + - (rev ? '' + rev + "" : "") + + ' | ' + fmtDt(t.closed_at) + " | " + - '" + - (rev ? '' + rev + "" : "") + + ' | ' + fmtHoldMinutes(t) + " | " + "" + - (t.direction || "—") + + esc(t.direction || "—") + " | " + "" + - (t.result || "—") + + esc(t.result || "—") + " | " + '" + ' | " + ' | ' + - ' | ' + - " |