diff --git a/docs/hub-symbol-archive-kline.md b/docs/hub-symbol-archive-kline.md new file mode 100644 index 0000000..693756e --- /dev/null +++ b/docs/hub-symbol-archive-kline.md @@ -0,0 +1,82 @@ +# 中控币种档案与永久 K 线 + +## 概述 + +「币种档案」页(`/archive`)按 **交易所 + 币种** 一行汇总历史已平仓记录,支持筛选、交易时间线、备注/犯病情绪标签,以及基于 **永久 5m 真源** 的 K 线大图(15m/1h/4h 由 5m 聚合)。 + +与行情区 `hub_kline.db`(15 天滚动缓存)**完全独立**:档案库只增不删,从建档起永久保留。 + +## 数据约定 + +| 项 | 约定 | +|----|------| +| 列表粒度 | 一所一币一行 | +| 交易来源 | 四所 `trade_records`,经 `/api/hub/trades/archive` 拉取 | +| 筛选 | 交易所、有盈利单、有亏损单、犯病、情绪(中控 overlay) | +| K 线真源 | 仅 **5m** 写入 `hub_symbol_archive.db` | +| 建档种子 | 该币 **最早开仓** 向前 **30 天** 5m | +| 增量同步 | 默认每 **4 小时** 补新 5m 至当前 | +| 展示周期 | Tab:**5m / 15m / 1h / 4h**,默认 **15m** | +| 视窗模式 | **持仓过程**(锚平仓,默认)/ **进场决策**(锚开仓) | +| 时间跳转 | 上方输入 `YYYY-MM-DD HH:MM` 后点「跳转」 | +| 图片 | **不上传** | + +## 存储 + +- 默认路径:`manual_trading_hub/data/hub_symbol_archive.db` +- 环境变量:`HUB_ARCHIVE_DB_PATH` +- 表: + - `archive_meta` — 建档元数据 + - `archive_bars_5m` — 永久 5m K 线 + - `archive_trade_cache` — 从实例同步的交易快照 + - `trade_overlay` — 犯病/情绪标签与备注(仅中控) + +## API(中控 FastAPI) + +| 方法 | 路径 | 说明 | +|------|------|------| +| GET | `/api/archive/meta` | 周期、交易所、同步间隔等 | +| GET | `/api/archive/list` | 币种列表(筛选 query) | +| GET | `/api/archive/detail` | 单币种交易时间线 | +| 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/hub/trades/archive` | 近 N 天已平仓(`days` / `limit`) | + +## 后台任务 + +Hub 启动后在 lifespan 中运行 `hub-archive-sync`: + +1. 对各启用交易所调用 `/api/hub/trades/archive` +2. 写入 `archive_trade_cache` +3. 未建档币种:拉 30 天 5m 种子 +4. 已建档币种:增量补 5m + +间隔:`HUB_ARCHIVE_SYNC_INTERVAL_SEC`(默认 14400)。 + +## 代码位置 + +- `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` — 前端页 + +## 与行情区的区别 + +| | 行情区 | 币种档案 | +|--|--------|----------| +| DB | `hub_kline.db` | `hub_symbol_archive.db` | +| 保留 | 15 天滚动删除 | 建档起永久 | +| 周期 | 多周期直存/拉取 | 仅存 5m,高周期聚合 | +| 用途 | 实时看盘 | 复盘与档案 | + +## 相关文档 + +- [中控平仓与交易记录](trend-hub-close-and-trade-records.md) +- [中控使用说明](../manual_trading_hub/使用说明.md) diff --git a/docs/trend-hub-close-and-trade-records.md b/docs/trend-hub-close-and-trade-records.md index 000a1cc..db7584f 100644 --- a/docs/trend-hub-close-and-trade-records.md +++ b/docs/trend-hub-close-and-trade-records.md @@ -177,6 +177,7 @@ pm2 save | [策略交易说明.md](../策略交易说明.md) | 策略总览、策略交易记录页 | | [crypto_monitor_gate_bot/趋势回调策略说明.md](../crypto_monitor_gate_bot/趋势回调策略说明.md) | 趋势回调业务细则 | | [manual_trading_hub/使用说明.md](../manual_trading_hub/使用说明.md) | 中控监控与趋势卡布局 | +| [hub-symbol-archive-kline.md](./hub-symbol-archive-kline.md) | 币种档案、永久 5m K 线、交易 overlay | --- diff --git a/hub_bridge.py b/hub_bridge.py index 3805917..29da12b 100644 --- a/hub_bridge.py +++ b/hub_bridge.py @@ -418,6 +418,52 @@ def register_hub_routes(app): ) ) + @app.route("/api/hub/trades/archive") + @_hub_auth_required + def api_hub_trades_archive(): + """中控币种档案:近 N 天已平仓记录。""" + from hub_trades_lib import fetch_trades_for_archive, summarize_trades + + c = _ctx() + get_db = c.get("get_db") + if not get_db: + return jsonify({"ok": False, "msg": "HUB_CTX 缺少 get_db"}), 500 + try: + days = int(request.args.get("days") or "365") + except ValueError: + days = 365 + try: + limit = int(request.args.get("limit") or "2000") + except ValueError: + limit = 2000 + try: + import os + + reset_hour = int(os.getenv("TRADING_DAY_RESET_HOUR", "8") or "8") + except ValueError: + reset_hour = 8 + conn = get_db() + try: + trades = fetch_trades_for_archive( + conn, + days=days, + row_to_dict_fn=c.get("row_to_dict"), + reset_hour=reset_hour, + limit=limit, + ) + finally: + conn.close() + stats = summarize_trades(trades) + return jsonify( + { + "ok": True, + "days": max(1, min(days, 3650)), + "trading_day_reset_hour": reset_hour, + "trades": trades, + "stats": stats, + } + ) + @app.route("/api/hub/trades/today") @_hub_auth_required def api_hub_trades_today(): diff --git a/hub_symbol_archive_lib.py b/hub_symbol_archive_lib.py new file mode 100644 index 0000000..745809f --- /dev/null +++ b/hub_symbol_archive_lib.py @@ -0,0 +1,811 @@ +"""中控币种档案:永久 5m K 线库(建档种子 + 4h 增量),交易缓存与 overlay。""" + +from __future__ import annotations + +import json +import os +import sqlite3 +import time +from datetime import datetime +from pathlib import Path +from typing import Any, Callable, Optional + +from hub_ohlcv_lib import ( + TIMEFRAME_MS, + aggregate_ohlcv_bars, + normalize_chart_timeframe, +) + +ARCHIVE_TIMEFRAMES = frozenset({"5m", "15m", "1h", "4h"}) +ARCHIVE_DEFAULT_TIMEFRAME = "15m" +ARCHIVE_SEED_LOOKBACK_DAYS = 30 +ARCHIVE_VISIBLE_BARS_DEFAULT = 200 +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")) + +BEHAVIOR_TAGS = frozenset({"", "sick", "emotion"}) + + +def default_db_path() -> Path: + raw = (os.getenv("HUB_ARCHIVE_DB_PATH") or "").strip() + if raw: + return Path(raw) + hub_dir = Path(__file__).resolve().parent / "manual_trading_hub" / "data" + hub_dir.mkdir(parents=True, exist_ok=True) + return hub_dir / "hub_symbol_archive.db" + + +def _connect(db_path: Path | None = None) -> sqlite3.Connection: + path = db_path or default_db_path() + path.parent.mkdir(parents=True, exist_ok=True) + conn = sqlite3.connect(str(path), timeout=30, isolation_level=None) + conn.row_factory = sqlite3.Row + conn.execute("PRAGMA journal_mode=WAL") + conn.execute("PRAGMA synchronous=NORMAL") + return conn + + +def init_db(db_path: Path | None = None) -> None: + conn = _connect(db_path) + try: + conn.execute( + """ + CREATE TABLE IF NOT EXISTS archive_meta ( + exchange_key TEXT NOT NULL, + symbol TEXT NOT NULL, + first_trade_opened_ms INTEGER, + archive_started_at INTEGER NOT NULL, + last_kline_sync_ms INTEGER, + last_trade_sync_ms INTEGER, + seed_complete INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY (exchange_key, symbol) + ) + """ + ) + conn.execute( + """ + CREATE TABLE IF NOT EXISTS archive_bars_5m ( + exchange_key TEXT NOT NULL, + symbol TEXT NOT NULL, + open_time_ms INTEGER NOT NULL, + open REAL NOT NULL, + high REAL NOT NULL, + low REAL NOT NULL, + close REAL NOT NULL, + volume REAL NOT NULL DEFAULT 0, + updated_at INTEGER NOT NULL, + PRIMARY KEY (exchange_key, symbol, open_time_ms) + ) + """ + ) + conn.execute( + """ + CREATE INDEX IF NOT EXISTS idx_archive_bars_series + ON archive_bars_5m (exchange_key, symbol, open_time_ms) + """ + ) + conn.execute( + """ + CREATE TABLE IF NOT EXISTS archive_trade_cache ( + exchange_key TEXT NOT NULL, + trade_id INTEGER NOT NULL, + symbol TEXT NOT NULL, + direction TEXT, + result TEXT, + pnl_amount REAL, + opened_at TEXT, + closed_at TEXT, + opened_at_ms INTEGER, + closed_at_ms INTEGER, + monitor_type TEXT, + entry_reason TEXT, + payload_json TEXT, + synced_at INTEGER NOT NULL, + PRIMARY KEY (exchange_key, trade_id) + ) + """ + ) + conn.execute( + """ + CREATE INDEX IF NOT EXISTS idx_archive_trades_sym + ON archive_trade_cache (exchange_key, symbol, closed_at_ms) + """ + ) + conn.execute( + """ + CREATE TABLE IF NOT EXISTS trade_overlay ( + exchange_key TEXT NOT NULL, + trade_id INTEGER NOT NULL, + behavior_tag TEXT NOT NULL DEFAULT '', + note TEXT NOT NULL DEFAULT '', + updated_at INTEGER NOT NULL, + PRIMARY KEY (exchange_key, trade_id) + ) + """ + ) + finally: + conn.close() + + +def _now_ms() -> int: + return int(time.time() * 1000) + + +def _parse_dt_ms(raw: Any) -> int | None: + if raw in (None, ""): + return None + try: + if isinstance(raw, (int, float)): + v = int(raw) + return v if v > 1_000_000_000_000 else v * 1000 + except (TypeError, ValueError): + pass + s = str(raw).strip().replace("Z", "").replace("T", " ") + if not s: + return None + for fmt, ln in (("%Y-%m-%d %H:%M:%S", 19), ("%Y-%m-%d %H:%M", 16), ("%Y-%m-%d", 10)): + try: + dt = datetime.strptime(s[:ln], fmt) + return int(dt.timestamp() * 1000) + except ValueError: + continue + return None + + +def upsert_trades_cache( + exchange_key: str, + trades: list[dict[str, Any]], + *, + db_path: Path | None = None, +) -> int: + init_db(db_path) + ex_k = (exchange_key or "").strip().lower() + if not ex_k: + return 0 + now = _now_ms() + n = 0 + conn = _connect(db_path) + try: + for t in trades or []: + try: + tid = int(t.get("id")) + except (TypeError, ValueError): + continue + sym = (t.get("symbol") or "").strip().upper() + if not sym: + continue + payload = {k: t.get(k) for k in t.keys()} + conn.execute( + """ + INSERT INTO archive_trade_cache ( + exchange_key, trade_id, symbol, direction, result, pnl_amount, + opened_at, closed_at, opened_at_ms, closed_at_ms, + monitor_type, entry_reason, payload_json, synced_at + ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?) + ON CONFLICT(exchange_key, trade_id) DO UPDATE SET + symbol=excluded.symbol, + direction=excluded.direction, + result=excluded.result, + pnl_amount=excluded.pnl_amount, + opened_at=excluded.opened_at, + closed_at=excluded.closed_at, + opened_at_ms=excluded.opened_at_ms, + closed_at_ms=excluded.closed_at_ms, + monitor_type=excluded.monitor_type, + entry_reason=excluded.entry_reason, + payload_json=excluded.payload_json, + synced_at=excluded.synced_at + """, + ( + ex_k, + tid, + sym, + t.get("direction"), + t.get("result"), + float(t.get("pnl_amount") or 0), + t.get("opened_at"), + t.get("closed_at"), + t.get("opened_at_ms") or _parse_dt_ms(t.get("opened_at")), + t.get("closed_at_ms") or _parse_dt_ms(t.get("closed_at")), + t.get("monitor_type"), + t.get("entry_reason"), + json.dumps(payload, ensure_ascii=False, default=str), + now, + ), + ) + n += 1 + finally: + conn.close() + return n + + +def _trade_row_to_dict(row: sqlite3.Row, overlay: dict | None = None) -> dict[str, Any]: + d = dict(row) + payload = {} + raw = d.pop("payload_json", None) + if raw: + try: + payload = json.loads(raw) + except (json.JSONDecodeError, TypeError): + payload = {} + out = {**payload, **{k: d[k] for k in d.keys() if k not in payload}} + ov = overlay or {} + out["behavior_tag"] = ov.get("behavior_tag") or "" + out["note"] = ov.get("note") or "" + out["trade_id"] = out.get("trade_id") or out.get("id") + return out + + +def load_overlays( + exchange_key: str, + trade_ids: list[int] | None = None, + *, + db_path: Path | None = None, +) -> dict[int, dict[str, Any]]: + ex_k = (exchange_key or "").strip().lower() + conn = _connect(db_path) + try: + if trade_ids: + placeholders = ",".join("?" * len(trade_ids)) + rows = conn.execute( + f""" + SELECT exchange_key, trade_id, behavior_tag, note, updated_at + FROM trade_overlay + WHERE exchange_key=? AND trade_id IN ({placeholders}) + """, + (ex_k, *trade_ids), + ).fetchall() + else: + rows = conn.execute( + """ + SELECT exchange_key, trade_id, behavior_tag, note, updated_at + FROM trade_overlay WHERE exchange_key=? + """, + (ex_k,), + ).fetchall() + return { + int(r["trade_id"]): { + "behavior_tag": r["behavior_tag"] or "", + "note": r["note"] or "", + "updated_at": r["updated_at"], + } + for r in rows + } + finally: + conn.close() + + +def upsert_trade_overlay( + exchange_key: str, + trade_id: int, + *, + behavior_tag: str | None = None, + note: str | None = None, + db_path: Path | None = None, +) -> dict[str, Any]: + init_db(db_path) + ex_k = (exchange_key or "").strip().lower() + tid = int(trade_id) + tag = (behavior_tag or "").strip().lower() + if tag not in BEHAVIOR_TAGS: + tag = "" + note_text = (note or "").strip()[:2000] + now = _now_ms() + conn = _connect(db_path) + try: + conn.execute( + """ + INSERT INTO trade_overlay (exchange_key, trade_id, behavior_tag, note, updated_at) + VALUES (?,?,?,?,?) + ON CONFLICT(exchange_key, trade_id) DO UPDATE SET + behavior_tag=excluded.behavior_tag, + note=excluded.note, + updated_at=excluded.updated_at + """, + (ex_k, tid, tag, note_text, now), + ) + finally: + conn.close() + return {"exchange_key": ex_k, "trade_id": tid, "behavior_tag": tag, "note": note_text} + + +def list_symbol_rows( + *, + exchange_key: str = "", + filter_profit: bool = False, + filter_loss: bool = False, + filter_sick: bool = False, + filter_emotion: bool = False, + db_path: Path | None = None, +) -> list[dict[str, Any]]: + """一所一币一行汇总。""" + init_db(db_path) + conn = _connect(db_path) + try: + params: list[Any] = [] + where = "1=1" + ex_filter = (exchange_key or "").strip().lower() + if ex_filter: + where += " AND t.exchange_key=?" + params.append(ex_filter) + + rows = conn.execute( + f""" + SELECT t.exchange_key, t.symbol, + COUNT(*) AS trade_count, + SUM(CASE WHEN t.pnl_amount > 0.0001 THEN 1 ELSE 0 END) AS win_count, + SUM(CASE WHEN t.pnl_amount < -0.0001 THEN 1 ELSE 0 END) AS loss_count, + SUM(COALESCE(t.pnl_amount, 0)) AS total_pnl, + MIN(COALESCE(t.opened_at_ms, 0)) AS first_opened_ms, + MAX(COALESCE(t.closed_at_ms, 0)) AS last_closed_ms + FROM archive_trade_cache t + WHERE {where} + GROUP BY t.exchange_key, t.symbol + ORDER BY last_closed_ms DESC + """, + params, + ).fetchall() + + overlays_by_ex: dict[str, dict[int, dict]] = {} + out: list[dict[str, Any]] = [] + for r in rows: + ex_k = r["exchange_key"] + sym = r["symbol"] + if ex_k not in overlays_by_ex: + overlays_by_ex[ex_k] = load_overlays(ex_k, db_path=db_path) + + trade_rows = conn.execute( + """ + SELECT trade_id, pnl_amount FROM archive_trade_cache + WHERE exchange_key=? AND symbol=? + """, + (ex_k, sym), + ).fetchall() + has_profit = any(float(x["pnl_amount"] or 0) > 0.0001 for x in trade_rows) + has_loss = any(float(x["pnl_amount"] or 0) < -0.0001 for x in trade_rows) + has_sick = False + has_emotion = False + ov_map = overlays_by_ex.get(ex_k) or {} + for tr in trade_rows: + ov = ov_map.get(int(tr["trade_id"])) or {} + if ov.get("behavior_tag") == "sick": + has_sick = True + if ov.get("behavior_tag") == "emotion": + has_emotion = True + + if filter_profit and not has_profit: + continue + if filter_loss and not has_loss: + continue + if filter_sick and not has_sick: + continue + if filter_emotion and not has_emotion: + continue + + meta = conn.execute( + "SELECT seed_complete, last_kline_sync_ms FROM archive_meta WHERE exchange_key=? AND symbol=?", + (ex_k, sym), + ).fetchone() + + out.append( + { + "exchange_key": ex_k, + "symbol": sym, + "trade_count": int(r["trade_count"] or 0), + "win_count": int(r["win_count"] or 0), + "loss_count": int(r["loss_count"] or 0), + "total_pnl": round(float(r["total_pnl"] or 0), 4), + "first_opened_ms": int(r["first_opened_ms"] or 0) or None, + "last_closed_ms": int(r["last_closed_ms"] or 0) or None, + "seed_complete": bool(meta["seed_complete"]) if meta else False, + "last_kline_sync_ms": int(meta["last_kline_sync_ms"] or 0) if meta else None, + } + ) + return out + finally: + conn.close() + + +def load_symbol_trades( + exchange_key: str, + symbol: str, + *, + db_path: Path | None = None, +) -> list[dict[str, Any]]: + ex_k = (exchange_key or "").strip().lower() + sym = (symbol or "").strip().upper() + conn = _connect(db_path) + try: + rows = conn.execute( + """ + SELECT * FROM archive_trade_cache + WHERE exchange_key=? AND symbol=? + ORDER BY COALESCE(closed_at_ms, 0) DESC, trade_id DESC + """, + (ex_k, sym), + ).fetchall() + ids = [int(r["trade_id"]) for r in rows] + ov = load_overlays(ex_k, ids, db_path=db_path) + return [_trade_row_to_dict(r, ov.get(int(r["trade_id"]))) for r in rows] + finally: + conn.close() + + +def upsert_bars_5m( + exchange_key: str, + symbol: str, + bars: list[dict[str, Any]], + *, + db_path: Path | None = None, +) -> int: + init_db(db_path) + ex_k = (exchange_key or "").strip().lower() + sym = (symbol or "").strip().upper() + now = _now_ms() + n = 0 + conn = _connect(db_path) + try: + for b in bars or []: + try: + conn.execute( + """ + INSERT INTO archive_bars_5m ( + exchange_key, symbol, open_time_ms, open, high, low, close, volume, updated_at + ) VALUES (?,?,?,?,?,?,?,?,?) + ON CONFLICT(exchange_key, symbol, open_time_ms) DO UPDATE SET + open=excluded.open, + high=excluded.high, + low=excluded.low, + close=excluded.close, + volume=excluded.volume, + updated_at=excluded.updated_at + """, + ( + ex_k, + sym, + int(b["open_time_ms"]), + float(b["open"]), + float(b["high"]), + float(b["low"]), + float(b["close"]), + float(b.get("volume") or 0), + now, + ), + ) + n += 1 + except (KeyError, TypeError, ValueError): + continue + finally: + conn.close() + return n + + +def load_bars_5m_range( + exchange_key: str, + symbol: str, + start_ms: int, + end_ms: int, + *, + db_path: Path | None = None, +) -> list[dict[str, Any]]: + ex_k = (exchange_key or "").strip().lower() + sym = (symbol or "").strip().upper() + conn = _connect(db_path) + try: + rows = conn.execute( + """ + SELECT open_time_ms, open, high, low, close, volume + FROM archive_bars_5m + WHERE exchange_key=? AND symbol=? + AND open_time_ms >= ? AND open_time_ms <= ? + ORDER BY open_time_ms ASC + """, + (ex_k, sym, int(start_ms), int(end_ms)), + ).fetchall() + return [ + { + "open_time_ms": int(r["open_time_ms"]), + "open": float(r["open"]), + "high": float(r["high"]), + "low": float(r["low"]), + "close": float(r["close"]), + "volume": float(r["volume"] or 0), + } + for r in rows + ] + finally: + conn.close() + + +def _to_candles(bars: list[dict[str, Any]]) -> list[dict[str, Any]]: + out = [] + for b in bars or []: + try: + out.append( + { + "time": int(b["open_time_ms"] // 1000), + "open": float(b["open"]), + "high": float(b["high"]), + "low": float(b["low"]), + "close": float(b["close"]), + "volume": float(b.get("volume") or 0), + } + ) + except (KeyError, TypeError, ValueError): + continue + return out + + +def resolve_archive_chart( + exchange_key: str, + symbol: str, + timeframe: str = ARCHIVE_DEFAULT_TIMEFRAME, + *, + anchor_ms: int | None = None, + mode: str = "hold", + bars: int = ARCHIVE_VISIBLE_BARS_DEFAULT, + db_path: Path | None = None, +) -> dict[str, Any]: + """从永久 5m 库聚合出档案 K 线视窗。""" + tf = normalize_chart_timeframe(timeframe, default=ARCHIVE_DEFAULT_TIMEFRAME) + if tf not in ARCHIVE_TIMEFRAMES: + return {"ok": False, "msg": f"档案仅支持 {', '.join(sorted(ARCHIVE_TIMEFRAMES))}"} + ex_k = (exchange_key or "").strip().lower() + sym = (symbol or "").strip().upper() + if not ex_k or not sym: + return {"ok": False, "msg": "缺少 exchange_key 或 symbol"} + + period = TIMEFRAME_MS[tf] + visible = max(50, min(int(bars or ARCHIVE_VISIBLE_BARS_DEFAULT), 500)) + anchor = int(anchor_ms) if anchor_ms else _now_ms() + half = visible // 2 + start_ms = max(0, anchor - half * period) + end_ms = anchor + half * period + + raw_5m = load_bars_5m_range(ex_k, sym, start_ms - period * 3, end_ms + period * 3, db_path=db_path) + if not raw_5m: + return {"ok": False, "msg": "档案库暂无 K 线,请等待同步或手动刷新"} + + if tf == "5m": + merged = [b for b in raw_5m if start_ms <= int(b["open_time_ms"]) <= end_ms] + else: + agg = aggregate_ohlcv_bars(raw_5m, tf) + merged = [b for b in agg if start_ms <= int(b["open_time_ms"]) <= end_ms] + + candles = _to_candles(merged) + if not candles: + return {"ok": False, "msg": "视窗内无 K 线"} + + return { + "ok": True, + "exchange_key": ex_k, + "symbol": sym, + "timeframe": tf, + "mode": (mode or "hold").strip().lower(), + "anchor_ms": anchor, + "window_start_ms": start_ms, + "window_end_ms": end_ms, + "candles": candles, + "bar_count": len(candles), + } + + +def _ensure_meta( + exchange_key: str, + symbol: str, + first_opened_ms: int | None, + *, + db_path: Path | None = None, +) -> None: + ex_k = (exchange_key or "").strip().lower() + sym = (symbol or "").strip().upper() + now = _now_ms() + conn = _connect(db_path) + try: + row = conn.execute( + "SELECT first_trade_opened_ms FROM archive_meta WHERE exchange_key=? AND symbol=?", + (ex_k, sym), + ).fetchone() + if row: + if first_opened_ms and ( + not row["first_trade_opened_ms"] + or int(first_opened_ms) < int(row["first_trade_opened_ms"]) + ): + conn.execute( + """ + UPDATE archive_meta SET first_trade_opened_ms=? + WHERE exchange_key=? AND symbol=? + """, + (int(first_opened_ms), ex_k, sym), + ) + return + conn.execute( + """ + INSERT INTO archive_meta ( + exchange_key, symbol, first_trade_opened_ms, + archive_started_at, last_kline_sync_ms, last_trade_sync_ms, seed_complete + ) VALUES (?,?,?,?,?,?,0) + """, + (ex_k, sym, int(first_opened_ms) if first_opened_ms else None, now, None, None), + ) + finally: + conn.close() + + +def _mark_meta_sync( + exchange_key: str, + symbol: str, + *, + kline_ms: int | None = None, + trade_ms: int | None = None, + seed_complete: bool | None = None, + db_path: Path | None = None, +) -> None: + ex_k = (exchange_key or "").strip().lower() + sym = (symbol or "").strip().upper() + conn = _connect(db_path) + try: + sets = [] + params: list[Any] = [] + if kline_ms is not None: + sets.append("last_kline_sync_ms=?") + params.append(int(kline_ms)) + if trade_ms is not None: + sets.append("last_trade_sync_ms=?") + params.append(int(trade_ms)) + if seed_complete is not None: + sets.append("seed_complete=?") + params.append(1 if seed_complete else 0) + if not sets: + return + params.extend([ex_k, sym]) + conn.execute( + f"UPDATE archive_meta SET {', '.join(sets)} WHERE exchange_key=? AND symbol=?", + params, + ) + finally: + conn.close() + + +def fetch_remote_5m_range( + remote_fetch: Callable[..., dict[str, Any]], + symbol: str, + start_ms: int, + end_ms: int, +) -> list[dict[str, Any]]: + """经实例 /api/hub/ohlcv 分页拉取 5m。""" + period = TIMEFRAME_MS["5m"] + since = max(0, int(start_ms)) + end = int(end_ms) + merged: dict[int, dict[str, Any]] = {} + guard = 0 + while since < end and guard < 120: + guard += 1 + remote = remote_fetch(symbol=symbol, timeframe="5m", since_ms=since, limit=500) + if not remote.get("ok"): + break + batch = remote.get("bars") or [] + if not batch: + break + for b in batch: + try: + ts = int(b["open_time_ms"]) + merged[ts] = b + except (KeyError, TypeError, ValueError): + continue + last_ts = max(int(b["open_time_ms"]) for b in batch) + next_since = last_ts + period + if next_since <= since: + break + since = next_since + if last_ts >= end: + break + return [merged[k] for k in sorted(merged.keys()) if start_ms <= k <= end] + + +def seed_symbol_archive( + exchange_key: str, + symbol: str, + first_opened_ms: int, + remote_fetch: Callable[..., dict[str, Any]], + *, + db_path: Path | None = None, +) -> dict[str, Any]: + """建档:最早开仓向前 30 天 5m 种子。""" + init_db(db_path) + ex_k = (exchange_key or "").strip().lower() + sym = (symbol or "").strip().upper() + anchor = int(first_opened_ms) + start_ms = max(0, anchor - ARCHIVE_SEED_LOOKBACK_DAYS * 86400000) + end_ms = _now_ms() + _ensure_meta(ex_k, sym, anchor, db_path=db_path) + bars = fetch_remote_5m_range(remote_fetch, sym, start_ms, end_ms) + n = upsert_bars_5m(ex_k, sym, bars, db_path=db_path) + now = _now_ms() + _mark_meta(ex_k, sym, kline_ms=now, seed_complete=True, db_path=db_path) + return {"ok": True, "seed_bars": n, "start_ms": start_ms, "end_ms": end_ms} + + +def sync_symbol_klines_incremental( + exchange_key: str, + symbol: str, + remote_fetch: Callable[..., dict[str, Any]], + *, + db_path: Path | None = None, +) -> dict[str, Any]: + """增量补 5m 至当前。""" + init_db(db_path) + ex_k = (exchange_key or "").strip().lower() + sym = (symbol or "").strip().upper() + conn = _connect(db_path) + try: + row = conn.execute( + "SELECT MAX(open_time_ms) AS mx FROM archive_bars_5m WHERE exchange_key=? AND symbol=?", + (ex_k, sym), + ).fetchone() + last_bar = int(row["mx"]) if row and row["mx"] else None + finally: + conn.close() + + period = TIMEFRAME_MS["5m"] + start_ms = max(0, (last_bar + period) if last_bar else 0) + end_ms = _now_ms() + if start_ms >= end_ms - period: + return {"ok": True, "appended": 0, "skipped": True} + bars = fetch_remote_5m_range(remote_fetch, sym, start_ms, end_ms) + n = upsert_bars_5m(ex_k, sym, bars, db_path=db_path) + now = _now_ms() + _mark_meta(ex_k, sym, kline_ms=now, db_path=db_path) + return {"ok": True, "appended": n, "start_ms": start_ms, "end_ms": end_ms} + + +def sync_exchange_symbol_archives( + exchange_key: str, + trades: list[dict[str, Any]], + remote_fetch: Callable[..., dict[str, Any]], + *, + db_path: Path | None = None, +) -> dict[str, Any]: + """同步单所:交易缓存 + 各币种 K 线种子/增量。""" + ex_k = (exchange_key or "").strip().lower() + upsert_trades_cache(ex_k, trades, db_path=db_path) + + by_sym: dict[str, int] = {} + for t in trades or []: + sym = (t.get("symbol") or "").strip().upper() + if not sym: + continue + oms = t.get("opened_at_ms") or _parse_dt_ms(t.get("opened_at")) + if oms: + cur = by_sym.get(sym) + if cur is None or int(oms) < cur: + by_sym[sym] = int(oms) + + seeded = 0 + appended = 0 + for sym, first_ms in by_sym.items(): + _ensure_meta(ex_k, sym, first_ms, db_path=db_path) + conn = _connect(db_path) + try: + meta = conn.execute( + "SELECT seed_complete FROM archive_meta WHERE exchange_key=? AND symbol=?", + (ex_k, sym), + ).fetchone() + finally: + conn.close() + if not meta or not int(meta["seed_complete"] or 0): + r = seed_symbol_archive(ex_k, sym, first_ms, remote_fetch, db_path=db_path) + seeded += int(r.get("seed_bars") or 0) + else: + r = sync_symbol_klines_incremental(ex_k, sym, remote_fetch, db_path=db_path) + appended += int(r.get("appended") or 0) + + return { + "ok": True, + "exchange_key": ex_k, + "symbols": len(by_sym), + "seed_bars": seeded, + "appended_bars": appended, + "trades": len(trades or []), + } diff --git a/hub_trades_lib.py b/hub_trades_lib.py index 829bac1..f98bb22 100644 --- a/hub_trades_lib.py +++ b/hub_trades_lib.py @@ -176,6 +176,97 @@ def fetch_trades_for_trading_day( return out +def _normalize_archive_trade_row( + d: dict, + *, + exchange_key: str = "", + reset_hour: int = 8, +) -> dict[str, Any] | None: + """全历史档案用:已平仓记录(不按交易日截断)。""" + effective_result = str(_effective_field(d, "reviewed_result", "result") or "").strip() + if effective_result not in TRADE_COMPLETED_RESULTS: + return None + close_dt = _trade_close_dt(d) + if not close_dt: + return None + pnl = _effective_pnl(d) + closed_at = _effective_field(d, "reviewed_closed_at", "closed_at") + opened_at = _effective_field(d, "reviewed_opened_at", "opened_at") + opened_ms = d.get("opened_at_ms") + closed_ms = d.get("closed_at_ms") + if opened_ms in (None, ""): + odt = parse_dt_for_trading_day(opened_at) + opened_ms = int(odt.timestamp() * 1000) if odt else None + if closed_ms in (None, ""): + cdt = close_dt + closed_ms = int(cdt.timestamp() * 1000) if cdt else None + try: + trade_id = int(d.get("id")) + except (TypeError, ValueError): + return None + return { + "id": trade_id, + "exchange_key": (exchange_key or "").strip().lower(), + "symbol": (d.get("symbol") or "").strip().upper(), + "direction": d.get("direction"), + "result": effective_result, + "pnl_amount": round(pnl, 4), + "closed_at": closed_at, + "opened_at": opened_at, + "opened_at_ms": int(opened_ms) if opened_ms else None, + "closed_at_ms": int(closed_ms) if closed_ms else None, + "monitor_type": d.get("monitor_type"), + "actual_rr": d.get("actual_rr"), + "planned_rr": d.get("planned_rr"), + "trade_style": d.get("trade_style"), + "entry_reason": d.get("entry_reason"), + "trigger_price": d.get("trigger_price"), + "stop_loss": _effective_field(d, "reviewed_stop_loss", "stop_loss"), + "take_profit": _effective_field(d, "reviewed_take_profit", "take_profit"), + "reviewed": bool(d.get("reviewed_at") or d.get("reviewed_result")), + "trading_day": trading_day_from_dt(close_dt, reset_hour), + } + + +def fetch_trades_for_archive( + conn, + *, + days: int = 365, + row_to_dict_fn: Optional[Callable] = None, + reset_hour: int = 8, + limit: int = 2000, +) -> list[dict[str, Any]]: + """返回近 N 天已平仓记录(供币种档案聚合)。""" + lim = max(1, min(int(limit or 2000), 5000)) + day_span = max(1, min(int(days or 365), 3650)) + cutoff = datetime.now() - timedelta(days=day_span) + cutoff_s = cutoff.strftime("%Y-%m-%d %H:%M:%S") + ts_expr = "REPLACE(COALESCE(reviewed_closed_at, closed_at, created_at, opened_at), 'T', ' ')" + rows = conn.execute( + f""" + SELECT id, symbol, direction, result, reviewed_result, pnl_amount, reviewed_pnl_amount, + exchange_realized_pnl, closed_at, reviewed_closed_at, opened_at, reviewed_opened_at, + opened_at_ms, closed_at_ms, created_at, monitor_type, actual_rr, planned_rr, + trade_style, entry_reason, trigger_price, stop_loss, take_profit, + reviewed_stop_loss, reviewed_take_profit, reviewed_at + FROM trade_records + WHERE {ts_expr} >= ? + ORDER BY {ts_expr} DESC + LIMIT ? + """, + (cutoff_s, lim * 2), + ).fetchall() + out: list[dict[str, Any]] = [] + for row in rows: + d = _row_dict(row, row_to_dict_fn) + norm = _normalize_archive_trade_row(d, reset_hour=reset_hour) + if norm: + out.append(norm) + if len(out) >= lim: + break + return out + + def summarize_trades(trades: list[dict]) -> dict[str, Any]: """单笔列表 → 笔数 / 盈亏 / 胜败统计。""" total_pnl = 0.0 diff --git a/manual_trading_hub/hub.py b/manual_trading_hub/hub.py index 8e39b6c..a92df4c 100644 --- a/manual_trading_hub/hub.py +++ b/manual_trading_hub/hub.py @@ -16,6 +16,21 @@ if str(_REPO_ROOT) not in sys.path: from hub_kline_store import format_ohlcv_detail, resolve_chart_bars, retention_days from hub_ohlcv_lib import CHART_TIMEFRAME_ORDER, CHART_TIMEFRAMES, bar_limit_for_timeframe +from hub_symbol_archive_lib import ( + ARCHIVE_DEFAULT_TIMEFRAME, + ARCHIVE_SEED_LOOKBACK_DAYS, + ARCHIVE_SYNC_INTERVAL_SEC, + ARCHIVE_TIMEFRAMES, + ARCHIVE_TRADE_DAYS, + ARCHIVE_TRADE_LIMIT, + ARCHIVE_VISIBLE_BARS_DEFAULT, + init_db as init_archive_db, + list_symbol_rows, + load_symbol_trades, + resolve_archive_chart, + sync_exchange_symbol_archives, + upsert_trade_overlay, +) from env_load import load_hub_dotenv load_hub_dotenv() @@ -77,7 +92,9 @@ _allow_pub_raw = (os.getenv("HUB_ALLOW_PUBLIC") or "").strip().lower() # 云服务器 + 域名反代时设为 true:不做 IP 限制,仅靠 HUB_PASSWORD / 登录页保护 HUB_ALLOW_PUBLIC = _allow_pub_raw in ("1", "true", "yes", "on") DIR = Path(__file__).resolve().parent -HUB_BUILD = "20260606-hub-ai" +HUB_BUILD = "20260607-hub-archive" +_archive_sync_stop: asyncio.Event | None = None +_archive_sync_task: asyncio.Task | None = None HUB_AGENT_TIMEOUT = float(os.getenv("HUB_AGENT_TIMEOUT", "8")) HUB_FLASK_TIMEOUT = float(os.getenv("HUB_FLASK_TIMEOUT", "10")) HUB_BOARD_TIMEOUT = float(os.getenv("HUB_BOARD_TIMEOUT", "45")) @@ -221,13 +238,91 @@ def _schedule_board_refresh() -> None: board_store.request_refresh() +async def _run_archive_sync_once() -> dict: + init_archive_db() + settings = load_settings() + targets = enabled_exchanges(settings) + results: list[dict] = [] + for ex in targets: + ex_key = str(ex.get("key") or "").strip().lower() + if not ex_key: + continue + trades_resp = await asyncio.to_thread( + _fetch_instance_trades_archive_sync, + ex, + days=ARCHIVE_TRADE_DAYS, + limit=ARCHIVE_TRADE_LIMIT, + ) + if not trades_resp.get("ok"): + results.append( + { + "exchange_key": ex_key, + "ok": False, + "msg": trades_resp.get("msg") or trades_resp.get("error") or "拉取交易失败", + } + ) + continue + trades = trades_resp.get("trades") or [] + for t in trades: + if isinstance(t, dict): + t.setdefault("exchange_key", ex_key) + + def remote_fetch(**kwargs): + return _fetch_instance_ohlcv_sync( + ex, + symbol=kwargs.get("symbol") or "", + timeframe=kwargs.get("timeframe") or "5m", + since_ms=kwargs.get("since_ms"), + limit=int(kwargs.get("limit") or 500), + ) + + r = await asyncio.to_thread( + sync_exchange_symbol_archives, + ex_key, + trades, + remote_fetch, + ) + results.append(r) + return {"ok": True, "exchanges": len(targets), "results": results} + + +async def _archive_sync_loop() -> None: + global _archive_sync_stop + stop = _archive_sync_stop + if stop is None: + return + init_archive_db() + while not stop.is_set(): + try: + await _run_archive_sync_once() + except Exception: + pass + try: + await asyncio.wait_for(stop.wait(), timeout=float(ARCHIVE_SYNC_INTERVAL_SEC)) + except asyncio.TimeoutError: + pass + + @asynccontextmanager async def _hub_lifespan(_app: FastAPI): + global _archive_sync_stop, _archive_sync_task await board_store.start(_run_board_aggregate) await chart_poll_store.start(_run_chart_poll) + _archive_sync_stop = asyncio.Event() + _archive_sync_task = asyncio.create_task(_archive_sync_loop(), name="hub-archive-sync") try: yield finally: + if _archive_sync_stop: + _archive_sync_stop.set() + if _archive_sync_task: + _archive_sync_task.cancel() + try: + await _archive_sync_task + except asyncio.CancelledError: + pass + _archive_sync_task = None + _archive_sync_stop = None await chart_poll_store.stop() await board_store.stop() @@ -377,6 +472,7 @@ def root_redirect(): @app.get("/monitor") @app.get("/market") +@app.get("/archive") @app.get("/ai") @app.get("/settings") def shell_pages(): @@ -436,6 +532,30 @@ def _find_exchange_by_key(exchange_key: str) -> dict | None: return None +def _fetch_instance_trades_archive_sync( + ex: dict, + *, + days: int = 365, + limit: int = 2000, +) -> dict: + base = (ex.get("flask_url") or "").rstrip("/") + if not base: + return {"ok": False, "msg": "未配置 flask_url"} + params = {"days": str(int(days)), "limit": str(int(limit))} + url = f"{base}/api/hub/trades/archive?{urlencode(params)}" + try: + with httpx.Client(timeout=HUB_FLASK_TIMEOUT) as client: + r = client.get(url, headers=_hub_headers()) + if r.status_code >= 400: + parsed = _parse_http_json_body(r) + parsed.setdefault("ok", False) + return parsed + data = r.json() if r.content else {} + return data if isinstance(data, dict) else {"ok": False, "msg": "无效 JSON"} + except Exception as e: + return {"ok": False, "msg": str(e)} + + def _fetch_instance_ohlcv_sync( ex: dict, *, @@ -1503,6 +1623,141 @@ def _trade_removed_response(): ) +def _parse_anchor_ms(at: str = "", anchor_ms: str = "") -> int | None: + raw = (anchor_ms or at or "").strip() + if not raw: + return None + if raw.isdigit(): + v = int(raw) + return v if v > 1_000_000_000_000 else v * 1000 + s = raw.replace("Z", "").replace("T", " ") + for fmt, ln in (("%Y-%m-%d %H:%M:%S", 19), ("%Y-%m-%d %H:%M", 16), ("%Y-%m-%d", 10)): + try: + from datetime import datetime + + dt = datetime.strptime(s[:ln], fmt) + return int(dt.timestamp() * 1000) + except ValueError: + continue + return None + + +@app.get("/api/archive/meta") +def api_archive_meta(): + init_archive_db() + exchanges = [] + for ex in enabled_exchanges(load_settings()): + exchanges.append( + { + "id": ex.get("id"), + "key": ex.get("key"), + "name": ex.get("name"), + } + ) + return { + "ok": True, + "timeframes": sorted(ARCHIVE_TIMEFRAMES), + "default_timeframe": ARCHIVE_DEFAULT_TIMEFRAME, + "seed_lookback_days": ARCHIVE_SEED_LOOKBACK_DAYS, + "sync_interval_sec": ARCHIVE_SYNC_INTERVAL_SEC, + "visible_bars_default": ARCHIVE_VISIBLE_BARS_DEFAULT, + "exchanges": exchanges, + } + + +@app.get("/api/archive/list") +def api_archive_list( + exchange_key: str = "", + filter_profit: str = "", + filter_loss: str = "", + filter_sick: str = "", + filter_emotion: str = "", +): + init_archive_db() + rows = list_symbol_rows( + 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"), + filter_emotion=(filter_emotion or "").lower() in ("1", "true", "yes", "on"), + ) + return {"ok": True, "rows": rows, "count": len(rows)} + + +@app.get("/api/archive/detail") +def api_archive_detail(exchange_key: str = "", symbol: str = ""): + ex_k = (exchange_key or "").strip().lower() + sym = (symbol or "").strip().upper() + if not ex_k or not sym: + raise HTTPException(status_code=400, detail="缺少 exchange_key 或 symbol") + init_archive_db() + trades = load_symbol_trades(ex_k, sym) + return {"ok": True, "exchange_key": ex_k, "symbol": sym, "trades": trades} + + +@app.get("/api/archive/ohlcv") +def api_archive_ohlcv( + exchange_key: str = "", + symbol: str = "", + timeframe: str = ARCHIVE_DEFAULT_TIMEFRAME, + mode: str = "hold", + anchor_ms: str = "", + at: str = "", + bars: str = "", +): + ex_k = (exchange_key or "").strip().lower() + sym = (symbol or "").strip().upper() + if not ex_k or not sym: + raise HTTPException(status_code=400, detail="缺少 exchange_key 或 symbol") + init_archive_db() + anchor = _parse_anchor_ms(at, anchor_ms) + try: + bar_n = int(bars) if (bars or "").strip().isdigit() else ARCHIVE_VISIBLE_BARS_DEFAULT + except ValueError: + bar_n = ARCHIVE_VISIBLE_BARS_DEFAULT + result = resolve_archive_chart( + ex_k, + sym, + timeframe, + anchor_ms=anchor, + mode=mode, + bars=bar_n, + ) + if not result.get("ok"): + raise HTTPException(status_code=404, detail=result.get("msg") or "无 K 线") + return result + + +class ArchiveOverlayBody(BaseModel): + behavior_tag: str = "" + note: str = "" + + +@app.patch("/api/archive/trade/{exchange_key}/{trade_id}") +def api_archive_trade_overlay( + exchange_key: str, + trade_id: int, + body: ArchiveOverlayBody = Body(...), +): + ex_k = (exchange_key or "").strip().lower() + if not ex_k: + raise HTTPException(status_code=400, detail="缺少 exchange_key") + init_archive_db() + out = upsert_trade_overlay( + ex_k, + int(trade_id), + behavior_tag=body.behavior_tag, + note=body.note, + ) + return {"ok": True, "overlay": out} + + +@app.post("/api/archive/sync") +async def api_archive_sync(): + body = await _run_archive_sync_once() + return body + + @app.get("/api/ping") def api_ping(): return { @@ -1510,7 +1765,7 @@ def api_ping(): "service": "manual-trading-hub", "build": HUB_BUILD, "trade_ui": False, - "features": ["monitor", "settings", "auth", "board_sse"], + "features": ["monitor", "settings", "auth", "board_sse", "archive"], "board_poll_interval_sec": HUB_BOARD_POLL_INTERVAL, "board_version": board_store.version, "board_aggregating": board_store.aggregating, diff --git a/manual_trading_hub/static/app.css b/manual_trading_hub/static/app.css index 7635104..4f86142 100644 --- a/manual_trading_hub/static/app.css +++ b/manual_trading_hub/static/app.css @@ -3852,3 +3852,193 @@ body.hub-page-ai #page-ai { opacity: 0.65; } +/* —— 币种档案 —— */ +.archive-toolbar { + flex-wrap: wrap; + gap: 10px 14px; + margin-bottom: 12px; +} +.archive-field { + display: inline-flex; + align-items: center; + gap: 6px; + font-size: 0.82rem; + color: var(--muted); +} +.archive-field select, +.archive-field input { + min-width: 120px; + padding: 6px 8px; + border-radius: 8px; + border: 1px solid var(--border-soft); + background: var(--inset-surface); + color: var(--text); + font-family: var(--font); +} +.archive-layout { + display: grid; + grid-template-columns: minmax(220px, 280px) minmax(0, 1fr); + gap: 14px; + min-height: 520px; +} +.archive-list-panel { + background: var(--panel); + border: 1px solid var(--border-soft); + border-radius: var(--radius); + overflow: auto; + max-height: calc(100vh - 200px); +} +.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 { + display: flex; + flex-direction: column; + gap: 10px; + min-width: 0; +} +.archive-detail-head { + display: flex; + flex-wrap: wrap; + align-items: baseline; + gap: 8px 16px; +} +.archive-detail-head h2 { + margin: 0; + font-size: 1.1rem; +} +.archive-detail-stats { + font-size: 0.82rem; + color: var(--muted); +} +.archive-chart-toolbar { + flex-wrap: wrap; +} +.archive-tf-tabs { + display: inline-flex; + gap: 4px; +} +.archive-tf-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-tf-btn.is-active { + color: var(--text); + border-color: var(--accent); + background: color-mix(in srgb, var(--accent) 12%, transparent); +} +.archive-chart-host { + height: 360px; + min-height: 280px; + border: 1px solid var(--border-soft); + border-radius: var(--radius); + background: var(--panel); + overflow: hidden; +} +.archive-trades { + overflow: auto; + max-height: 280px; + border: 1px solid var(--border-soft); + border-radius: var(--radius); + background: var(--panel); +} +.archive-trades-table { + width: 100%; + border-collapse: collapse; + font-size: 0.78rem; +} +.archive-trades-table th, +.archive-trades-table td { + padding: 6px 8px; + border-bottom: 1px solid var(--border-soft); + text-align: left; +} +.archive-trades-table th { + color: var(--muted); + font-weight: 500; + position: sticky; + top: 0; + background: var(--panel); +} +.archive-trade-row { + cursor: pointer; +} +.archive-trade-row.is-active { + background: var(--inset-surface); +} +.archive-trades-table td.pos { + color: #22c55e; +} +.archive-trades-table td.neg { + color: #ef4444; +} +.archive-tag-select, +.archive-note-input { + width: 100%; + max-width: 140px; + padding: 4px 6px; + border-radius: 6px; + border: 1px solid var(--border-soft); + background: var(--inset-surface); + color: var(--text); + font-size: 0.75rem; +} +.archive-empty { + padding: 16px; + color: var(--muted); + font-size: 0.85rem; +} +@media (max-width: 900px) { + .archive-layout { + grid-template-columns: 1fr; + } + .archive-list-panel { + max-height: 240px; + } +} + diff --git a/manual_trading_hub/static/app.js b/manual_trading_hub/static/app.js index 7df6633..5c1c2af 100644 --- a/manual_trading_hub/static/app.js +++ b/manual_trading_hub/static/app.js @@ -624,6 +624,7 @@ function currentPage() { const p = window.location.pathname.replace(/\/$/, "") || "/monitor"; if (p.includes("settings")) return "settings"; + if (p.includes("archive")) return "archive"; if (p.includes("market")) return "market"; if (p.includes("/ai")) return "ai"; return "monitor"; @@ -631,6 +632,7 @@ function pageElementId(page) { if (page === "settings") return "page-settings"; + if (page === "archive") return "page-archive"; if (page === "market") return "page-market"; if (page === "ai") return "page-ai"; return "page-monitor"; @@ -654,6 +656,11 @@ else stopMonitorPoll(); if (page === "settings") loadSettingsUI(); if (page === "ai") loadAiPage(); + if (page === "archive" && window.hubArchivePage) { + window.hubArchivePage.init(); + } else if (window.hubArchivePage && window.hubArchivePage.destroy) { + window.hubArchivePage.destroy(); + } if (page === "market" && window.hubMarketChart) { window.hubMarketChart.init(); } else if (window.hubMarketChart) { diff --git a/manual_trading_hub/static/archive.js b/manual_trading_hub/static/archive.js new file mode 100644 index 0000000..e1950b2 --- /dev/null +++ b/manual_trading_hub/static/archive.js @@ -0,0 +1,484 @@ +/** + * 中控币种档案:列表筛选、交易时间线、永久 K 线(lightweight-charts)。 + */ +(function () { + const page = document.getElementById("page-archive"); + if (!page) return; + + const elExchange = document.getElementById("archive-exchange"); + 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 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 elTfTabs = document.getElementById("archive-tf-tabs"); + const elViewMode = document.getElementById("archive-view-mode"); + const elJumpAt = document.getElementById("archive-jump-at"); + const elBtnJump = document.getElementById("archive-btn-jump"); + const elBtnReloadChart = document.getElementById("archive-btn-reload-chart"); + const elChartHost = document.getElementById("archive-chart"); + const elTrades = document.getElementById("archive-trades"); + + const TF_MS = { + "5m": 5 * 60_000, + "15m": 15 * 60_000, + "1h": 60 * 60_000, + "4h": 4 * 60 * 60_000, + }; + + let meta = null; + let listRows = []; + let selected = null; + let trades = []; + let selectedTradeId = null; + let timeframe = "15m"; + let chart = null; + let candleSeries = null; + let volumeSeries = null; + let inited = false; + + function fmt(n, d) { + if (n == null || n === "" || !Number.isFinite(Number(n))) return "—"; + return Number(n).toFixed(d == null ? 2 : d); + } + + function fmtPnl(v) { + const n = Number(v); + if (!Number.isFinite(n)) return "—"; + const s = (n >= 0 ? "+" : "") + n.toFixed(2); + return s; + } + + function pnlClass(v) { + const n = Number(v); + if (!Number.isFinite(n) || Math.abs(n) < 1e-6) return ""; + return n > 0 ? "pos" : "neg"; + } + + function setStatus(text) { + if (elStatus) elStatus.textContent = text || ""; + } + + async function apiFetch(url, opts) { + const r = await fetch(url, opts); + if (r.status === 401) { + location.href = "/login?next=" + encodeURIComponent(location.pathname); + throw new Error("未登录"); + } + return r; + } + + function queryListParams() { + const q = new URLSearchParams(); + 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"); + return q.toString(); + } + + function renderExchangeOptions() { + if (!elExchange || !meta) return; + const cur = elExchange.value; + elExchange.innerHTML = ''; + (meta.exchanges || []).forEach(function (ex) { + const opt = document.createElement("option"); + opt.value = ex.key || ""; + opt.textContent = (ex.name || ex.key || "") + " (" + (ex.key || "") + ")"; + elExchange.appendChild(opt); + }); + if (cur) elExchange.value = cur; + } + + function renderList() { + if (!elList) return; + if (!listRows.length) { + elList.innerHTML = '
暂无档案数据。点击「同步交易与 K 线」从四所拉取。
'; + 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 ? "已建档" : "待种子"; + return ( + '" + ); + }) + .join(""); + elList.querySelectorAll(".archive-row").forEach(function (btn) { + btn.addEventListener("click", function () { + openDetail(btn.getAttribute("data-ex"), btn.getAttribute("data-sym")); + }); + }); + } + + function pickAnchorTrade() { + if (!trades.length) return null; + if (selectedTradeId != null) { + const hit = trades.find(function (t) { + return String(t.trade_id || t.id) === String(selectedTradeId); + }); + if (hit) return hit; + } + return trades[0]; + } + + function anchorMsForTrade(tr) { + if (!tr) return null; + const mode = (elViewMode && elViewMode.value) || "hold"; + if (mode === "entry") { + return tr.opened_at_ms || null; + } + return tr.closed_at_ms || tr.opened_at_ms || null; + } + + function destroyChart() { + if (chart) { + chart.remove(); + chart = null; + candleSeries = null; + volumeSeries = null; + } + if (elChartHost) elChartHost.innerHTML = ""; + } + + function ensureChart() { + if (!elChartHost || !window.LightweightCharts) return; + if (chart) return; + const isDark = document.documentElement.getAttribute("data-theme") !== "light"; + chart = LightweightCharts.createChart(elChartHost, { + layout: { + background: { color: isDark ? "#0b0e18" : "#f8f9fc" }, + textColor: isDark ? "#9aa4b8" : "#4a5568", + }, + grid: { + vertLines: { color: isDark ? "#1a2030" : "#e8ecf2" }, + horzLines: { color: isDark ? "#1a2030" : "#e8ecf2" }, + }, + rightPriceScale: { borderColor: isDark ? "#2a3348" : "#d0d7e2" }, + timeScale: { borderColor: isDark ? "#2a3348" : "#d0d7e2", timeVisible: true }, + crosshair: { mode: LightweightCharts.CrosshairMode.Normal }, + }); + candleSeries = chart.addCandlestickSeries({ + upColor: "#22c55e", + downColor: "#ef4444", + borderVisible: false, + wickUpColor: "#22c55e", + wickDownColor: "#ef4444", + }); + volumeSeries = chart.addHistogramSeries({ + color: "#3b82f680", + priceFormat: { type: "volume" }, + priceScaleId: "", + }); + volumeSeries.priceScale().applyOptions({ + scaleMargins: { top: 0.82, bottom: 0 }, + }); + new ResizeObserver(function () { + if (chart && elChartHost) { + chart.applyOptions({ width: elChartHost.clientWidth, height: elChartHost.clientHeight }); + } + }).observe(elChartHost); + chart.applyOptions({ width: elChartHost.clientWidth, height: elChartHost.clientHeight }); + } + + async function loadChart() { + if (!selected) return; + const tr = pickAnchorTrade(); + const anchor = anchorMsForTrade(tr); + const jump = (elJumpAt && elJumpAt.value || "").trim(); + const params = new URLSearchParams({ + exchange_key: selected.exchange_key, + symbol: selected.symbol, + timeframe: timeframe, + mode: (elViewMode && elViewMode.value) || "hold", + bars: "200", + }); + if (jump) params.set("at", jump); + else if (anchor) params.set("anchor_ms", String(anchor)); + setStatus("加载 K 线…"); + const r = await apiFetch("/api/archive/ohlcv?" + params.toString()); + const j = await r.json(); + if (!r.ok) { + setStatus(j.detail || "K 线加载失败"); + return; + } + ensureChart(); + const candles = j.candles || []; + candleSeries.setData( + candles.map(function (c) { + return { time: c.time, open: c.open, high: c.high, low: c.low, close: c.close }; + }) + ); + volumeSeries.setData( + candles.map(function (c) { + return { + time: c.time, + value: c.volume || 0, + color: c.close >= c.open ? "#22c55e55" : "#ef444455", + }; + }) + ); + if (candles.length > 10) { + chart.timeScale().setVisibleLogicalRange({ from: candles.length - 120, to: candles.length + 5 }); + } + setStatus("K 线 " + candles.length + " 根 · " + timeframe); + } + + function renderTrades() { + if (!elTrades) return; + if (!trades.length) { + elTrades.innerHTML = '该币种暂无已平仓记录。
'; + return; + } + elTrades.innerHTML = + '| 平仓 | 方向 | 结果 | 盈亏 | 标签 | 备注 | " + + "
|---|---|---|---|---|---|
| " + + (t.closed_at || "—") + + " | " + + "" + + (t.direction || "—") + + " | " + + "" + + (t.result || "—") + + " | " + + '' + + fmtPnl(t.pnl_amount) + + " | " + + '" + + ' | ' + + " |