feat(hub): add symbol archive with permanent 5m klines
Add /archive page, hub_symbol_archive.db, trade overlay, 4h background sync, and instance /api/hub/trades/archive. Document in hub-symbol-archive-kline.md with cross-links. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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)
|
||||||
@@ -177,6 +177,7 @@ pm2 save
|
|||||||
| [策略交易说明.md](../策略交易说明.md) | 策略总览、策略交易记录页 |
|
| [策略交易说明.md](../策略交易说明.md) | 策略总览、策略交易记录页 |
|
||||||
| [crypto_monitor_gate_bot/趋势回调策略说明.md](../crypto_monitor_gate_bot/趋势回调策略说明.md) | 趋势回调业务细则 |
|
| [crypto_monitor_gate_bot/趋势回调策略说明.md](../crypto_monitor_gate_bot/趋势回调策略说明.md) | 趋势回调业务细则 |
|
||||||
| [manual_trading_hub/使用说明.md](../manual_trading_hub/使用说明.md) | 中控监控与趋势卡布局 |
|
| [manual_trading_hub/使用说明.md](../manual_trading_hub/使用说明.md) | 中控监控与趋势卡布局 |
|
||||||
|
| [hub-symbol-archive-kline.md](./hub-symbol-archive-kline.md) | 币种档案、永久 5m K 线、交易 overlay |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -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")
|
@app.route("/api/hub/trades/today")
|
||||||
@_hub_auth_required
|
@_hub_auth_required
|
||||||
def api_hub_trades_today():
|
def api_hub_trades_today():
|
||||||
|
|||||||
@@ -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 []),
|
||||||
|
}
|
||||||
@@ -176,6 +176,97 @@ def fetch_trades_for_trading_day(
|
|||||||
return out
|
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]:
|
def summarize_trades(trades: list[dict]) -> dict[str, Any]:
|
||||||
"""单笔列表 → 笔数 / 盈亏 / 胜败统计。"""
|
"""单笔列表 → 笔数 / 盈亏 / 胜败统计。"""
|
||||||
total_pnl = 0.0
|
total_pnl = 0.0
|
||||||
|
|||||||
+257
-2
@@ -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_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_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
|
from env_load import load_hub_dotenv
|
||||||
|
|
||||||
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 / 登录页保护
|
# 云服务器 + 域名反代时设为 true:不做 IP 限制,仅靠 HUB_PASSWORD / 登录页保护
|
||||||
HUB_ALLOW_PUBLIC = _allow_pub_raw in ("1", "true", "yes", "on")
|
HUB_ALLOW_PUBLIC = _allow_pub_raw in ("1", "true", "yes", "on")
|
||||||
DIR = Path(__file__).resolve().parent
|
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_AGENT_TIMEOUT = float(os.getenv("HUB_AGENT_TIMEOUT", "8"))
|
||||||
HUB_FLASK_TIMEOUT = float(os.getenv("HUB_FLASK_TIMEOUT", "10"))
|
HUB_FLASK_TIMEOUT = float(os.getenv("HUB_FLASK_TIMEOUT", "10"))
|
||||||
HUB_BOARD_TIMEOUT = float(os.getenv("HUB_BOARD_TIMEOUT", "45"))
|
HUB_BOARD_TIMEOUT = float(os.getenv("HUB_BOARD_TIMEOUT", "45"))
|
||||||
@@ -221,13 +238,91 @@ def _schedule_board_refresh() -> None:
|
|||||||
board_store.request_refresh()
|
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
|
@asynccontextmanager
|
||||||
async def _hub_lifespan(_app: FastAPI):
|
async def _hub_lifespan(_app: FastAPI):
|
||||||
|
global _archive_sync_stop, _archive_sync_task
|
||||||
await board_store.start(_run_board_aggregate)
|
await board_store.start(_run_board_aggregate)
|
||||||
await chart_poll_store.start(_run_chart_poll)
|
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:
|
try:
|
||||||
yield
|
yield
|
||||||
finally:
|
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 chart_poll_store.stop()
|
||||||
await board_store.stop()
|
await board_store.stop()
|
||||||
|
|
||||||
@@ -377,6 +472,7 @@ def root_redirect():
|
|||||||
|
|
||||||
@app.get("/monitor")
|
@app.get("/monitor")
|
||||||
@app.get("/market")
|
@app.get("/market")
|
||||||
|
@app.get("/archive")
|
||||||
@app.get("/ai")
|
@app.get("/ai")
|
||||||
@app.get("/settings")
|
@app.get("/settings")
|
||||||
def shell_pages():
|
def shell_pages():
|
||||||
@@ -436,6 +532,30 @@ def _find_exchange_by_key(exchange_key: str) -> dict | None:
|
|||||||
return 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(
|
def _fetch_instance_ohlcv_sync(
|
||||||
ex: dict,
|
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")
|
@app.get("/api/ping")
|
||||||
def api_ping():
|
def api_ping():
|
||||||
return {
|
return {
|
||||||
@@ -1510,7 +1765,7 @@ def api_ping():
|
|||||||
"service": "manual-trading-hub",
|
"service": "manual-trading-hub",
|
||||||
"build": HUB_BUILD,
|
"build": HUB_BUILD,
|
||||||
"trade_ui": False,
|
"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_poll_interval_sec": HUB_BOARD_POLL_INTERVAL,
|
||||||
"board_version": board_store.version,
|
"board_version": board_store.version,
|
||||||
"board_aggregating": board_store.aggregating,
|
"board_aggregating": board_store.aggregating,
|
||||||
|
|||||||
@@ -3852,3 +3852,193 @@ body.hub-page-ai #page-ai {
|
|||||||
opacity: 0.65;
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -624,6 +624,7 @@
|
|||||||
function currentPage() {
|
function currentPage() {
|
||||||
const p = window.location.pathname.replace(/\/$/, "") || "/monitor";
|
const p = window.location.pathname.replace(/\/$/, "") || "/monitor";
|
||||||
if (p.includes("settings")) return "settings";
|
if (p.includes("settings")) return "settings";
|
||||||
|
if (p.includes("archive")) return "archive";
|
||||||
if (p.includes("market")) return "market";
|
if (p.includes("market")) return "market";
|
||||||
if (p.includes("/ai")) return "ai";
|
if (p.includes("/ai")) return "ai";
|
||||||
return "monitor";
|
return "monitor";
|
||||||
@@ -631,6 +632,7 @@
|
|||||||
|
|
||||||
function pageElementId(page) {
|
function pageElementId(page) {
|
||||||
if (page === "settings") return "page-settings";
|
if (page === "settings") return "page-settings";
|
||||||
|
if (page === "archive") return "page-archive";
|
||||||
if (page === "market") return "page-market";
|
if (page === "market") return "page-market";
|
||||||
if (page === "ai") return "page-ai";
|
if (page === "ai") return "page-ai";
|
||||||
return "page-monitor";
|
return "page-monitor";
|
||||||
@@ -654,6 +656,11 @@
|
|||||||
else stopMonitorPoll();
|
else stopMonitorPoll();
|
||||||
if (page === "settings") loadSettingsUI();
|
if (page === "settings") loadSettingsUI();
|
||||||
if (page === "ai") loadAiPage();
|
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) {
|
if (page === "market" && window.hubMarketChart) {
|
||||||
window.hubMarketChart.init();
|
window.hubMarketChart.init();
|
||||||
} else if (window.hubMarketChart) {
|
} else if (window.hubMarketChart) {
|
||||||
|
|||||||
@@ -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 = '<option value="">全部</option>';
|
||||||
|
(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 = '<p class="archive-empty">暂无档案数据。点击「同步交易与 K 线」从四所拉取。</p>';
|
||||||
|
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 (
|
||||||
|
'<button type="button" class="archive-row' +
|
||||||
|
active +
|
||||||
|
'" data-ex="' +
|
||||||
|
row.exchange_key +
|
||||||
|
'" data-sym="' +
|
||||||
|
row.symbol +
|
||||||
|
'">' +
|
||||||
|
'<span class="archive-row-sym">' +
|
||||||
|
row.symbol +
|
||||||
|
"</span>" +
|
||||||
|
'<span class="archive-row-ex">' +
|
||||||
|
row.exchange_key +
|
||||||
|
"</span>" +
|
||||||
|
'<span class="archive-row-stat">' +
|
||||||
|
row.trade_count +
|
||||||
|
" 笔 · " +
|
||||||
|
fmtPnl(row.total_pnl) +
|
||||||
|
" U</span>" +
|
||||||
|
'<span class="archive-row-meta">' +
|
||||||
|
seed +
|
||||||
|
"</span>" +
|
||||||
|
"</button>"
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.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 = '<p class="archive-empty">该币种暂无已平仓记录。</p>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
elTrades.innerHTML =
|
||||||
|
'<table class="archive-trades-table"><thead><tr>' +
|
||||||
|
"<th>平仓</th><th>方向</th><th>结果</th><th>盈亏</th><th>标签</th><th>备注</th>" +
|
||||||
|
"</tr></thead><tbody>" +
|
||||||
|
trades
|
||||||
|
.map(function (t) {
|
||||||
|
const tid = t.trade_id || t.id;
|
||||||
|
const active = String(tid) === String(selectedTradeId) ? " is-active" : "";
|
||||||
|
const tag = t.behavior_tag || "";
|
||||||
|
return (
|
||||||
|
'<tr class="archive-trade-row' +
|
||||||
|
active +
|
||||||
|
'" data-id="' +
|
||||||
|
tid +
|
||||||
|
'">' +
|
||||||
|
"<td>" +
|
||||||
|
(t.closed_at || "—") +
|
||||||
|
"</td>" +
|
||||||
|
"<td>" +
|
||||||
|
(t.direction || "—") +
|
||||||
|
"</td>" +
|
||||||
|
"<td>" +
|
||||||
|
(t.result || "—") +
|
||||||
|
"</td>" +
|
||||||
|
'<td class="' +
|
||||||
|
pnlClass(t.pnl_amount) +
|
||||||
|
'">' +
|
||||||
|
fmtPnl(t.pnl_amount) +
|
||||||
|
"</td>" +
|
||||||
|
'<td><select class="archive-tag-select" data-id="' +
|
||||||
|
tid +
|
||||||
|
'">' +
|
||||||
|
'<option value=""' +
|
||||||
|
(tag === "" ? " selected" : "") +
|
||||||
|
">—</option>" +
|
||||||
|
'<option value="sick"' +
|
||||||
|
(tag === "sick" ? " selected" : "") +
|
||||||
|
">犯病</option>" +
|
||||||
|
'<option value="emotion"' +
|
||||||
|
(tag === "emotion" ? " selected" : "") +
|
||||||
|
">情绪</option>" +
|
||||||
|
"</select></td>" +
|
||||||
|
'<td><input class="archive-note-input" data-id="' +
|
||||||
|
tid +
|
||||||
|
'" value="' +
|
||||||
|
String(t.note || "").replace(/"/g, """) +
|
||||||
|
'" placeholder="备注" /></td>' +
|
||||||
|
"</tr>"
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.join("") +
|
||||||
|
"</tbody></table>";
|
||||||
|
|
||||||
|
elTrades.querySelectorAll(".archive-trade-row").forEach(function (row) {
|
||||||
|
row.addEventListener("click", function (ev) {
|
||||||
|
if (ev.target.closest("select") || ev.target.closest("input")) return;
|
||||||
|
selectedTradeId = row.getAttribute("data-id");
|
||||||
|
renderTrades();
|
||||||
|
loadChart();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
elTrades.querySelectorAll(".archive-tag-select").forEach(function (sel) {
|
||||||
|
sel.addEventListener("change", function () {
|
||||||
|
saveOverlay(sel.getAttribute("data-id"), 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveOverlay(tradeId, tag, note) {
|
||||||
|
if (!selected) 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, {
|
||||||
|
method: "PATCH",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
const tr = trades.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());
|
||||||
|
const j = await r.json();
|
||||||
|
listRows = j.rows || [];
|
||||||
|
renderList();
|
||||||
|
setStatus("共 " + listRows.length + " 个币种档案 · " + new Date().toLocaleTimeString());
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadMeta() {
|
||||||
|
const r = await apiFetch("/api/archive/meta");
|
||||||
|
meta = await r.json();
|
||||||
|
timeframe = (meta && meta.default_timeframe) || "15m";
|
||||||
|
renderExchangeOptions();
|
||||||
|
if (elTfTabs) {
|
||||||
|
elTfTabs.querySelectorAll(".archive-tf-btn").forEach(function (btn) {
|
||||||
|
btn.classList.toggle("is-active", btn.getAttribute("data-tf") === timeframe);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function syncAll() {
|
||||||
|
setStatus("同步中(可能需数分钟)…");
|
||||||
|
elBtnSync && (elBtnSync.disabled = true);
|
||||||
|
try {
|
||||||
|
const r = await apiFetch("/api/archive/sync", { method: "POST" });
|
||||||
|
const j = await r.json();
|
||||||
|
const okN = (j.results || []).filter(function (x) {
|
||||||
|
return x.ok !== false;
|
||||||
|
}).length;
|
||||||
|
setStatus("同步完成 · " + okN + "/" + (j.exchanges || 0) + " 所");
|
||||||
|
await loadList();
|
||||||
|
if (selected) await openDetail(selected.exchange_key, selected.symbol);
|
||||||
|
} catch (e) {
|
||||||
|
setStatus(String(e));
|
||||||
|
} finally {
|
||||||
|
elBtnSync && (elBtnSync.disabled = false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function bindEvents() {
|
||||||
|
if (elBtnRefresh) elBtnRefresh.addEventListener("click", loadList);
|
||||||
|
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 (elTfTabs) {
|
||||||
|
elTfTabs.addEventListener("click", function (ev) {
|
||||||
|
const btn = ev.target.closest(".archive-tf-btn");
|
||||||
|
if (!btn) return;
|
||||||
|
timeframe = btn.getAttribute("data-tf") || "15m";
|
||||||
|
elTfTabs.querySelectorAll(".archive-tf-btn").forEach(function (b) {
|
||||||
|
b.classList.toggle("is-active", b === btn);
|
||||||
|
});
|
||||||
|
loadChart();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (elViewMode) elViewMode.addEventListener("change", loadChart);
|
||||||
|
if (elBtnReloadChart) elBtnReloadChart.addEventListener("click", loadChart);
|
||||||
|
if (elBtnJump) {
|
||||||
|
elBtnJump.addEventListener("click", function () {
|
||||||
|
loadChart();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function init() {
|
||||||
|
if (!document.getElementById("page-archive") || document.getElementById("page-archive").classList.contains("hidden")) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!inited) {
|
||||||
|
bindEvents();
|
||||||
|
inited = true;
|
||||||
|
}
|
||||||
|
await loadMeta();
|
||||||
|
await loadList();
|
||||||
|
}
|
||||||
|
|
||||||
|
function destroy() {
|
||||||
|
destroyChart();
|
||||||
|
}
|
||||||
|
|
||||||
|
window.hubArchivePage = { init: init, destroy: destroy };
|
||||||
|
})();
|
||||||
@@ -15,7 +15,7 @@
|
|||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||||
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600&family=Orbitron:wght@500;600;700&display=swap" rel="stylesheet" media="print" onload="this.media='all'" />
|
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600&family=Orbitron:wght@500;600;700&display=swap" rel="stylesheet" media="print" onload="this.media='all'" />
|
||||||
<noscript><link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600&family=Orbitron:wght@500;600;700&display=swap" rel="stylesheet" /></noscript>
|
<noscript><link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600&family=Orbitron:wght@500;600;700&display=swap" rel="stylesheet" /></noscript>
|
||||||
<link rel="stylesheet" href="/assets/app.css?v=20260607-hub-board-v1" />
|
<link rel="stylesheet" href="/assets/app.css?v=20260607-hub-archive-v1" />
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div class="app-bg" aria-hidden="true"></div>
|
<div class="app-bg" aria-hidden="true"></div>
|
||||||
@@ -46,6 +46,7 @@
|
|||||||
<nav class="top-nav">
|
<nav class="top-nav">
|
||||||
<a href="/monitor" id="nav-monitor">监控区</a>
|
<a href="/monitor" id="nav-monitor">监控区</a>
|
||||||
<a href="/market" id="nav-market">行情区</a>
|
<a href="/market" id="nav-market">行情区</a>
|
||||||
|
<a href="/archive" id="nav-archive">币种档案</a>
|
||||||
<a href="/ai" id="nav-ai">AI 教练</a>
|
<a href="/ai" id="nav-ai">AI 教练</a>
|
||||||
<a href="/settings" id="nav-settings">系统设置</a>
|
<a href="/settings" id="nav-settings">系统设置</a>
|
||||||
</nav>
|
</nav>
|
||||||
@@ -185,6 +186,60 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div id="page-archive" class="page hidden">
|
||||||
|
<div class="page-head">
|
||||||
|
<h1><span class="head-tag">ARC</span> 币种档案</h1>
|
||||||
|
<p class="page-desc">一所一币一行 · 交易时间线 · 永久 5m K 线(15m/1h/4h 聚合)</p>
|
||||||
|
</div>
|
||||||
|
<div class="archive-toolbar toolbar">
|
||||||
|
<label class="archive-field">
|
||||||
|
<span>交易所</span>
|
||||||
|
<select id="archive-exchange"><option value="">全部</option></select>
|
||||||
|
</label>
|
||||||
|
<label class="chk-label"><input type="checkbox" id="archive-filter-profit" /> 有盈利单</label>
|
||||||
|
<label class="chk-label"><input type="checkbox" id="archive-filter-loss" /> 有亏损单</label>
|
||||||
|
<label class="chk-label"><input type="checkbox" id="archive-filter-sick" /> 犯病</label>
|
||||||
|
<label class="chk-label"><input type="checkbox" id="archive-filter-emotion" /> 情绪</label>
|
||||||
|
<button type="button" id="archive-btn-refresh" class="primary">刷新列表</button>
|
||||||
|
<button type="button" id="archive-btn-sync" class="ghost">同步交易与 K 线</button>
|
||||||
|
<span id="archive-status" class="toolbar-meta"></span>
|
||||||
|
</div>
|
||||||
|
<div class="archive-layout">
|
||||||
|
<section class="archive-list-panel">
|
||||||
|
<div id="archive-list" class="archive-list" role="list"></div>
|
||||||
|
</section>
|
||||||
|
<section class="archive-detail-panel hidden" id="archive-detail-panel">
|
||||||
|
<div class="archive-detail-head">
|
||||||
|
<h2 id="archive-detail-title">—</h2>
|
||||||
|
<span id="archive-detail-stats" class="archive-detail-stats"></span>
|
||||||
|
</div>
|
||||||
|
<div class="archive-chart-toolbar toolbar">
|
||||||
|
<div class="archive-tf-tabs" id="archive-tf-tabs" role="tablist">
|
||||||
|
<button type="button" class="archive-tf-btn" data-tf="5m">5m</button>
|
||||||
|
<button type="button" class="archive-tf-btn is-active" data-tf="15m">15m</button>
|
||||||
|
<button type="button" class="archive-tf-btn" data-tf="1h">1h</button>
|
||||||
|
<button type="button" class="archive-tf-btn" data-tf="4h">4h</button>
|
||||||
|
</div>
|
||||||
|
<label class="archive-field">
|
||||||
|
<span>视窗</span>
|
||||||
|
<select id="archive-view-mode">
|
||||||
|
<option value="hold">持仓过程(锚平仓)</option>
|
||||||
|
<option value="entry">进场决策(锚开仓)</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label class="archive-field">
|
||||||
|
<span>跳转时间</span>
|
||||||
|
<input id="archive-jump-at" type="text" placeholder="2026-06-07 14:30" autocomplete="off" />
|
||||||
|
</label>
|
||||||
|
<button type="button" id="archive-btn-jump" class="ghost">跳转</button>
|
||||||
|
<button type="button" id="archive-btn-reload-chart" class="primary">重载图表</button>
|
||||||
|
</div>
|
||||||
|
<div id="archive-chart" class="archive-chart-host"></div>
|
||||||
|
<div id="archive-trades" class="archive-trades"></div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div id="instance-frame-shell" class="instance-frame-shell hidden" aria-hidden="true">
|
<div id="instance-frame-shell" class="instance-frame-shell hidden" aria-hidden="true">
|
||||||
<div class="instance-frame-toolbar">
|
<div class="instance-frame-toolbar">
|
||||||
<button type="button" id="instance-frame-back" class="ghost">← 返回监控</button>
|
<button type="button" id="instance-frame-back" class="ghost">← 返回监控</button>
|
||||||
@@ -294,7 +349,8 @@
|
|||||||
<div id="toast"></div>
|
<div id="toast"></div>
|
||||||
<script src="https://unpkg.com/lightweight-charts@4.2.0/dist/lightweight-charts.standalone.production.js"></script>
|
<script src="https://unpkg.com/lightweight-charts@4.2.0/dist/lightweight-charts.standalone.production.js"></script>
|
||||||
<script src="/assets/chart.js?v=20260604-upnl-contracts"></script>
|
<script src="/assets/chart.js?v=20260604-upnl-contracts"></script>
|
||||||
|
<script src="/assets/archive.js?v=20260607-hub-archive-v1"></script>
|
||||||
<script src="/assets/ai_review_render.js?v=2"></script>
|
<script src="/assets/ai_review_render.js?v=2"></script>
|
||||||
<script src="/assets/app.js?v=20260607-hub-board-v1"></script>
|
<script src="/assets/app.js?v=20260607-hub-archive-v1"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# 多账户交易中控 — 使用说明
|
# 多账户交易中控 — 使用说明
|
||||||
|
|
||||||
本文档说明 **manual_trading_hub** 的架构、启动方式、界面操作与故障排查。中控聚合四所 **持仓/条件单/余额/关键位/趋势计划监控 + 撤单/紧急全平**,并提供 **行情区 K 线**;**人工下单、关键位、策略交易(趋势回调 / 顺势加仓)、交易复盘** 均在各实例网页操作(点监控卡片 **「实例」**)。行情区细则见 **[行情区说明.md](./行情区说明.md)**。
|
本文档说明 **manual_trading_hub** 的架构、启动方式、界面操作与故障排查。中控聚合四所 **持仓/条件单/余额/关键位/趋势计划监控 + 撤单/紧急全平**,并提供 **行情区 K 线** 与 **币种档案(永久 K 线复盘)**;**人工下单、关键位、策略交易(趋势回调 / 顺势加仓)、交易复盘** 均在各实例网页操作(点监控卡片 **「实例」**)。行情区细则见 **[行情区说明.md](./行情区说明.md)**;币种档案见 **[docs/hub-symbol-archive-kline.md](../docs/hub-symbol-archive-kline.md)**。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -10,6 +10,7 @@
|
|||||||
浏览器
|
浏览器
|
||||||
├─ /monitor 监控区(持仓、关键位、趋势计划、全平)
|
├─ /monitor 监控区(持仓、关键位、趋势计划、全平)
|
||||||
├─ /market 行情区(K 线、技术指标、持仓价格线)
|
├─ /market 行情区(K 线、技术指标、持仓价格线)
|
||||||
|
├─ /archive 币种档案(交易时间线 + 永久 5m K 线)
|
||||||
├─ /ai AI 教练(四户今日总结 + 聊天)
|
├─ /ai AI 教练(四户今日总结 + 聊天)
|
||||||
└─ /settings 系统设置(hub_settings.json)
|
└─ /settings 系统设置(hub_settings.json)
|
||||||
|
|
||||||
@@ -127,6 +128,7 @@ pm2 save
|
|||||||
|
|
||||||
- 监控区:`http://127.0.0.1:5100/monitor`
|
- 监控区:`http://127.0.0.1:5100/monitor`
|
||||||
- 行情区:`http://127.0.0.1:5100/market`
|
- 行情区:`http://127.0.0.1:5100/market`
|
||||||
|
- 币种档案:`http://127.0.0.1:5100/archive`
|
||||||
- 系统设置:`http://127.0.0.1:5100/settings`
|
- 系统设置:`http://127.0.0.1:5100/settings`
|
||||||
|
|
||||||
验收:
|
验收:
|
||||||
@@ -178,6 +180,19 @@ Chrome **桌面快捷方式**图标来自站点 `favicon` / `manifest`(已配
|
|||||||
|
|
||||||
数据经中控 → 各实例 `GET /api/hub/ohlcv`(`hub_ohlcv_lib`)。升级 hub 与四实例 Flask 后请 **强刷浏览器**;异常 K 线可点 **强制刷新**。
|
数据经中控 → 各实例 `GET /api/hub/ohlcv`(`hub_ohlcv_lib`)。升级 hub 与四实例 Flask 后请 **强刷浏览器**;异常 K 线可点 **强制刷新**。
|
||||||
|
|
||||||
|
### 4.2.1 币种档案 `/archive`
|
||||||
|
|
||||||
|
| 功能 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| **列表** | 一所一币一行;数据来自四所 `trade_records`(`GET /api/hub/trades/archive`) |
|
||||||
|
| **筛选** | 交易所、有盈利单、有亏损单、犯病/情绪标签(中控 overlay,不上传图片) |
|
||||||
|
| **明细** | 交易时间线;可编辑备注与犯病/情绪标签 |
|
||||||
|
| **K 线** | 独立库 `data/hub_symbol_archive.db`;仅存 **5m** 真源,**15m/1h/4h** 聚合;默认 Tab **15m** |
|
||||||
|
| **建档** | 最早开仓向前 **30 天** 5m 种子;之后每 **4h** 增量(Hub 后台 + 可点「同步」) |
|
||||||
|
| **视窗** | **持仓过程**(锚平仓)/ **进场决策**(锚开仓);支持时间输入跳转 |
|
||||||
|
|
||||||
|
与行情区 `hub_kline.db`(15 天滚动)**分离**,建档起 **只增不删**。细则见 **[docs/hub-symbol-archive-kline.md](../docs/hub-symbol-archive-kline.md)**。
|
||||||
|
|
||||||
### 4.3 AI 教练 `/ai`
|
### 4.3 AI 教练 `/ai`
|
||||||
|
|
||||||
| 功能 | 说明 |
|
| 功能 | 说明 |
|
||||||
@@ -303,6 +318,12 @@ PM2:仓库 `ecosystem.config.cjs` 默认只有四 agent;第五户需自行 `
|
|||||||
| GET | `/api/ping` | 版本与健康检查(**免登录**) |
|
| GET | `/api/ping` | 版本与健康检查(**免登录**) |
|
||||||
| GET | `/api/chart/meta` | 行情区:交易所、周期、limit |
|
| GET | `/api/chart/meta` | 行情区:交易所、周期、limit |
|
||||||
| GET | `/api/chart/ohlcv` | 行情区 K 线(`exchange_key`、`symbol`、`timeframe`、可选 `refresh=1`) |
|
| GET | `/api/chart/ohlcv` | 行情区 K 线(`exchange_key`、`symbol`、`timeframe`、可选 `refresh=1`) |
|
||||||
|
| GET | `/api/archive/meta` | 币种档案:周期、同步间隔 |
|
||||||
|
| GET | `/api/archive/list` | 币种列表(筛选 query) |
|
||||||
|
| GET | `/api/archive/detail` | 单币种交易时间线 |
|
||||||
|
| GET | `/api/archive/ohlcv` | 档案 K 线视窗 |
|
||||||
|
| PATCH | `/api/archive/trade/{exchange_key}/{trade_id}` | 犯病/情绪标签与备注 |
|
||||||
|
| POST | `/api/archive/sync` | 立即同步四所交易与 K 线 |
|
||||||
|
|
||||||
已移除的 `/api/trade/*` 若被旧缓存页面请求,返回 **410** 并提示前往各实例网页。
|
已移除的 `/api/trade/*` 若被旧缓存页面请求,返回 **410** 并提示前往各实例网页。
|
||||||
|
|
||||||
@@ -313,6 +334,7 @@ PM2:仓库 `ecosystem.config.cjs` 默认只有四 agent;第五户需自行 `
|
|||||||
| `/api/hub/ping` | 连通与能力 |
|
| `/api/hub/ping` | 连通与能力 |
|
||||||
| `/api/hub/monitor` | 关键位、机器人单、趋势计划 |
|
| `/api/hub/monitor` | 关键位、机器人单、趋势计划 |
|
||||||
| `/api/hub/ohlcv` | 行情区 OHLCV(ccxt 拉取,供中控聚合缓存) |
|
| `/api/hub/ohlcv` | 行情区 OHLCV(ccxt 拉取,供中控聚合缓存) |
|
||||||
|
| `/api/hub/trades/archive` | 币种档案:近 N 天已平仓(`days` / `limit`) |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -334,6 +356,10 @@ PM2:仓库 `ecosystem.config.cjs` 默认只有四 agent;第五户需自行 `
|
|||||||
| `HUB_SESSION_DAYS` | `7` | 登录保持天数 |
|
| `HUB_SESSION_DAYS` | `7` | 登录保持天数 |
|
||||||
| `HUB_KLINE_RETENTION_DAYS` | `15` | 行情区 K 线库保留天数 |
|
| `HUB_KLINE_RETENTION_DAYS` | `15` | 行情区 K 线库保留天数 |
|
||||||
| `HUB_KLINE_DB_PATH` | `data/hub_kline.db` | K 线 SQLite 路径 |
|
| `HUB_KLINE_DB_PATH` | `data/hub_kline.db` | K 线 SQLite 路径 |
|
||||||
|
| `HUB_ARCHIVE_DB_PATH` | `data/hub_symbol_archive.db` | 币种档案永久 K 线库 |
|
||||||
|
| `HUB_ARCHIVE_SYNC_INTERVAL_SEC` | `14400` | 档案 K 线后台同步间隔(秒) |
|
||||||
|
| `HUB_ARCHIVE_TRADE_DAYS` | `365` | 同步交易记录回看天数 |
|
||||||
|
| `HUB_ARCHIVE_TRADE_LIMIT` | `2000` | 单所同步交易条数上限 |
|
||||||
|
|
||||||
### 子代理 agent.py
|
### 子代理 agent.py
|
||||||
|
|
||||||
@@ -443,6 +469,7 @@ pm2 save && pm2 startup
|
|||||||
|------|------|
|
|------|------|
|
||||||
| [使用说明.md](./使用说明.md) | 本文 |
|
| [使用说明.md](./使用说明.md) | 本文 |
|
||||||
| [行情区说明.md](./行情区说明.md) | K 线周期、缓存、快捷键、API |
|
| [行情区说明.md](./行情区说明.md) | K 线周期、缓存、快捷键、API |
|
||||||
|
| [docs/hub-symbol-archive-kline.md](../docs/hub-symbol-archive-kline.md) | 币种档案、永久 5m、建档与同步 |
|
||||||
| [部署文档.md](./部署文档.md) | Ubuntu / PM2 / 反代 |
|
| [部署文档.md](./部署文档.md) | Ubuntu / PM2 / 反代 |
|
||||||
| [常见问题.md](./常见问题.md) | 故障实录与排障 |
|
| [常见问题.md](./常见问题.md) | 故障实录与排障 |
|
||||||
| [README.md](./README.md) | 速览 |
|
| [README.md](./README.md) | 速览 |
|
||||||
|
|||||||
@@ -0,0 +1,118 @@
|
|||||||
|
"""币种档案库:5m 聚合与视窗计算。"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import tempfile
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from hub_ohlcv_lib import aggregate_ohlcv_bars
|
||||||
|
from hub_symbol_archive_lib import (
|
||||||
|
init_db,
|
||||||
|
resolve_archive_chart,
|
||||||
|
upsert_bars_5m,
|
||||||
|
upsert_trade_overlay,
|
||||||
|
list_symbol_rows,
|
||||||
|
upsert_trades_cache,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _seed_5m_bars(db: Path, start_ms: int, count: int, step: int = 300_000) -> None:
|
||||||
|
bars = []
|
||||||
|
price = 1.0
|
||||||
|
for i in range(count):
|
||||||
|
o = start_ms + i * step
|
||||||
|
price += 0.001
|
||||||
|
bars.append(
|
||||||
|
{
|
||||||
|
"open_time_ms": o,
|
||||||
|
"open": price,
|
||||||
|
"high": price + 0.002,
|
||||||
|
"low": price - 0.001,
|
||||||
|
"close": price + 0.001,
|
||||||
|
"volume": 100 + i,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
upsert_bars_5m("gate", "ONDO", bars, db_path=db)
|
||||||
|
|
||||||
|
|
||||||
|
def test_aggregate_15m_from_5m():
|
||||||
|
start = 1_700_000_000_000
|
||||||
|
bars = []
|
||||||
|
for i in range(6):
|
||||||
|
t = start + i * 300_000
|
||||||
|
bars.append(
|
||||||
|
{
|
||||||
|
"open_time_ms": t,
|
||||||
|
"open": 1.0,
|
||||||
|
"high": 1.1,
|
||||||
|
"low": 0.9,
|
||||||
|
"close": 1.05,
|
||||||
|
"volume": 10,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
agg = aggregate_ohlcv_bars(bars, "15m")
|
||||||
|
assert len(agg) >= 1
|
||||||
|
assert agg[-1]["close"] == bars[-1]["close"]
|
||||||
|
assert agg[0]["open_time_ms"] <= agg[1]["open_time_ms"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_resolve_archive_chart_15m():
|
||||||
|
with tempfile.TemporaryDirectory() as td:
|
||||||
|
db = Path(td) / "archive.db"
|
||||||
|
init_db(db)
|
||||||
|
anchor = 1_700_000_000_000
|
||||||
|
_seed_5m_bars(db, anchor - 50 * 300_000, 120)
|
||||||
|
out = resolve_archive_chart(
|
||||||
|
"gate",
|
||||||
|
"ONDO",
|
||||||
|
"15m",
|
||||||
|
anchor_ms=anchor,
|
||||||
|
mode="hold",
|
||||||
|
bars=40,
|
||||||
|
db_path=db,
|
||||||
|
)
|
||||||
|
assert out["ok"] is True
|
||||||
|
assert out["timeframe"] == "15m"
|
||||||
|
assert len(out["candles"]) >= 10
|
||||||
|
|
||||||
|
|
||||||
|
def test_list_with_overlay_filters():
|
||||||
|
with tempfile.TemporaryDirectory() as td:
|
||||||
|
db = Path(td) / "archive.db"
|
||||||
|
init_db(db)
|
||||||
|
upsert_trades_cache(
|
||||||
|
"gate",
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"symbol": "ONDO",
|
||||||
|
"direction": "long",
|
||||||
|
"result": "止盈",
|
||||||
|
"pnl_amount": 12.5,
|
||||||
|
"opened_at": "2026-01-01 10:00:00",
|
||||||
|
"closed_at": "2026-01-01 12:00:00",
|
||||||
|
"opened_at_ms": 1_700_000_000_000,
|
||||||
|
"closed_at_ms": 1_700_007_200_000,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 2,
|
||||||
|
"symbol": "ONDO",
|
||||||
|
"direction": "short",
|
||||||
|
"result": "止损",
|
||||||
|
"pnl_amount": -3.2,
|
||||||
|
"opened_at": "2026-01-02 10:00:00",
|
||||||
|
"closed_at": "2026-01-02 11:00:00",
|
||||||
|
"opened_at_ms": 1_700_086_400_000,
|
||||||
|
"closed_at_ms": 1_700_090_000_000,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
db_path=db,
|
||||||
|
)
|
||||||
|
upsert_trade_overlay("gate", 2, behavior_tag="sick", note="追高", db_path=db)
|
||||||
|
rows = list_symbol_rows(db_path=db)
|
||||||
|
assert len(rows) == 1
|
||||||
|
assert rows[0]["trade_count"] == 2
|
||||||
|
sick_only = list_symbol_rows(filter_sick=True, db_path=db)
|
||||||
|
assert len(sick_only) == 1
|
||||||
|
profit_only = list_symbol_rows(filter_profit=True, db_path=db)
|
||||||
|
assert len(profit_only) == 1
|
||||||
@@ -146,6 +146,7 @@ pm2 save
|
|||||||
| [AI复盘与模型配置说明.md](./AI复盘与模型配置说明.md) | 复盘页 AI(与策略无关) |
|
| [AI复盘与模型配置说明.md](./AI复盘与模型配置说明.md) | 复盘页 AI(与策略无关) |
|
||||||
| [manual_trading_hub/使用说明.md](./manual_trading_hub/使用说明.md) | 中控监控、全屏趋势卡两列布局 |
|
| [manual_trading_hub/使用说明.md](./manual_trading_hub/使用说明.md) | 中控监控、全屏趋势卡两列布局 |
|
||||||
| [docs/trend-hub-close-and-trade-records.md](./docs/trend-hub-close-and-trade-records.md) | 中控平仓、交易记录写入、补仓展示统一、漏记补录 |
|
| [docs/trend-hub-close-and-trade-records.md](./docs/trend-hub-close-and-trade-records.md) | 中控平仓、交易记录写入、补仓展示统一、漏记补录 |
|
||||||
|
| [docs/hub-symbol-archive-kline.md](./docs/hub-symbol-archive-kline.md) | 币种档案、永久 5m K 线、建档与 4h 增量同步 |
|
||||||
| [docs/ubuntu-server.md](./docs/ubuntu-server.md) | Ubuntu / root /opt / PM2 部署 |
|
| [docs/ubuntu-server.md](./docs/ubuntu-server.md) | Ubuntu / root /opt / PM2 部署 |
|
||||||
| [fib_key_monitor_lib.py](./fib_key_monitor_lib.py) | 斐波公式共用 |
|
| [fib_key_monitor_lib.py](./fib_key_monitor_lib.py) | 斐波公式共用 |
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user