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 ( - '" + "" + + '
' + + '" + + '
' + + '' + + '' + + "
" ); }) .join(""); - elList.querySelectorAll(".archive-row").forEach(function (btn) { + + elQuotesList.querySelectorAll(".archive-quote-save").forEach(function (btn) { btn.addEventListener("click", function () { - openDetail(btn.getAttribute("data-ex"), btn.getAttribute("data-sym")); + const id = btn.getAttribute("data-id"); + const card = btn.closest(".archive-quote-card"); + const ta = card && card.querySelector(".archive-quote-edit"); + const dateEl = card && card.querySelector(".archive-quote-date"); + if (!id || !ta) return; + void saveQuote(id, dateEl ? dateEl.textContent : "", ta.value, card); }); }); + elQuotesList.querySelectorAll(".archive-quote-del").forEach(function (btn) { + btn.addEventListener("click", function () { + void deleteQuote(btn.getAttribute("data-id")); + }); + }); + } + + async function loadQuotes() { + const r = await apiFetch("/api/archive/quotes"); + const j = await r.json(); + quotes = j.quotes || []; + renderQuotes(); + } + + async function addQuote(ev) { + if (ev) ev.preventDefault(); + const date = elQuoteDate && elQuoteDate.value; + const content = elQuoteContent && elQuoteContent.value.trim(); + if (!date || !content) return; + const r = await apiFetch("/api/archive/quotes", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ quote_date: date, content: content }), + }); + const j = await r.json(); + if (!r.ok) { + setStatus(j.detail || "添加失败"); + return; + } + if (elQuoteContent) elQuoteContent.value = ""; + await loadQuotes(); + setStatus("语录已添加"); + } + + async function saveQuote(id, quoteDate, content, cardEl) { + let card = cardEl; + if (!card && elQuotesList) { + const ta = elQuotesList.querySelector('.archive-quote-edit[data-id="' + id + '"]'); + card = ta ? ta.closest(".archive-quote-card") : null; + } + const date = + quoteDate || + (card && + card.querySelector(".archive-quote-date") && + card.querySelector(".archive-quote-date").textContent) || + ""; + const r = await apiFetch("/api/archive/quotes/" + id, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ quote_date: date.trim(), content: content }), + }); + const j = await r.json(); + if (!r.ok) { + setStatus(j.detail || "保存失败"); + return; + } + if (card) card.open = false; + await loadQuotes(); + setStatus("语录已保存"); + } + + async function deleteQuote(id) { + if (!id || !window.confirm("确定删除这条复盘语录?")) return; + const r = await apiFetch("/api/archive/quotes/" + id, { method: "DELETE" }); + if (!r.ok) { + const j = await r.json().catch(function () { + return {}; + }); + setStatus(j.detail || "删除失败"); + return; + } + await loadQuotes(); + setStatus("语录已删除"); } function pickAnchorTrade() { @@ -326,9 +465,7 @@ function anchorMsForTrade(tr) { if (!tr) return null; const mode = (elViewMode && elViewMode.value) || "hold"; - if (mode === "entry") { - return tradeOpenMs(tr); - } + if (mode === "entry") return tradeOpenMs(tr); return tradeCloseMs(tr) || tradeOpenMs(tr); } @@ -359,12 +496,8 @@ function isLongDirection(dir) { const d = String(dir || "").trim().toLowerCase(); - if (d === "short" || d === "空" || d === "sell" || d === "做空" || d === "shorts") { - return false; - } - if (d === "long" || d === "多" || d === "buy" || d === "做多" || d === "longs") { - return true; - } + if (d === "short" || d === "空" || d === "sell" || d === "做空" || d === "shorts") return false; + if (d === "long" || d === "多" || d === "buy" || d === "做多" || d === "longs") return true; return true; } @@ -384,9 +517,7 @@ const openColor = openArrowColor(long, highlight); let closeColor = highlight ? "#fbbf24" : "#f59e0b"; const pnl = Number(tr.pnl_amount); - if (!highlight && Number.isFinite(pnl) && pnl < -0.0001) { - closeColor = "#a855f7"; - } + if (!highlight && Number.isFinite(pnl) && pnl < -0.0001) closeColor = "#a855f7"; const markers = []; if (openMs) { markers.push({ @@ -438,7 +569,6 @@ candleSeries.setMarkers(buildChartMarkers(lastCandles, timeframe)); } - /** 初始只聚焦持仓段;完整历史已加载,可向左拖动/滚轮缩小查看建仓前全局。 */ function focusInitialTradeView(candles, tr, tf) { if (!chart || !candles.length || !tr) return; const mode = (elViewMode && elViewMode.value) || "hold"; @@ -472,9 +602,7 @@ fromIdx = Math.max(0, openIdx - 10); toIdx = Math.min(candles.length - 1, closeIdx + 14); } - if (toIdx <= fromIdx) { - toIdx = Math.min(candles.length - 1, fromIdx + 80); - } + if (toIdx <= fromIdx) toIdx = Math.min(candles.length - 1, fromIdx + 80); chart.timeScale().setVisibleLogicalRange({ from: fromIdx, to: toIdx + 4 }); } @@ -533,9 +661,7 @@ priceFormat: { type: "volume" }, priceScaleId: "", }); - volumeSeries.priceScale().applyOptions({ - scaleMargins: { top: 0.82, bottom: 0 }, - }); + volumeSeries.priceScale().applyOptions({ scaleMargins: { top: 0.82, bottom: 0 } }); new ResizeObserver(function () { if (chart && elChartHost) { chart.applyOptions({ width: elChartHost.clientWidth, height: elChartHost.clientHeight }); @@ -544,11 +670,21 @@ chart.applyOptions({ width: elChartHost.clientWidth, height: elChartHost.clientHeight }); } + async function loadSymbolTradesForChart(exKey, sym) { + const r = await apiFetch( + "/api/archive/detail?exchange_key=" + + encodeURIComponent(exKey) + + "&symbol=" + + encodeURIComponent(sym) + ); + const j = await r.json(); + trades = j.trades || []; + } + async function loadChart() { - if (!selected) return; + if (!selected || !isChartOpen()) return; const tr = pickAnchorTrade(); - const anchor = anchorMsForTrade(tr); - const jump = (elJumpAt && elJumpAt.value || "").trim(); + const jump = (elJumpAt && elJumpAt.value) || ""; let openMs = null; let closeMs = null; if (markAuto && trades.length) { @@ -571,7 +707,8 @@ params.set("closed_ms", String(closeMs)); } else { params.set("bars", "200"); - if (jump) params.set("at", jump); + const anchor = anchorMsForTrade(tr); + if (jump.trim()) params.set("at", jump.trim()); else if (anchor) params.set("anchor_ms", String(anchor)); } setStatus("加载 K 线…"); @@ -604,66 +741,72 @@ } else if (candles.length > 10) { chart.timeScale().setVisibleLogicalRange({ from: candles.length - 120, to: candles.length + 5 }); } - const markHint = markAuto && trades.length > 1 ? " · 自动标注 " + trades.length + " 笔" : tr ? " · 已标注开/平" : ""; - const histHint = - openMs && closeMs - ? " · 建档30天历史 · 可拖动/滚轮缩放查看建仓前走势" - : ""; - setStatus("K 线 " + candles.length + " 根 · " + timeframe + markHint + histHint); + updateChartTitle(); + setStatus("K 线 " + candles.length + " 根 · " + timeframe); + } + + async function openTradeChart(tr) { + if (!tr) return; + const exKey = tr.exchange_key; + const sym = tr.symbol; + if (!exKey || !sym) return; + selected = { exchange_key: exKey, symbol: sym }; + selectedTradeId = String(tr.trade_id || tr.id); + setChartOpen(true); + await loadSymbolTradesForChart(exKey, sym); + await loadChart(); } function renderTrades() { if (!elTrades) return; - if (!trades.length) { - elTrades.innerHTML = '

该币种暂无已平仓记录。

'; + if (!dailyTrades.length) { + elTrades.innerHTML = + '

该日暂无交易记录。可调整日期或点击「同步」拉取数据。

'; return; } elTrades.innerHTML = '' + - "" + + "" + "" + "" + - trades + dailyTrades .map(function (t) { const tid = t.trade_id || t.id; - const active = String(tid) === String(selectedTradeId) ? " is-active" : ""; + const exKey = t.exchange_key || ""; const tag = t.behavior_tag || ""; + const sick = tag === "sick"; + const active = String(tid) === String(selectedTradeId) ? " is-active" : ""; const rev = reviewMark(t); return ( '' + - '" + - (rev ? '' + rev + "" : "") + - fmtEntryType(t) + + "" + - '" + + '" + - '" + - '" + "" + "" + '" + '' + - '' + - "" + '">图表' + + '' + + "" ); }) .join("") + @@ -700,48 +851,49 @@ elTrades.querySelectorAll(".archive-del-btn").forEach(function (btn) { btn.addEventListener("click", function (ev) { ev.stopPropagation(); - void deleteTrade(btn.getAttribute("data-id")); + const row = btn.closest(".archive-trade-row"); + void deleteTrade(btn.getAttribute("data-id"), row && row.getAttribute("data-ex")); }); }); - elTrades.querySelectorAll(".archive-trade-row").forEach(function (row) { - row.addEventListener("click", function (ev) { - if ( - ev.target.closest("select") || - ev.target.closest("input") || - ev.target.closest(".archive-del-btn") - ) { - return; - } - selectedTradeId = row.getAttribute("data-id"); - renderTrades(); - applyChartMarkers(); - const trSel = pickAnchorTrade(); - if (trSel && tradeOpenMs(trSel) && tradeCloseMs(trSel)) { - focusInitialTradeView(lastCandles, trSel, timeframe); + elTrades.querySelectorAll(".archive-chart-btn").forEach(function (btn) { + btn.addEventListener("click", function (ev) { + ev.stopPropagation(); + const row = btn.closest(".archive-trade-row"); + const tid = btn.getAttribute("data-id"); + const tr = dailyTrades.find(function (t) { + return String(t.trade_id || t.id) === String(tid); + }); + if (tr) void openTradeChart(tr); + else if (row) { + selectedTradeId = tid; + renderTrades(); } }); }); elTrades.querySelectorAll(".archive-tag-select").forEach(function (sel) { sel.addEventListener("change", function () { - saveOverlay(sel.getAttribute("data-id"), sel.value, null); + saveOverlay(sel.getAttribute("data-id"), sel.getAttribute("data-ex"), sel.value, null); }); }); elTrades.querySelectorAll(".archive-note-input").forEach(function (inp) { inp.addEventListener("change", function () { const row = inp.closest(".archive-trade-row"); const tagSel = row && row.querySelector(".archive-tag-select"); - saveOverlay(inp.getAttribute("data-id"), tagSel ? tagSel.value : "", inp.value); + saveOverlay( + inp.getAttribute("data-id"), + inp.getAttribute("data-ex"), + tagSel ? tagSel.value : "", + inp.value + ); }); }); } - async function deleteTrade(tradeId) { - if (!selected || tradeId == null) return; - if (!window.confirm("从币种档案移除该笔交易?(不影响交易所实例里的复盘记录)")) return; - const r = await apiFetch( - "/api/archive/trade/" + selected.exchange_key + "/" + tradeId, - { method: "DELETE" } - ); + async function deleteTrade(tradeId, exchangeKey) { + const exKey = exchangeKey || (selected && selected.exchange_key); + if (!exKey || tradeId == null) return; + if (!window.confirm("从档案移除该笔交易?(不影响交易所实例里的复盘记录)")) return; + const r = await apiFetch("/api/archive/trade/" + exKey + "/" + tradeId, { method: "DELETE" }); if (!r.ok) { const j = await r.json().catch(function () { return {}; @@ -749,82 +901,53 @@ setStatus(j.detail || j.msg || "删除失败"); return; } - if (String(selectedTradeId) === String(tradeId)) { - selectedTradeId = null; - } - trades = trades.filter(function (t) { - return String(t.trade_id || t.id) !== String(tradeId); - }); - renderTrades(); - applyChartMarkers(); - await loadList(); + if (String(selectedTradeId) === String(tradeId)) selectedTradeId = null; + await loadDailyTrades(); setStatus("已移除 1 笔档案记录"); } - async function saveOverlay(tradeId, tag, note) { - if (!selected) return; + async function saveOverlay(tradeId, exchangeKey, tag, note) { + const exKey = exchangeKey || (selected && selected.exchange_key); + if (!exKey) return; const body = { behavior_tag: tag || "", note: note != null ? note : undefined }; if (note == null) { const row = elTrades.querySelector('.archive-trade-row[data-id="' + tradeId + '"]'); const inp = row && row.querySelector(".archive-note-input"); body.note = inp ? inp.value : ""; } - await apiFetch("/api/archive/trade/" + selected.exchange_key + "/" + tradeId, { + await apiFetch("/api/archive/trade/" + exKey + "/" + tradeId, { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body), }); - const tr = trades.find(function (t) { + const tr = dailyTrades.find(function (t) { return String(t.trade_id || t.id) === String(tradeId); }); if (tr) { tr.behavior_tag = body.behavior_tag; tr.note = body.note; } - } - - async function openDetail(exchangeKey, symbol) { - selected = { exchange_key: exchangeKey, symbol: symbol }; - if (elDetailPanel) elDetailPanel.classList.remove("hidden"); - const row = listRows.find(function (r) { - return r.exchange_key === exchangeKey && r.symbol === symbol; - }); - if (elDetailTitle) { - elDetailTitle.textContent = symbol + " · " + exchangeKey; - } - if (elDetailStats && row) { - elDetailStats.textContent = - row.trade_count + - " 笔 · 胜 " + - row.win_count + - " / 负 " + - row.loss_count + - " · 合计 " + - fmtPnl(row.total_pnl) + - " U"; - } - renderList(); - setStatus("加载交易明细…"); - const r = await apiFetch( - "/api/archive/detail?exchange_key=" + - encodeURIComponent(exchangeKey) + - "&symbol=" + - encodeURIComponent(symbol) - ); - const j = await r.json(); - trades = j.trades || []; - selectedTradeId = trades.length ? String(trades[0].trade_id || trades[0].id) : null; renderTrades(); - await loadChart(); } - async function loadList() { - setStatus("加载列表…"); - const r = await apiFetch("/api/archive/list?" + queryListParams()); + async function loadDailyTrades() { + setStatus("加载交易记录…"); + const r = await apiFetch("/api/archive/daily-trades?" + queryDailyParams()); const j = await r.json(); - listRows = j.rows || []; - renderList(); - setStatus("共 " + listRows.length + " 个币种档案 · " + new Date().toLocaleTimeString()); + if (!r.ok) { + setStatus(j.detail || "加载失败"); + return; + } + tradingDay = j.trading_day || tradingDay; + if (elTradingDay && tradingDay && !elTradingDay.value) elTradingDay.value = tradingDay; + if (elQuoteDate && tradingDay && !elQuoteDate.value) elQuoteDate.value = tradingDay; + dailyTrades = j.trades || []; + dailyStats = j.stats || { open_count: 0, by_exchange: {} }; + renderStats(); + renderTrades(); + setStatus( + (tradingDay || "当日") + " · " + dailyTrades.length + " 笔 · " + new Date().toLocaleTimeString() + ); } async function loadMeta() { @@ -850,14 +973,10 @@ const parts = ["同步完成 · " + okN + "/" + (j.exchanges || 0) + " 所"]; results.forEach(function (row) { const label = row.exchange_key || row.name || "?"; - if (row.ok === false) { - parts.push(label + " 失败: " + (row.msg || "未知错误")); - } else { - let line = - label + " " + (row.trade_count != null ? row.trade_count : row.trades || 0) + " 笔"; - if (row.trades_removed > 0) { - line += " 清" + row.trades_removed; - } + if (row.ok === false) parts.push(label + " 失败: " + (row.msg || "未知错误")); + else { + let line = label + " " + (row.trade_count != null ? row.trade_count : row.trades || 0) + " 笔"; + if (row.trades_removed > 0) line += " 清" + row.trades_removed; parts.push(line); } }); @@ -866,27 +985,50 @@ async function syncAll() { setStatus("同步中(可能需数分钟)…"); - elBtnSync && (elBtnSync.disabled = true); + if (elBtnSync) elBtnSync.disabled = true; try { const r = await apiFetch("/api/archive/sync", { method: "POST" }); const j = await r.json(); setStatus(formatSyncSummary(j)); - await loadList(); - if (selected) await openDetail(selected.exchange_key, selected.symbol); + await loadDailyTrades(); + await loadQuotes(); + if (isChartOpen() && selected) await loadChart(); } catch (e) { setStatus(String(e)); } finally { - elBtnSync && (elBtnSync.disabled = false); + if (elBtnSync) elBtnSync.disabled = false; } } function bindEvents() { - if (elBtnRefresh) elBtnRefresh.addEventListener("click", loadList); + if (elBtnRefresh) elBtnRefresh.addEventListener("click", loadDailyTrades); if (elBtnSync) elBtnSync.addEventListener("click", syncAll); - if (elExchange) elExchange.addEventListener("change", loadList); - [elFilterProfit, elFilterLoss, elFilterSick, elFilterEmotion].forEach(function (el) { - if (el) el.addEventListener("change", loadList); + if (elExchange) elExchange.addEventListener("change", loadDailyTrades); + if (elTradingDay) elTradingDay.addEventListener("change", loadDailyTrades); + [elFilterProfit, elFilterLoss, elFilterSick].forEach(function (el) { + if (el) el.addEventListener("change", loadDailyTrades); }); + if (elSearch) { + elSearch.addEventListener("input", function () { + clearTimeout(searchTimer); + searchTimer = setTimeout(loadDailyTrades, 320); + }); + } + if (elBtnChartToggle) { + elBtnChartToggle.addEventListener("click", function () { + const next = !isChartOpen(); + setChartOpen(next); + if (next && selected) void loadChart(); + }); + } + if (elChartSection) { + elChartSection.addEventListener("toggle", function () { + if (elBtnChartToggle) elBtnChartToggle.classList.toggle("is-active", elChartSection.open); + if (elChartSection.open && selected) void loadChart(); + else if (!elChartSection.open) destroyChart(); + }); + } + if (elQuoteForm) elQuoteForm.addEventListener("submit", addQuote); if (elTfTabs) { elTfTabs.addEventListener("click", function (ev) { const btn = ev.target.closest(".archive-tf-btn"); @@ -908,24 +1050,20 @@ loadChart(); }); } - if (elBtnJump) { - elBtnJump.addEventListener("click", function () { - loadChart(); - }); - } + if (elBtnJump) elBtnJump.addEventListener("click", loadChart); } async function init() { - if (!document.getElementById("page-archive") || document.getElementById("page-archive").classList.contains("hidden")) { - return; - } + if (!page || page.classList.contains("hidden")) return; if (!inited) { loadMarkAutoPref(); + setChartOpen(false); bindEvents(); inited = true; } await loadMeta(); - await loadList(); + await loadQuotes(); + await loadDailyTrades(); } function destroy() { diff --git a/manual_trading_hub/static/index.html b/manual_trading_hub/static/index.html index 6f9dfe4..2c7d95f 100644 --- a/manual_trading_hub/static/index.html +++ b/manual_trading_hub/static/index.html @@ -15,7 +15,7 @@ - + @@ -48,7 +48,7 @@ 资金概况监控区行情区 - 币种档案 + 内照明心数据看板AI 教练系统设置 @@ -259,58 +259,78 @@ @@ -553,7 +573,7 @@ - +
开仓类型开仓时间平仓时间持仓时长交易所开仓类型开仓时间平仓时间持仓时长方向结果盈亏标签备注操作
" + + 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 || "—") + "" + '