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:
dekun
2026-06-07 22:51:48 +08:00
parent 32b66fc343
commit 6a56928d59
13 changed files with 2174 additions and 5 deletions
+257 -2
View File
@@ -16,6 +16,21 @@ if str(_REPO_ROOT) not in sys.path:
from hub_kline_store import format_ohlcv_detail, resolve_chart_bars, retention_days
from hub_ohlcv_lib import CHART_TIMEFRAME_ORDER, CHART_TIMEFRAMES, bar_limit_for_timeframe
from hub_symbol_archive_lib import (
ARCHIVE_DEFAULT_TIMEFRAME,
ARCHIVE_SEED_LOOKBACK_DAYS,
ARCHIVE_SYNC_INTERVAL_SEC,
ARCHIVE_TIMEFRAMES,
ARCHIVE_TRADE_DAYS,
ARCHIVE_TRADE_LIMIT,
ARCHIVE_VISIBLE_BARS_DEFAULT,
init_db as init_archive_db,
list_symbol_rows,
load_symbol_trades,
resolve_archive_chart,
sync_exchange_symbol_archives,
upsert_trade_overlay,
)
from env_load import load_hub_dotenv
load_hub_dotenv()
@@ -77,7 +92,9 @@ _allow_pub_raw = (os.getenv("HUB_ALLOW_PUBLIC") or "").strip().lower()
# 云服务器 + 域名反代时设为 true:不做 IP 限制,仅靠 HUB_PASSWORD / 登录页保护
HUB_ALLOW_PUBLIC = _allow_pub_raw in ("1", "true", "yes", "on")
DIR = Path(__file__).resolve().parent
HUB_BUILD = "20260606-hub-ai"
HUB_BUILD = "20260607-hub-archive"
_archive_sync_stop: asyncio.Event | None = None
_archive_sync_task: asyncio.Task | None = None
HUB_AGENT_TIMEOUT = float(os.getenv("HUB_AGENT_TIMEOUT", "8"))
HUB_FLASK_TIMEOUT = float(os.getenv("HUB_FLASK_TIMEOUT", "10"))
HUB_BOARD_TIMEOUT = float(os.getenv("HUB_BOARD_TIMEOUT", "45"))
@@ -221,13 +238,91 @@ def _schedule_board_refresh() -> None:
board_store.request_refresh()
async def _run_archive_sync_once() -> dict:
init_archive_db()
settings = load_settings()
targets = enabled_exchanges(settings)
results: list[dict] = []
for ex in targets:
ex_key = str(ex.get("key") or "").strip().lower()
if not ex_key:
continue
trades_resp = await asyncio.to_thread(
_fetch_instance_trades_archive_sync,
ex,
days=ARCHIVE_TRADE_DAYS,
limit=ARCHIVE_TRADE_LIMIT,
)
if not trades_resp.get("ok"):
results.append(
{
"exchange_key": ex_key,
"ok": False,
"msg": trades_resp.get("msg") or trades_resp.get("error") or "拉取交易失败",
}
)
continue
trades = trades_resp.get("trades") or []
for t in trades:
if isinstance(t, dict):
t.setdefault("exchange_key", ex_key)
def remote_fetch(**kwargs):
return _fetch_instance_ohlcv_sync(
ex,
symbol=kwargs.get("symbol") or "",
timeframe=kwargs.get("timeframe") or "5m",
since_ms=kwargs.get("since_ms"),
limit=int(kwargs.get("limit") or 500),
)
r = await asyncio.to_thread(
sync_exchange_symbol_archives,
ex_key,
trades,
remote_fetch,
)
results.append(r)
return {"ok": True, "exchanges": len(targets), "results": results}
async def _archive_sync_loop() -> None:
global _archive_sync_stop
stop = _archive_sync_stop
if stop is None:
return
init_archive_db()
while not stop.is_set():
try:
await _run_archive_sync_once()
except Exception:
pass
try:
await asyncio.wait_for(stop.wait(), timeout=float(ARCHIVE_SYNC_INTERVAL_SEC))
except asyncio.TimeoutError:
pass
@asynccontextmanager
async def _hub_lifespan(_app: FastAPI):
global _archive_sync_stop, _archive_sync_task
await board_store.start(_run_board_aggregate)
await chart_poll_store.start(_run_chart_poll)
_archive_sync_stop = asyncio.Event()
_archive_sync_task = asyncio.create_task(_archive_sync_loop(), name="hub-archive-sync")
try:
yield
finally:
if _archive_sync_stop:
_archive_sync_stop.set()
if _archive_sync_task:
_archive_sync_task.cancel()
try:
await _archive_sync_task
except asyncio.CancelledError:
pass
_archive_sync_task = None
_archive_sync_stop = None
await chart_poll_store.stop()
await board_store.stop()
@@ -377,6 +472,7 @@ def root_redirect():
@app.get("/monitor")
@app.get("/market")
@app.get("/archive")
@app.get("/ai")
@app.get("/settings")
def shell_pages():
@@ -436,6 +532,30 @@ def _find_exchange_by_key(exchange_key: str) -> dict | None:
return None
def _fetch_instance_trades_archive_sync(
ex: dict,
*,
days: int = 365,
limit: int = 2000,
) -> dict:
base = (ex.get("flask_url") or "").rstrip("/")
if not base:
return {"ok": False, "msg": "未配置 flask_url"}
params = {"days": str(int(days)), "limit": str(int(limit))}
url = f"{base}/api/hub/trades/archive?{urlencode(params)}"
try:
with httpx.Client(timeout=HUB_FLASK_TIMEOUT) as client:
r = client.get(url, headers=_hub_headers())
if r.status_code >= 400:
parsed = _parse_http_json_body(r)
parsed.setdefault("ok", False)
return parsed
data = r.json() if r.content else {}
return data if isinstance(data, dict) else {"ok": False, "msg": "无效 JSON"}
except Exception as e:
return {"ok": False, "msg": str(e)}
def _fetch_instance_ohlcv_sync(
ex: dict,
*,
@@ -1503,6 +1623,141 @@ def _trade_removed_response():
)
def _parse_anchor_ms(at: str = "", anchor_ms: str = "") -> int | None:
raw = (anchor_ms or at or "").strip()
if not raw:
return None
if raw.isdigit():
v = int(raw)
return v if v > 1_000_000_000_000 else v * 1000
s = raw.replace("Z", "").replace("T", " ")
for fmt, ln in (("%Y-%m-%d %H:%M:%S", 19), ("%Y-%m-%d %H:%M", 16), ("%Y-%m-%d", 10)):
try:
from datetime import datetime
dt = datetime.strptime(s[:ln], fmt)
return int(dt.timestamp() * 1000)
except ValueError:
continue
return None
@app.get("/api/archive/meta")
def api_archive_meta():
init_archive_db()
exchanges = []
for ex in enabled_exchanges(load_settings()):
exchanges.append(
{
"id": ex.get("id"),
"key": ex.get("key"),
"name": ex.get("name"),
}
)
return {
"ok": True,
"timeframes": sorted(ARCHIVE_TIMEFRAMES),
"default_timeframe": ARCHIVE_DEFAULT_TIMEFRAME,
"seed_lookback_days": ARCHIVE_SEED_LOOKBACK_DAYS,
"sync_interval_sec": ARCHIVE_SYNC_INTERVAL_SEC,
"visible_bars_default": ARCHIVE_VISIBLE_BARS_DEFAULT,
"exchanges": exchanges,
}
@app.get("/api/archive/list")
def api_archive_list(
exchange_key: str = "",
filter_profit: str = "",
filter_loss: str = "",
filter_sick: str = "",
filter_emotion: str = "",
):
init_archive_db()
rows = list_symbol_rows(
exchange_key=exchange_key,
filter_profit=(filter_profit or "").lower() in ("1", "true", "yes", "on"),
filter_loss=(filter_loss or "").lower() in ("1", "true", "yes", "on"),
filter_sick=(filter_sick or "").lower() in ("1", "true", "yes", "on"),
filter_emotion=(filter_emotion or "").lower() in ("1", "true", "yes", "on"),
)
return {"ok": True, "rows": rows, "count": len(rows)}
@app.get("/api/archive/detail")
def api_archive_detail(exchange_key: str = "", symbol: str = ""):
ex_k = (exchange_key or "").strip().lower()
sym = (symbol or "").strip().upper()
if not ex_k or not sym:
raise HTTPException(status_code=400, detail="缺少 exchange_key 或 symbol")
init_archive_db()
trades = load_symbol_trades(ex_k, sym)
return {"ok": True, "exchange_key": ex_k, "symbol": sym, "trades": trades}
@app.get("/api/archive/ohlcv")
def api_archive_ohlcv(
exchange_key: str = "",
symbol: str = "",
timeframe: str = ARCHIVE_DEFAULT_TIMEFRAME,
mode: str = "hold",
anchor_ms: str = "",
at: str = "",
bars: str = "",
):
ex_k = (exchange_key or "").strip().lower()
sym = (symbol or "").strip().upper()
if not ex_k or not sym:
raise HTTPException(status_code=400, detail="缺少 exchange_key 或 symbol")
init_archive_db()
anchor = _parse_anchor_ms(at, anchor_ms)
try:
bar_n = int(bars) if (bars or "").strip().isdigit() else ARCHIVE_VISIBLE_BARS_DEFAULT
except ValueError:
bar_n = ARCHIVE_VISIBLE_BARS_DEFAULT
result = resolve_archive_chart(
ex_k,
sym,
timeframe,
anchor_ms=anchor,
mode=mode,
bars=bar_n,
)
if not result.get("ok"):
raise HTTPException(status_code=404, detail=result.get("msg") or "无 K 线")
return result
class ArchiveOverlayBody(BaseModel):
behavior_tag: str = ""
note: str = ""
@app.patch("/api/archive/trade/{exchange_key}/{trade_id}")
def api_archive_trade_overlay(
exchange_key: str,
trade_id: int,
body: ArchiveOverlayBody = Body(...),
):
ex_k = (exchange_key or "").strip().lower()
if not ex_k:
raise HTTPException(status_code=400, detail="缺少 exchange_key")
init_archive_db()
out = upsert_trade_overlay(
ex_k,
int(trade_id),
behavior_tag=body.behavior_tag,
note=body.note,
)
return {"ok": True, "overlay": out}
@app.post("/api/archive/sync")
async def api_archive_sync():
body = await _run_archive_sync_once()
return body
@app.get("/api/ping")
def api_ping():
return {
@@ -1510,7 +1765,7 @@ def api_ping():
"service": "manual-trading-hub",
"build": HUB_BUILD,
"trade_ui": False,
"features": ["monitor", "settings", "auth", "board_sse"],
"features": ["monitor", "settings", "auth", "board_sse", "archive"],
"board_poll_interval_sec": HUB_BOARD_POLL_INTERVAL,
"board_version": board_store.version,
"board_aggregating": board_store.aggregating,
+190
View File
@@ -3852,3 +3852,193 @@ body.hub-page-ai #page-ai {
opacity: 0.65;
}
/* —— 币种档案 —— */
.archive-toolbar {
flex-wrap: wrap;
gap: 10px 14px;
margin-bottom: 12px;
}
.archive-field {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 0.82rem;
color: var(--muted);
}
.archive-field select,
.archive-field input {
min-width: 120px;
padding: 6px 8px;
border-radius: 8px;
border: 1px solid var(--border-soft);
background: var(--inset-surface);
color: var(--text);
font-family: var(--font);
}
.archive-layout {
display: grid;
grid-template-columns: minmax(220px, 280px) minmax(0, 1fr);
gap: 14px;
min-height: 520px;
}
.archive-list-panel {
background: var(--panel);
border: 1px solid var(--border-soft);
border-radius: var(--radius);
overflow: auto;
max-height: calc(100vh - 200px);
}
.archive-list {
display: flex;
flex-direction: column;
}
.archive-row {
display: grid;
grid-template-columns: 1fr auto;
grid-template-rows: auto auto;
gap: 2px 8px;
width: 100%;
text-align: left;
padding: 10px 12px;
border: none;
border-bottom: 1px solid var(--border-soft);
background: transparent;
color: var(--text);
cursor: pointer;
font-family: var(--font);
}
.archive-row:hover,
.archive-row.is-active {
background: var(--inset-surface);
}
.archive-row-sym {
font-weight: 600;
font-size: 0.95rem;
}
.archive-row-ex {
font-size: 0.75rem;
color: var(--muted);
text-transform: uppercase;
}
.archive-row-stat {
grid-column: 1 / -1;
font-size: 0.8rem;
color: var(--muted);
}
.archive-row-meta {
font-size: 0.72rem;
color: var(--accent);
align-self: start;
}
.archive-detail-panel {
display: flex;
flex-direction: column;
gap: 10px;
min-width: 0;
}
.archive-detail-head {
display: flex;
flex-wrap: wrap;
align-items: baseline;
gap: 8px 16px;
}
.archive-detail-head h2 {
margin: 0;
font-size: 1.1rem;
}
.archive-detail-stats {
font-size: 0.82rem;
color: var(--muted);
}
.archive-chart-toolbar {
flex-wrap: wrap;
}
.archive-tf-tabs {
display: inline-flex;
gap: 4px;
}
.archive-tf-btn {
padding: 5px 10px;
border-radius: 8px;
border: 1px solid var(--border-soft);
background: var(--inset-surface);
color: var(--muted);
cursor: pointer;
font-family: var(--font);
font-size: 0.8rem;
}
.archive-tf-btn.is-active {
color: var(--text);
border-color: var(--accent);
background: color-mix(in srgb, var(--accent) 12%, transparent);
}
.archive-chart-host {
height: 360px;
min-height: 280px;
border: 1px solid var(--border-soft);
border-radius: var(--radius);
background: var(--panel);
overflow: hidden;
}
.archive-trades {
overflow: auto;
max-height: 280px;
border: 1px solid var(--border-soft);
border-radius: var(--radius);
background: var(--panel);
}
.archive-trades-table {
width: 100%;
border-collapse: collapse;
font-size: 0.78rem;
}
.archive-trades-table th,
.archive-trades-table td {
padding: 6px 8px;
border-bottom: 1px solid var(--border-soft);
text-align: left;
}
.archive-trades-table th {
color: var(--muted);
font-weight: 500;
position: sticky;
top: 0;
background: var(--panel);
}
.archive-trade-row {
cursor: pointer;
}
.archive-trade-row.is-active {
background: var(--inset-surface);
}
.archive-trades-table td.pos {
color: #22c55e;
}
.archive-trades-table td.neg {
color: #ef4444;
}
.archive-tag-select,
.archive-note-input {
width: 100%;
max-width: 140px;
padding: 4px 6px;
border-radius: 6px;
border: 1px solid var(--border-soft);
background: var(--inset-surface);
color: var(--text);
font-size: 0.75rem;
}
.archive-empty {
padding: 16px;
color: var(--muted);
font-size: 0.85rem;
}
@media (max-width: 900px) {
.archive-layout {
grid-template-columns: 1fr;
}
.archive-list-panel {
max-height: 240px;
}
}
+7
View File
@@ -624,6 +624,7 @@
function currentPage() {
const p = window.location.pathname.replace(/\/$/, "") || "/monitor";
if (p.includes("settings")) return "settings";
if (p.includes("archive")) return "archive";
if (p.includes("market")) return "market";
if (p.includes("/ai")) return "ai";
return "monitor";
@@ -631,6 +632,7 @@
function pageElementId(page) {
if (page === "settings") return "page-settings";
if (page === "archive") return "page-archive";
if (page === "market") return "page-market";
if (page === "ai") return "page-ai";
return "page-monitor";
@@ -654,6 +656,11 @@
else stopMonitorPoll();
if (page === "settings") loadSettingsUI();
if (page === "ai") loadAiPage();
if (page === "archive" && window.hubArchivePage) {
window.hubArchivePage.init();
} else if (window.hubArchivePage && window.hubArchivePage.destroy) {
window.hubArchivePage.destroy();
}
if (page === "market" && window.hubMarketChart) {
window.hubMarketChart.init();
} else if (window.hubMarketChart) {
+484
View File
@@ -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, "&quot;") +
'" 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 };
})();
+58 -2
View File
@@ -15,7 +15,7 @@
<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'" />
<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>
<body>
<div class="app-bg" aria-hidden="true"></div>
@@ -46,6 +46,7 @@
<nav class="top-nav">
<a href="/monitor" id="nav-monitor">监控区</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="/settings" id="nav-settings">系统设置</a>
</nav>
@@ -185,6 +186,60 @@
</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 class="instance-frame-toolbar">
<button type="button" id="instance-frame-back" class="ghost">← 返回监控</button>
@@ -294,7 +349,8 @@
<div id="toast"></div>
<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/archive.js?v=20260607-hub-archive-v1"></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>
</html>
+28 -1
View File
@@ -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 监控区(持仓、关键位、趋势计划、全平)
├─ /market 行情区(K 线、技术指标、持仓价格线)
├─ /archive 币种档案(交易时间线 + 永久 5m K 线)
├─ /ai AI 教练(四户今日总结 + 聊天)
└─ /settings 系统设置(hub_settings.json
@@ -127,6 +128,7 @@ pm2 save
- 监控区:`http://127.0.0.1:5100/monitor`
- 行情区:`http://127.0.0.1:5100/market`
- 币种档案:`http://127.0.0.1:5100/archive`
- 系统设置:`http://127.0.0.1:5100/settings`
验收:
@@ -178,6 +180,19 @@ Chrome **桌面快捷方式**图标来自站点 `favicon` / `manifest`(已配
数据经中控 → 各实例 `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`
| 功能 | 说明 |
@@ -303,6 +318,12 @@ PM2:仓库 `ecosystem.config.cjs` 默认只有四 agent;第五户需自行 `
| GET | `/api/ping` | 版本与健康检查(**免登录**) |
| GET | `/api/chart/meta` | 行情区:交易所、周期、limit |
| 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** 并提示前往各实例网页。
@@ -313,6 +334,7 @@ PM2:仓库 `ecosystem.config.cjs` 默认只有四 agent;第五户需自行 `
| `/api/hub/ping` | 连通与能力 |
| `/api/hub/monitor` | 关键位、机器人单、趋势计划 |
| `/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_KLINE_RETENTION_DAYS` | `15` | 行情区 K 线库保留天数 |
| `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
@@ -443,6 +469,7 @@ pm2 save && pm2 startup
|------|------|
| [使用说明.md](./使用说明.md) | 本文 |
| [行情区说明.md](./行情区说明.md) | K 线周期、缓存、快捷键、API |
| [docs/hub-symbol-archive-kline.md](../docs/hub-symbol-archive-kline.md) | 币种档案、永久 5m、建档与同步 |
| [部署文档.md](./部署文档.md) | Ubuntu / PM2 / 反代 |
| [常见问题.md](./常见问题.md) | 故障实录与排障 |
| [README.md](./README.md) | 速览 |