diff --git a/docs/hub-symbol-archive-kline.md b/docs/hub-symbol-archive-kline.md index 478d5d5..74ce901 100644 --- a/docs/hub-symbol-archive-kline.md +++ b/docs/hub-symbol-archive-kline.md @@ -1,25 +1,58 @@ -# 中控币种档案与永久 K 线 +# 内照明心与永久 K 线 ## 概述 -「币种档案」页(`/archive`)按 **交易所 + 币种** 一行汇总历史已平仓记录,支持筛选、交易时间线、备注/犯病情绪标签,以及基于 **永久 5m 真源** 的 K 线大图(15m/1h/4h 由 5m 聚合)。 +「内照明心」页(`/archive`)用于 **复盘语录 + 交易记录回顾 + 按需 K 线**。左侧维护每日复盘语录(最多 100 条);右侧按日期区间列出开仓记录,展示区间统计,并可展开 K 线图表对照单笔交易。 与行情区 `hub_kline.db`(15 天滚动缓存)**完全独立**:档案库只增不删,从建档起永久保留。 +## 页面布局 + +| 区域 | 说明 | +|------|------| +| **复盘语录** | 左栏;按日期添加/编辑/删除,一日一条 | +| **日期与筛选** | 顶栏:本日 / 本周 / 本月 / 自选区间;盈利单、亏损单、犯病、交易所、搜索 | +| **区间统计** | 统计栏随日期选择自动更新(见下) | +| **K 线图表** | 默认折叠;点「图表」或展开后按需加载 | +| **交易记录** | 默认展开;犯病行 **红色字体**(无红底);可编辑标签与备注 | + +## 日期区间 + +交易日按北京时间 **8:00** 切日(`TRADING_DAY_RESET_HOUR`)。 + +| 模式 | 范围 | +|------|------| +| **本日** | 可选单个交易日(默认当前交易日) | +| **本周** | 当周周一至当前交易日 | +| **本月** | 当月 1 日至当前交易日 | +| **区间** | 自选 `date_from`~`date_to`(含首尾交易日) | + +## 区间统计(统计栏) + +基于所选日期区间内 **全部开仓**(不受盈利/亏损/犯病勾选与搜索影响;交易所筛选仍生效): + +| 指标 | 说明 | +|------|------| +| 总开仓次数 | 区间内开仓笔数 | +| 犯病次数 / 占比 | `behavior_tag = sick` 的笔数及占开仓比例 | +| 盈亏 | 区间内全部已平仓盈亏合计 | +| 剔除犯病盈亏 | 排除犯病单后的盈亏合计 | +| 各交易所 | 每所:开仓、犯病、盈亏、剔除犯病盈亏 | + +表格列表仍可按盈利单 / 亏损单 / 犯病 / 搜索进一步过滤。 + ## 数据约定 | 项 | 约定 | |----|------| -| 列表粒度 | 一所一币一行 | -| 交易来源 | 四所 `trade_records` + 未落库的 `strategy_trade_snapshots`(gate_bot 趋势漏记时补全),经 `/api/hub/trades/archive` 拉取 | -| 筛选 | 交易所、有盈利单、有亏损单、犯病、情绪(中控 overlay) | +| 交易来源 | 四所 `trade_records` + 未落库的 `strategy_trade_snapshots`,经 `/api/hub/trades/archive` 拉取 | +| 犯病标签 | 中控 `trade_overlay.behavior_tag = sick` | | K 线真源 | 仅 **5m** 写入 `hub_symbol_archive.db` | | 建档种子 | 该币 **最早开仓** 向前 **30 天** 5m | | 增量同步 | 默认每 **4 小时** 补新 5m 至当前 | | 展示周期 | Tab:**5m / 15m / 1h / 4h**,默认 **15m** | -| 视窗模式 | **持仓过程**(锚平仓,默认)/ **进场决策**(锚开仓);历史段为建档→平仓,可拖动/滚轮缩放看建仓前全局,**不拉到「现在」** | -| 时间跳转 | 上方输入 `YYYY-MM-DD HH:MM` 后点「跳转」 | -| 图片 | **不上传** | +| 视窗模式 | **持仓过程**(锚平仓,默认)/ **进场决策**(锚开仓) | +| 时间跳转 | 输入 `YYYY-MM-DD HH:MM` 后点「跳转」 | ## 存储 @@ -29,20 +62,37 @@ - `archive_meta` — 建档元数据 - `archive_bars_5m` — 永久 5m K 线 - `archive_trade_cache` — 从实例同步的交易快照 - - `trade_overlay` — 犯病/情绪标签与备注(仅中控) + - `trade_overlay` — 犯病标签与备注(仅中控) + - `archive_review_quotes` — 复盘语录 ## API(中控 FastAPI) | 方法 | 路径 | 说明 | |------|------|------| | GET | `/api/archive/meta` | 周期、交易所、同步间隔等 | -| GET | `/api/archive/list` | 币种列表(筛选 query) | -| GET | `/api/archive/detail` | 单币种交易时间线 | +| GET | `/api/archive/daily-trades` | 区间交易列表与统计(见 query) | +| GET | `/api/archive/quotes` | 复盘语录列表 | +| POST | `/api/archive/quotes` | 新增语录 | +| PATCH | `/api/archive/quotes/{id}` | 更新语录 | +| DELETE | `/api/archive/quotes/{id}` | 删除语录 | | GET | `/api/archive/ohlcv` | K 线视窗(`timeframe` / `mode` / `anchor_ms` / `at`) | | PATCH | `/api/archive/trade/{exchange_key}/{trade_id}` | 更新标签/备注 | | POST | `/api/archive/sync` | 立即同步四所交易 + K 线 | -实例侧新增: +`GET /api/archive/daily-trades` 主要 query: + +| 参数 | 说明 | +|------|------| +| `period` | `today` / `week` / `month` / `range` | +| `trading_day` | 本日模式下的交易日 `YYYY-MM-DD` | +| `date_from` / `date_to` | 区间模式起止日 | +| `exchange_key` | 可选,按交易所筛选 | +| `filter_profit` / `filter_loss` / `filter_sick` | 仅过滤表格列表 | +| `search` | 合约 / 交易所 / 备注搜索(仅列表) | + +返回 `stats` 含 `open_count`、`sick_count`、`sick_pct`、`pnl_total`、`pnl_ex_sick`、`by_exchange`。 + +实例侧: | 方法 | 路径 | 说明 | |------|------|------| @@ -61,20 +111,20 @@ Hub 启动后在 lifespan 中运行 `hub-archive-sync`: ## 代码位置 -- `hub_symbol_archive_lib.py` — 库表、种子、增量、聚合、列表 +- `hub_symbol_archive_lib.py` — 库表、区间统计、种子、增量、聚合 - `hub_trades_lib.py` — `fetch_trades_for_archive` - `hub_bridge.py` — 实例 `/api/hub/trades/archive` - `manual_trading_hub/hub.py` — 路由与后台同步 -- `manual_trading_hub/static/archive.js` — 前端页 +- `manual_trading_hub/static/archive.js` — 内照明心前端 ## 与行情区的区别 -| | 行情区 | 币种档案 | +| | 行情区 | 内照明心 | |--|--------|----------| | DB | `hub_kline.db` | `hub_symbol_archive.db` | | 保留 | 15 天滚动删除 | 建档起永久 | | 周期 | 多周期直存/拉取 | 仅存 5m,高周期聚合 | -| 用途 | 实时看盘 | 复盘与档案 | +| 用途 | 实时看盘 | 复盘语录与交易回顾 | ## 相关文档 diff --git a/hub_symbol_archive_lib.py b/hub_symbol_archive_lib.py index 2646d03..4f652a8 100644 --- a/hub_symbol_archive_lib.py +++ b/hub_symbol_archive_lib.py @@ -1195,6 +1195,93 @@ def trading_day_bounds_ms( return int(start.timestamp() * 1000), int(end.timestamp() * 1000) +def resolve_period_bounds( + *, + period: str = "", + trading_day: str = "", + date_from: str = "", + date_to: str = "", + reset_hour: int = TRADING_DAY_RESET_HOUR, +) -> tuple[int, int, str, str, str]: + """返回 (start_ms, end_ms, date_from, date_to, period_label)。""" + td = today_trading_day(reset_hour=reset_hour) + p = (period or "today").strip().lower() + if p in ("day", "today", ""): + d = (trading_day or "").strip()[:10] or td + start_ms, end_ms = trading_day_bounds_ms(d, reset_hour=reset_hour) + return start_ms, end_ms, d, d, f"本日 {d}" + if p == "week": + day_dt = datetime.strptime(td, "%Y-%m-%d") + monday = day_dt - timedelta(days=day_dt.weekday()) + df = monday.strftime("%Y-%m-%d") + start_ms, _ = trading_day_bounds_ms(df, reset_hour=reset_hour) + _, end_ms = trading_day_bounds_ms(td, reset_hour=reset_hour) + return start_ms, end_ms, df, td, f"本周 {df}~{td}" + if p == "month": + day_dt = datetime.strptime(td, "%Y-%m-%d") + first = day_dt.replace(day=1) + df = first.strftime("%Y-%m-%d") + start_ms, _ = trading_day_bounds_ms(df, reset_hour=reset_hour) + _, end_ms = trading_day_bounds_ms(td, reset_hour=reset_hour) + return start_ms, end_ms, df, td, f"本月 {df}~{td}" + if p == "range": + df = (date_from or "").strip()[:10] or td + dt = (date_to or "").strip()[:10] or df + if df > dt: + df, dt = dt, df + start_ms, _ = trading_day_bounds_ms(df, reset_hour=reset_hour) + _, end_ms = trading_day_bounds_ms(dt, reset_hour=reset_hour) + label = f"区间 {df}~{dt}" if df != dt else f"区间 {df}" + return start_ms, end_ms, df, dt, label + d = (trading_day or "").strip()[:10] or td + start_ms, end_ms = trading_day_bounds_ms(d, reset_hour=reset_hour) + return start_ms, end_ms, d, d, f"本日 {d}" + + +def _compute_period_stats(trade_rows: list[dict[str, Any]]) -> dict[str, Any]: + total = len(trade_rows) + sick = 0 + pnl_all = 0.0 + pnl_ex = 0.0 + by_ex: dict[str, dict[str, Any]] = {} + for td_row in trade_rows: + ex = str(td_row.get("exchange_key") or "?") + pnl = float(td_row.get("pnl_amount") or 0) + tag = str(td_row.get("behavior_tag") or "") + is_sick = tag == "sick" + if is_sick: + sick += 1 + pnl_all += pnl + if not is_sick: + pnl_ex += pnl + if ex not in by_ex: + by_ex[ex] = { + "open_count": 0, + "sick_count": 0, + "pnl_total": 0.0, + "pnl_ex_sick": 0.0, + } + bucket = by_ex[ex] + bucket["open_count"] += 1 + bucket["pnl_total"] += pnl + if is_sick: + bucket["sick_count"] += 1 + else: + bucket["pnl_ex_sick"] += pnl + for ex in by_ex: + by_ex[ex]["pnl_total"] = round(by_ex[ex]["pnl_total"], 4) + by_ex[ex]["pnl_ex_sick"] = round(by_ex[ex]["pnl_ex_sick"], 4) + sick_pct = round(sick / total * 100, 1) if total else 0.0 + return { + "open_count": total, + "sick_count": sick, + "sick_pct": sick_pct, + "pnl_total": round(pnl_all, 4), + "pnl_ex_sick": round(pnl_ex, 4), + "by_exchange": by_ex, + } + + def list_review_quotes(*, db_path: Path | None = None) -> list[dict[str, Any]]: init_db(db_path) conn = _connect(db_path) @@ -1310,6 +1397,9 @@ def delete_review_quote(quote_id: int, *, db_path: Path | None = None) -> bool: def list_daily_trades( trading_day: str = "", *, + period: str = "", + date_from: str = "", + date_to: str = "", exchange_key: str = "", filter_profit: bool = False, filter_loss: bool = False, @@ -1317,10 +1407,15 @@ def list_daily_trades( 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) + p = (period or "today").strip().lower() or "today" + start_ms, end_ms, df, dt, period_label = resolve_period_bounds( + period=p, + trading_day=trading_day, + date_from=date_from, + date_to=date_to, + ) ex_filter = (exchange_key or "").strip().lower() conn = _connect(db_path) try: @@ -1329,15 +1424,6 @@ def list_daily_trades( 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 @@ -1347,6 +1433,7 @@ def list_daily_trades( params, ).fetchall() overlays_by_ex: dict[str, dict[int, dict]] = {} + all_rows: list[dict[str, Any]] = [] trades: list[dict[str, Any]] = [] q = (search or "").strip().lower() for r in rows: @@ -1354,6 +1441,7 @@ def list_daily_trades( 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"]))) + all_rows.append(td_row) pnl = float(td_row.get("pnl_amount") or 0) tag = td_row.get("behavior_tag") or "" if filter_profit and pnl <= 0.0001: @@ -1378,16 +1466,14 @@ def list_daily_trades( 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, + "period": p, + "period_label": period_label, + "trading_day": dt, + "date_from": df, + "date_to": dt, "trades": trades, - "stats": { - "open_count": sum(by_exchange.values()), - "by_exchange": by_exchange, - }, + "stats": _compute_period_stats(all_rows), } finally: conn.close() diff --git a/manual_trading_hub/hub.py b/manual_trading_hub/hub.py index 6d3c7a1..895f58c 100644 --- a/manual_trading_hub/hub.py +++ b/manual_trading_hub/hub.py @@ -2057,7 +2057,10 @@ def api_archive_list( @app.get("/api/archive/daily-trades") def api_archive_daily_trades( + period: str = "", trading_day: str = "", + date_from: str = "", + date_to: str = "", exchange_key: str = "", filter_profit: str = "", filter_loss: str = "", @@ -2066,7 +2069,10 @@ def api_archive_daily_trades( ): init_archive_db() payload = list_daily_trades( - trading_day=trading_day or today_trading_day(), + trading_day=trading_day, + period=period or "today", + date_from=date_from, + date_to=date_to, 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"), diff --git a/manual_trading_hub/static/app.css b/manual_trading_hub/static/app.css index f62fa6c..8a3f04b 100644 --- a/manual_trading_hub/static/app.css +++ b/manual_trading_hub/static/app.css @@ -5570,6 +5570,41 @@ body.funds-fullscreen-open { padding: 12px; min-height: 100%; } +.archive-period-bar { + flex-wrap: wrap; +} +.archive-period-tabs { + display: inline-flex; + gap: 4px; +} +.archive-period-btn { + padding: 5px 10px; + border-radius: 8px; + border: 1px solid var(--border-soft); + background: var(--inset-surface); + color: var(--muted); + cursor: pointer; + font-family: var(--font); + font-size: 0.8rem; +} +.archive-period-btn.is-active { + color: var(--text); + border-color: var(--accent); + background: color-mix(in srgb, var(--accent) 12%, transparent); +} +.archive-period-range { + display: inline-flex; + align-items: center; + gap: 4px; +} +.archive-period-range.hidden, +.archive-period-day-input.hidden { + display: none; +} +.archive-period-sep { + color: var(--muted); + font-size: 0.82rem; +} .archive-stats-bar { padding: 10px 12px; border-radius: 8px; @@ -5578,6 +5613,13 @@ body.funds-fullscreen-open { font-size: 0.82rem; color: var(--text); line-height: 1.45; + display: flex; + flex-direction: column; + gap: 4px; +} +.archive-stats-sub { + font-size: 0.78rem; + color: var(--muted); } .archive-acc-section { border: 1px solid var(--border-soft); diff --git a/manual_trading_hub/static/archive.js b/manual_trading_hub/static/archive.js index 036e9a7..1ea47e3 100644 --- a/manual_trading_hub/static/archive.js +++ b/manual_trading_hub/static/archive.js @@ -9,7 +9,11 @@ const elFilterProfit = document.getElementById("archive-filter-profit"); const elFilterLoss = document.getElementById("archive-filter-loss"); const elFilterSick = document.getElementById("archive-filter-sick"); + const elPeriodTabs = document.getElementById("archive-period-tabs"); const elTradingDay = document.getElementById("archive-trading-day"); + const elPeriodRangeWrap = document.getElementById("archive-period-range-wrap"); + const elDateFrom = document.getElementById("archive-date-from"); + const elDateTo = document.getElementById("archive-date-to"); const elSearch = document.getElementById("archive-search"); const elBtnChartToggle = document.getElementById("archive-btn-chart-toggle"); const elBtnRefresh = document.getElementById("archive-btn-refresh"); @@ -45,6 +49,10 @@ let quotes = []; let dailyTrades = []; let dailyStats = { open_count: 0, by_exchange: {} }; + let periodMode = "today"; + let periodLabel = ""; + let dateFrom = ""; + let dateTo = ""; let tradingDay = ""; let selected = null; let trades = []; @@ -292,9 +300,35 @@ return r; } + function syncPeriodUI() { + if (elPeriodTabs) { + elPeriodTabs.querySelectorAll(".archive-period-btn").forEach(function (btn) { + btn.classList.toggle("is-active", btn.getAttribute("data-period") === periodMode); + }); + } + if (elTradingDay) { + elTradingDay.classList.toggle("hidden", periodMode !== "today"); + } + if (elPeriodRangeWrap) { + elPeriodRangeWrap.classList.toggle("hidden", periodMode !== "range"); + } + } + + function setPeriodMode(mode) { + periodMode = mode || "today"; + syncPeriodUI(); + } + function queryDailyParams() { const q = new URLSearchParams(); - if (elTradingDay && elTradingDay.value) q.set("trading_day", elTradingDay.value); + q.set("period", periodMode); + if (periodMode === "today" && elTradingDay && elTradingDay.value) { + q.set("trading_day", elTradingDay.value); + } + if (periodMode === "range") { + if (elDateFrom && elDateFrom.value) q.set("date_from", elDateFrom.value); + if (elDateTo && elDateTo.value) q.set("date_to", elDateTo.value); + } const ex = (elExchange && elExchange.value) || ""; if (ex) q.set("exchange_key", ex); if (elFilterProfit && elFilterProfit.checked) q.set("filter_profit", "1"); @@ -304,6 +338,14 @@ return q.toString(); } + function fmtPnlStat(v) { + const n = Number(v); + if (!Number.isFinite(n)) return "—"; + const cls = n >= 0 ? "pnl-pos" : "pnl-neg"; + const text = (n >= 0 ? "+" : "") + n.toFixed(2) + "U"; + return '' + text + ""; + } + function renderExchangeOptions() { if (!elExchange || !meta) return; const cur = elExchange.value; @@ -320,14 +362,47 @@ function renderStats() { if (!elStats) return; const st = dailyStats || { open_count: 0, by_exchange: {} }; - const parts = ["今日开仓 " + (st.open_count || 0) + " 次"]; + const label = periodLabel || "本日"; + const sickPct = st.sick_pct != null ? st.sick_pct : 0; + let html = + '