feat(hub): redesign archive as inner-light-mind journal

Rename archive to 内照明心 with daily trade records by default, review quotes sidebar, on-demand chart, sick-row highlighting, and new daily-trades/quotes APIs.
This commit is contained in:
dekun
2026-06-11 17:43:45 +08:00
parent 65d2bc5e00
commit bb800b876b
5 changed files with 926 additions and 325 deletions
+254 -1
View File
@@ -6,7 +6,7 @@ import json
import os import os
import sqlite3 import sqlite3
import time import time
from datetime import datetime, timezone from datetime import datetime, timedelta, timezone
from pathlib import Path from pathlib import Path
from typing import Any, Callable, Optional from typing import Any, Callable, Optional
from zoneinfo import ZoneInfo from zoneinfo import ZoneInfo
@@ -37,6 +37,9 @@ ARCHIVE_MAX_CANDLES: dict[str, int] = {
ARCHIVE_SYNC_INTERVAL_SEC = int(os.getenv("HUB_ARCHIVE_SYNC_INTERVAL_SEC", str(4 * 3600))) ARCHIVE_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_DAYS = int(os.getenv("HUB_ARCHIVE_TRADE_DAYS", "365"))
ARCHIVE_TRADE_LIMIT = int(os.getenv("HUB_ARCHIVE_TRADE_LIMIT", "2000")) ARCHIVE_TRADE_LIMIT = int(os.getenv("HUB_ARCHIVE_TRADE_LIMIT", "2000"))
ARCHIVE_QUOTES_MAX = int(os.getenv("HUB_ARCHIVE_QUOTES_MAX", "100"))
TRADING_DAY_RESET_HOUR = int(os.getenv("TRADING_DAY_RESET_HOUR", "8"))
ARCHIVE_QUOTE_MAX_LEN = 5000
BEHAVIOR_TAGS = frozenset({"", "sick", "emotion"}) BEHAVIOR_TAGS = frozenset({"", "sick", "emotion"})
@@ -138,6 +141,23 @@ def init_db(db_path: Path | None = None) -> None:
) )
""" """
) )
conn.execute(
"""
CREATE TABLE IF NOT EXISTS archive_review_quotes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
quote_date TEXT NOT NULL UNIQUE,
content TEXT NOT NULL DEFAULT '',
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
)
"""
)
conn.execute(
"""
CREATE INDEX IF NOT EXISTS idx_archive_quotes_date
ON archive_review_quotes (quote_date DESC)
"""
)
finally: finally:
conn.close() conn.close()
@@ -1121,3 +1141,236 @@ def sync_exchange_symbol_archives(
"appended_bars": appended, "appended_bars": appended,
"trades": len(trades or []), "trades": len(trades or []),
} }
def ms_to_trading_day(
ms: int | None,
*,
reset_hour: int = TRADING_DAY_RESET_HOUR,
tz: ZoneInfo = CHART_DISPLAY_TZ,
) -> str | None:
if ms is None:
return None
try:
dt = datetime.fromtimestamp(int(ms) / 1000.0, tz=timezone.utc).astimezone(tz)
except (TypeError, ValueError, OSError):
return None
if dt.hour < reset_hour:
dt = dt - timedelta(days=1)
return dt.strftime("%Y-%m-%d")
def today_trading_day(*, reset_hour: int = TRADING_DAY_RESET_HOUR) -> str:
return ms_to_trading_day(_now_ms(), reset_hour=reset_hour) or datetime.now(
CHART_DISPLAY_TZ
).strftime("%Y-%m-%d")
def trading_day_bounds_ms(
trading_day: str,
*,
reset_hour: int = TRADING_DAY_RESET_HOUR,
tz: ZoneInfo = CHART_DISPLAY_TZ,
) -> tuple[int, int]:
day = datetime.strptime((trading_day or "").strip()[:10], "%Y-%m-%d")
start = day.replace(hour=reset_hour, minute=0, second=0, microsecond=0, tzinfo=tz)
end = start + timedelta(days=1)
return int(start.timestamp() * 1000), int(end.timestamp() * 1000)
def list_review_quotes(*, db_path: Path | None = None) -> list[dict[str, Any]]:
init_db(db_path)
conn = _connect(db_path)
try:
rows = conn.execute(
"""
SELECT id, quote_date, content, created_at, updated_at
FROM archive_review_quotes
ORDER BY quote_date DESC
LIMIT ?
""",
(ARCHIVE_QUOTES_MAX,),
).fetchall()
return [dict(r) for r in rows]
finally:
conn.close()
def create_review_quote(
quote_date: str,
content: str,
*,
db_path: Path | None = None,
) -> dict[str, Any]:
init_db(db_path)
qd = (quote_date or "").strip()[:10]
if not qd:
raise ValueError("缺少 quote_date")
text = (content or "").strip()
if not text:
raise ValueError("语录内容不能为空")
if len(text) > ARCHIVE_QUOTE_MAX_LEN:
raise ValueError(f"语录最长 {ARCHIVE_QUOTE_MAX_LEN}")
conn = _connect(db_path)
try:
cnt = conn.execute("SELECT COUNT(*) AS c FROM archive_review_quotes").fetchone()
if int(cnt["c"] or 0) >= ARCHIVE_QUOTES_MAX:
raise ValueError(f"复盘语录最多保存 {ARCHIVE_QUOTES_MAX}")
now = _now_ms()
try:
cur = conn.execute(
"""
INSERT INTO archive_review_quotes (quote_date, content, created_at, updated_at)
VALUES (?,?,?,?)
""",
(qd, text, now, now),
)
except sqlite3.IntegrityError as e:
raise ValueError("该日期已有语录,请展开编辑") from e
rid = int(cur.lastrowid)
row = conn.execute(
"SELECT id, quote_date, content, created_at, updated_at FROM archive_review_quotes WHERE id=?",
(rid,),
).fetchone()
return dict(row)
finally:
conn.close()
def update_review_quote(
quote_id: int,
*,
quote_date: str | None = None,
content: str | None = None,
db_path: Path | None = None,
) -> dict[str, Any] | None:
init_db(db_path)
conn = _connect(db_path)
try:
row = conn.execute(
"SELECT id, quote_date, content FROM archive_review_quotes WHERE id=?",
(int(quote_id),),
).fetchone()
if not row:
return None
qd = (quote_date or row["quote_date"] or "").strip()[:10]
text = (content if content is not None else row["content"] or "").strip()
if not qd or not text:
raise ValueError("日期与内容均不能为空")
if len(text) > ARCHIVE_QUOTE_MAX_LEN:
raise ValueError(f"语录最长 {ARCHIVE_QUOTE_MAX_LEN}")
now = _now_ms()
conn.execute(
"""
UPDATE archive_review_quotes
SET quote_date=?, content=?, updated_at=?
WHERE id=?
""",
(qd, text, now, int(quote_id)),
)
out = conn.execute(
"SELECT id, quote_date, content, created_at, updated_at FROM archive_review_quotes WHERE id=?",
(int(quote_id),),
).fetchone()
return dict(out) if out else None
finally:
conn.close()
def delete_review_quote(quote_id: int, *, db_path: Path | None = None) -> bool:
init_db(db_path)
conn = _connect(db_path)
try:
cur = conn.execute(
"DELETE FROM archive_review_quotes WHERE id=?",
(int(quote_id),),
)
return int(cur.rowcount or 0) > 0
finally:
conn.close()
def list_daily_trades(
trading_day: str = "",
*,
exchange_key: str = "",
filter_profit: bool = False,
filter_loss: bool = False,
filter_sick: bool = False,
search: str = "",
db_path: Path | None = None,
) -> dict[str, Any]:
"""按交易日列出开仓记录(默认当日),含各所开仓统计。"""
init_db(db_path)
td = (trading_day or "").strip()[:10] or today_trading_day()
start_ms, end_ms = trading_day_bounds_ms(td)
ex_filter = (exchange_key or "").strip().lower()
conn = _connect(db_path)
try:
params: list[Any] = [start_ms, end_ms]
where = "opened_at_ms >= ? AND opened_at_ms < ?"
if ex_filter:
where += " AND exchange_key=?"
params.append(ex_filter)
stat_rows = conn.execute(
f"""
SELECT exchange_key, COUNT(*) AS open_count
FROM archive_trade_cache
WHERE {where}
GROUP BY exchange_key
""",
params,
).fetchall()
rows = conn.execute(
f"""
SELECT * FROM archive_trade_cache
WHERE {where}
ORDER BY opened_at_ms DESC, trade_id DESC
""",
params,
).fetchall()
overlays_by_ex: dict[str, dict[int, dict]] = {}
trades: list[dict[str, Any]] = []
q = (search or "").strip().lower()
for r in rows:
ex_k = r["exchange_key"]
if ex_k not in overlays_by_ex:
overlays_by_ex[ex_k] = load_overlays(ex_k, db_path=db_path)
td_row = _trade_row_to_dict(r, overlays_by_ex[ex_k].get(int(r["trade_id"])))
pnl = float(td_row.get("pnl_amount") or 0)
tag = td_row.get("behavior_tag") or ""
if filter_profit and pnl <= 0.0001:
continue
if filter_loss and pnl >= -0.0001:
continue
if filter_sick and tag != "sick":
continue
if q:
blob = " ".join(
str(td_row.get(k) or "")
for k in (
"symbol",
"exchange_key",
"direction",
"result",
"note",
"monitor_type",
"entry_reason",
)
).lower()
if q not in blob:
continue
trades.append(td_row)
by_exchange = {
str(sr["exchange_key"]): int(sr["open_count"] or 0) for sr in stat_rows
}
return {
"trading_day": td,
"trades": trades,
"stats": {
"open_count": sum(by_exchange.values()),
"by_exchange": by_exchange,
},
}
finally:
conn.close()
+74
View File
@@ -39,18 +39,25 @@ from hub_volume_rank_lib import (
) )
from hub_symbol_archive_lib import ( from hub_symbol_archive_lib import (
ARCHIVE_DEFAULT_TIMEFRAME, ARCHIVE_DEFAULT_TIMEFRAME,
ARCHIVE_QUOTES_MAX,
ARCHIVE_SEED_LOOKBACK_DAYS, ARCHIVE_SEED_LOOKBACK_DAYS,
ARCHIVE_SYNC_INTERVAL_SEC, ARCHIVE_SYNC_INTERVAL_SEC,
ARCHIVE_TIMEFRAMES, ARCHIVE_TIMEFRAMES,
ARCHIVE_TRADE_DAYS, ARCHIVE_TRADE_DAYS,
ARCHIVE_TRADE_LIMIT, ARCHIVE_TRADE_LIMIT,
ARCHIVE_VISIBLE_BARS_DEFAULT, ARCHIVE_VISIBLE_BARS_DEFAULT,
create_review_quote,
delete_review_quote,
init_db as init_archive_db, init_db as init_archive_db,
list_daily_trades,
list_review_quotes,
list_symbol_rows, list_symbol_rows,
load_symbol_trades, load_symbol_trades,
parse_wall_clock_ms, parse_wall_clock_ms,
resolve_archive_chart, resolve_archive_chart,
sync_exchange_symbol_archives, sync_exchange_symbol_archives,
today_trading_day,
update_review_quote,
upsert_trade_overlay, upsert_trade_overlay,
) )
from env_load import load_hub_dotenv from env_load import load_hub_dotenv
@@ -2048,6 +2055,73 @@ def api_archive_list(
return {"ok": True, "rows": rows, "count": len(rows)} return {"ok": True, "rows": rows, "count": len(rows)}
@app.get("/api/archive/daily-trades")
def api_archive_daily_trades(
trading_day: str = "",
exchange_key: str = "",
filter_profit: str = "",
filter_loss: str = "",
filter_sick: str = "",
search: str = "",
):
init_archive_db()
payload = list_daily_trades(
trading_day=trading_day or today_trading_day(),
exchange_key=exchange_key,
filter_profit=(filter_profit or "").lower() in ("1", "true", "yes", "on"),
filter_loss=(filter_loss or "").lower() in ("1", "true", "yes", "on"),
filter_sick=(filter_sick or "").lower() in ("1", "true", "yes", "on"),
search=search,
)
return {"ok": True, **payload}
@app.get("/api/archive/quotes")
def api_archive_quotes():
init_archive_db()
rows = list_review_quotes()
return {"ok": True, "quotes": rows, "count": len(rows), "max": ARCHIVE_QUOTES_MAX}
class ArchiveQuoteBody(BaseModel):
quote_date: str = ""
content: str = ""
@app.post("/api/archive/quotes")
def api_archive_quote_create(body: ArchiveQuoteBody = Body(...)):
init_archive_db()
try:
row = create_review_quote(body.quote_date, body.content)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e)) from e
return {"ok": True, "quote": row}
@app.patch("/api/archive/quotes/{quote_id}")
def api_archive_quote_update(quote_id: int, body: ArchiveQuoteBody = Body(...)):
init_archive_db()
try:
row = update_review_quote(
int(quote_id),
quote_date=body.quote_date or None,
content=body.content if body.content is not None else None,
)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e)) from e
if not row:
raise HTTPException(status_code=404, detail="语录不存在")
return {"ok": True, "quote": row}
@app.delete("/api/archive/quotes/{quote_id}")
def api_archive_quote_delete(quote_id: int):
init_archive_db()
if not delete_review_quote(int(quote_id)):
raise HTTPException(status_code=404, detail="语录不存在")
return {"ok": True, "id": int(quote_id)}
@app.get("/api/archive/detail") @app.get("/api/archive/detail")
def api_archive_detail(exchange_key: str = "", symbol: str = ""): def api_archive_detail(exchange_key: str = "", symbol: str = ""):
ex_k = (exchange_key or "").strip().lower() ex_k = (exchange_key or "").strip().lower()
+177 -61
View File
@@ -5396,11 +5396,19 @@ body.funds-fullscreen-open {
} }
} }
/* —— 币种档案 —— */ /* —— 内照明心 —— */
.archive-toolbar { .archive-toolbar {
flex-wrap: wrap; flex-wrap: wrap;
gap: 10px 14px; gap: 10px 14px;
margin-bottom: 12px; margin-bottom: 10px;
}
.archive-search-field input {
min-width: 160px;
}
#archive-btn-chart-toggle.is-active {
color: var(--accent);
border-color: var(--accent);
background: var(--accent-dim);
} }
.archive-field { .archive-field {
display: inline-flex; display: inline-flex;
@@ -5421,79 +5429,166 @@ body.funds-fullscreen-open {
} }
.archive-layout { .archive-layout {
display: grid; display: grid;
grid-template-columns: minmax(220px, 280px) minmax(0, 1fr); grid-template-columns: minmax(240px, 300px) minmax(0, 1fr);
gap: 14px; gap: 14px;
min-height: 520px; min-height: 520px;
align-items: start;
} }
.archive-list-panel { .archive-quotes-panel,
.archive-main-panel {
background: var(--panel); background: var(--panel);
border: 1px solid var(--border-soft); border: 1px solid var(--border-soft);
border-radius: var(--radius); border-radius: var(--radius);
overflow: auto; min-width: 0;
max-height: calc(100vh - 200px);
} }
.archive-list { .archive-quotes-panel {
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; display: flex;
flex-direction: column; flex-direction: column;
gap: 10px; gap: 10px;
min-width: 0; padding: 12px;
max-height: calc(100vh - 180px);
overflow: hidden;
} }
.archive-detail-head { .archive-panel-head {
display: flex; display: flex;
flex-wrap: wrap; align-items: center;
align-items: baseline; justify-content: space-between;
gap: 8px 16px; gap: 8px;
} }
.archive-detail-head h2 { .archive-panel-head h2 {
margin: 0; margin: 0;
font-size: 1.1rem; font-size: 0.95rem;
} }
.archive-detail-stats { .archive-panel-meta {
font-size: 0.82rem; font-size: 0.72rem;
color: var(--muted); color: var(--muted);
} }
.archive-quote-form {
display: flex;
flex-direction: column;
gap: 8px;
}
.archive-quote-form input[type="date"],
.archive-quote-form textarea {
width: 100%;
padding: 8px 10px;
border-radius: 8px;
border: 1px solid var(--border-soft);
background: var(--inset-surface);
color: var(--text);
font-family: var(--font);
font-size: 0.82rem;
resize: vertical;
}
.archive-quotes-list {
flex: 1 1 auto;
min-height: 0;
overflow: auto;
display: flex;
flex-direction: column;
gap: 8px;
}
.archive-quote-card {
border: 1px solid var(--border-soft);
border-radius: 8px;
background: var(--inset-surface);
overflow: hidden;
}
.archive-quote-summary {
display: grid;
grid-template-columns: auto 1fr;
gap: 8px;
align-items: center;
padding: 8px 10px;
cursor: pointer;
list-style: none;
}
.archive-quote-summary::-webkit-details-marker {
display: none;
}
.archive-quote-date {
font-weight: 600;
font-size: 0.78rem;
color: var(--accent);
white-space: nowrap;
}
.archive-quote-preview {
font-size: 0.74rem;
color: var(--muted);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.archive-quote-body {
padding: 0 10px 10px;
display: flex;
flex-direction: column;
gap: 8px;
}
.archive-quote-edit {
width: 100%;
min-height: 72px;
padding: 8px 10px;
border-radius: 8px;
border: 1px solid var(--border-soft);
background: var(--panel);
color: var(--text);
font-family: var(--font);
font-size: 0.8rem;
resize: vertical;
}
.archive-quote-actions {
display: flex;
gap: 8px;
}
.archive-main-panel {
display: flex;
flex-direction: column;
gap: 10px;
padding: 12px;
min-height: 520px;
}
.archive-stats-bar {
padding: 10px 12px;
border-radius: 8px;
border: 1px solid var(--border-soft);
background: var(--inset-surface);
font-size: 0.82rem;
color: var(--text);
line-height: 1.45;
}
.archive-acc-section {
border: 1px solid var(--border-soft);
border-radius: var(--radius);
background: var(--inset-surface);
overflow: hidden;
}
.archive-acc-summary {
padding: 10px 12px;
font-weight: 600;
font-size: 0.86rem;
cursor: pointer;
list-style: none;
display: flex;
align-items: center;
gap: 8px;
}
.archive-acc-summary::-webkit-details-marker {
display: none;
}
.archive-acc-sub {
font-weight: 400;
font-size: 0.76rem;
color: var(--muted);
}
.archive-chart-section > :not(summary) {
padding: 0 10px 10px;
}
.archive-trades-section > .archive-trades {
border: none;
border-radius: 0;
max-height: min(56vh, 560px);
}
.archive-chart-toolbar { .archive-chart-toolbar {
flex-wrap: wrap; flex-wrap: wrap;
} }
@@ -5553,7 +5648,7 @@ body.funds-fullscreen-open {
} }
.archive-trades { .archive-trades {
overflow: auto; overflow: auto;
max-height: 280px; max-height: min(56vh, 560px);
border: 1px solid var(--border-soft); border: 1px solid var(--border-soft);
border-radius: var(--radius); border-radius: var(--radius);
background: var(--panel); background: var(--panel);
@@ -5601,6 +5696,27 @@ body.funds-fullscreen-open {
.archive-trade-row.is-active { .archive-trade-row.is-active {
background: var(--inset-surface); background: var(--inset-surface);
} }
.archive-trade-row.archive-trade-sick {
background: rgba(239, 68, 68, 0.12);
}
.archive-trade-row.archive-trade-sick.is-active {
background: rgba(239, 68, 68, 0.18);
}
.archive-trade-row.archive-trade-sick td {
border-bottom-color: rgba(239, 68, 68, 0.22);
}
.archive-actions-cell {
white-space: nowrap;
}
.archive-actions-cell .archive-chart-btn,
.archive-actions-cell .archive-del-btn {
margin-right: 6px;
}
.archive-chart-btn {
padding: 3px 8px;
font-size: 0.72rem;
border-radius: 6px;
}
.archive-trades-table td.pos { .archive-trades-table td.pos {
color: #22c55e; color: #22c55e;
} }
@@ -5639,8 +5755,8 @@ body.funds-fullscreen-open {
.archive-layout { .archive-layout {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
.archive-list-panel { .archive-quotes-panel {
max-height: 240px; max-height: 280px;
} }
} }
+350 -212
View File
@@ -1,5 +1,5 @@
/** /**
* 中控币种档案列表筛选交易时间线永久 K 线lightweight-charts * 内照明心复盘语录 + 当日交易记录 + 按需 K 线
*/ */
(function () { (function () {
const page = document.getElementById("page-archive"); const page = document.getElementById("page-archive");
@@ -9,14 +9,20 @@
const elFilterProfit = document.getElementById("archive-filter-profit"); const elFilterProfit = document.getElementById("archive-filter-profit");
const elFilterLoss = document.getElementById("archive-filter-loss"); const elFilterLoss = document.getElementById("archive-filter-loss");
const elFilterSick = document.getElementById("archive-filter-sick"); const elFilterSick = document.getElementById("archive-filter-sick");
const elFilterEmotion = document.getElementById("archive-filter-emotion"); const elTradingDay = document.getElementById("archive-trading-day");
const elSearch = document.getElementById("archive-search");
const elBtnChartToggle = document.getElementById("archive-btn-chart-toggle");
const elBtnRefresh = document.getElementById("archive-btn-refresh"); const elBtnRefresh = document.getElementById("archive-btn-refresh");
const elBtnSync = document.getElementById("archive-btn-sync"); const elBtnSync = document.getElementById("archive-btn-sync");
const elStatus = document.getElementById("archive-status"); const elStatus = document.getElementById("archive-status");
const elList = document.getElementById("archive-list"); const elStats = document.getElementById("archive-stats");
const elDetailPanel = document.getElementById("archive-detail-panel"); const elQuotesList = document.getElementById("archive-quotes-list");
const elDetailTitle = document.getElementById("archive-detail-title"); const elQuotesCount = document.getElementById("archive-quotes-count");
const elDetailStats = document.getElementById("archive-detail-stats"); const elQuoteForm = document.getElementById("archive-quote-form");
const elQuoteDate = document.getElementById("archive-quote-date");
const elQuoteContent = document.getElementById("archive-quote-content");
const elChartSection = document.getElementById("archive-chart-section");
const elChartTitle = document.getElementById("archive-chart-title");
const elTfTabs = document.getElementById("archive-tf-tabs"); const elTfTabs = document.getElementById("archive-tf-tabs");
const elViewMode = document.getElementById("archive-view-mode"); const elViewMode = document.getElementById("archive-view-mode");
const elJumpAt = document.getElementById("archive-jump-at"); const elJumpAt = document.getElementById("archive-jump-at");
@@ -36,7 +42,10 @@
const CHART_TZ_OFFSET_SEC = 8 * 60 * 60; const CHART_TZ_OFFSET_SEC = 8 * 60 * 60;
let meta = null; let meta = null;
let listRows = []; let quotes = [];
let dailyTrades = [];
let dailyStats = { open_count: 0, by_exchange: {} };
let tradingDay = "";
let selected = null; let selected = null;
let trades = []; let trades = [];
let selectedTradeId = null; let selectedTradeId = null;
@@ -47,6 +56,15 @@
let inited = false; let inited = false;
let markAuto = true; let markAuto = true;
let lastCandles = []; let lastCandles = [];
let searchTimer = null;
function esc(s) {
return String(s == null ? "" : s)
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
function loadMarkAutoPref() { function loadMarkAutoPref() {
try { try {
@@ -89,8 +107,7 @@
function fmtPnl(v) { function fmtPnl(v) {
const n = Number(v); const n = Number(v);
if (!Number.isFinite(n)) return "—"; if (!Number.isFinite(n)) return "—";
const s = (n >= 0 ? "+" : "") + n.toFixed(2); return (n >= 0 ? "+" : "") + n.toFixed(2);
return s;
} }
function pad2(n) { function pad2(n) {
@@ -174,14 +191,10 @@
const raw = String( const raw = String(
tr.entry_type || tr.entry_reason || tr.reviewed_entry_reason || "" tr.entry_type || tr.entry_reason || tr.reviewed_entry_reason || ""
).trim(); ).trim();
if (raw) { if (raw) return ENTRY_TYPE_LABELS[raw] || raw;
return ENTRY_TYPE_LABELS[raw] || raw;
}
const mt = String(tr.monitor_type || "").trim(); const mt = String(tr.monitor_type || "").trim();
if (mt && mt !== "下单监控") { if (mt && mt !== "下单监控") return ENTRY_TYPE_LABELS[mt] || mt;
return ENTRY_TYPE_LABELS[mt] || mt; return mt || "—";
}
return "—";
} }
function reviewMark(tr) { function reviewMark(tr) {
@@ -198,6 +211,36 @@
if (elStatus) elStatus.textContent = text || ""; if (elStatus) elStatus.textContent = text || "";
} }
function exchangeLabel(exKey) {
const key = String(exKey || "").toLowerCase();
const hit = (meta && meta.exchanges || []).find(function (ex) {
return String(ex.key || "").toLowerCase() === key;
});
return hit ? hit.name || hit.key : exKey || "—";
}
function isChartOpen() {
return !!(elChartSection && elChartSection.open);
}
function setChartOpen(on) {
if (!elChartSection) return;
elChartSection.open = !!on;
if (elBtnChartToggle) {
elBtnChartToggle.classList.toggle("is-active", !!on);
}
if (!on) destroyChart();
}
function updateChartTitle() {
if (!elChartTitle) return;
if (!selected) {
elChartTitle.textContent = "—";
return;
}
elChartTitle.textContent = selected.symbol + " · " + exchangeLabel(selected.exchange_key);
}
async function apiFetch(url, opts) { async function apiFetch(url, opts) {
const r = await fetch(url, opts); const r = await fetch(url, opts);
if (r.status === 401) { if (r.status === 401) {
@@ -207,14 +250,15 @@
return r; return r;
} }
function queryListParams() { function queryDailyParams() {
const q = new URLSearchParams(); const q = new URLSearchParams();
if (elTradingDay && elTradingDay.value) q.set("trading_day", elTradingDay.value);
const ex = (elExchange && elExchange.value) || ""; const ex = (elExchange && elExchange.value) || "";
if (ex) q.set("exchange_key", ex); if (ex) q.set("exchange_key", ex);
if (elFilterProfit && elFilterProfit.checked) q.set("filter_profit", "1"); if (elFilterProfit && elFilterProfit.checked) q.set("filter_profit", "1");
if (elFilterLoss && elFilterLoss.checked) q.set("filter_loss", "1"); if (elFilterLoss && elFilterLoss.checked) q.set("filter_loss", "1");
if (elFilterSick && elFilterSick.checked) q.set("filter_sick", "1"); if (elFilterSick && elFilterSick.checked) q.set("filter_sick", "1");
if (elFilterEmotion && elFilterEmotion.checked) q.set("filter_emotion", "1"); if (elSearch && elSearch.value.trim()) q.set("search", elSearch.value.trim());
return q.toString(); return q.toString();
} }
@@ -231,52 +275,147 @@
if (cur) elExchange.value = cur; if (cur) elExchange.value = cur;
} }
function renderList() { function renderStats() {
if (!elList) return; if (!elStats) return;
if (!listRows.length) { const st = dailyStats || { open_count: 0, by_exchange: {} };
elList.innerHTML = '<p class="archive-empty">暂无档案数据。点击「同步交易与 K 线」从四所拉取。</p>'; const parts = ["今日开仓 " + (st.open_count || 0) + " 次"];
const byEx = st.by_exchange || {};
Object.keys(byEx)
.sort()
.forEach(function (ex) {
parts.push(exchangeLabel(ex) + " " + byEx[ex]);
});
elStats.textContent = parts.join(" · ");
}
function quotePreview(text) {
const s = String(text || "").replace(/\s+/g, " ").trim();
if (!s) return "(空)";
return s.length > 36 ? s.slice(0, 36) + "…" : s;
}
function renderQuotes() {
if (!elQuotesList) return;
if (elQuotesCount) {
elQuotesCount.textContent = quotes.length ? quotes.length + " 条" : "";
}
if (!quotes.length) {
elQuotesList.innerHTML = '<p class="archive-empty">暂无复盘语录,可在上方添加。</p>';
return; return;
} }
elList.innerHTML = listRows elQuotesList.innerHTML = quotes
.map(function (row) { .map(function (q) {
const active =
selected &&
selected.exchange_key === row.exchange_key &&
selected.symbol === row.symbol
? " is-active"
: "";
const seed = row.seed_complete ? "已建档" : "待种子";
return ( return (
'<button type="button" class="archive-row' + '<details class="archive-quote-card">' +
active + '<summary class="archive-quote-summary">' +
'" data-ex="' + '<span class="archive-quote-date">' +
row.exchange_key + esc(q.quote_date) +
'" data-sym="' +
row.symbol +
'">' +
'<span class="archive-row-sym">' +
row.symbol +
"</span>" + "</span>" +
'<span class="archive-row-ex">' + '<span class="archive-quote-preview">' +
row.exchange_key + esc(quotePreview(q.content)) +
"</span>" + "</span>" +
'<span class="archive-row-stat">' + "</summary>" +
row.trade_count + '<div class="archive-quote-body">' +
" 笔 · " + '<textarea class="archive-quote-edit" data-id="' +
fmtPnl(row.total_pnl) + q.id +
" U</span>" + '" rows="4">' +
'<span class="archive-row-meta">' + esc(q.content) +
seed + "</textarea>" +
"</span>" + '<div class="archive-quote-actions">' +
"</button>" '<button type="button" class="ghost archive-quote-save" data-id="' +
q.id +
'">保存</button>' +
'<button type="button" class="archive-del-btn archive-quote-del" data-id="' +
q.id +
'">删除</button>' +
"</div></div></details>"
); );
}) })
.join(""); .join("");
elList.querySelectorAll(".archive-row").forEach(function (btn) {
elQuotesList.querySelectorAll(".archive-quote-save").forEach(function (btn) {
btn.addEventListener("click", function () { btn.addEventListener("click", function () {
openDetail(btn.getAttribute("data-ex"), btn.getAttribute("data-sym")); const id = btn.getAttribute("data-id");
const card = btn.closest(".archive-quote-card");
const ta = card && card.querySelector(".archive-quote-edit");
const dateEl = card && card.querySelector(".archive-quote-date");
if (!id || !ta) return;
void saveQuote(id, dateEl ? dateEl.textContent : "", ta.value, card);
}); });
}); });
elQuotesList.querySelectorAll(".archive-quote-del").forEach(function (btn) {
btn.addEventListener("click", function () {
void deleteQuote(btn.getAttribute("data-id"));
});
});
}
async function loadQuotes() {
const r = await apiFetch("/api/archive/quotes");
const j = await r.json();
quotes = j.quotes || [];
renderQuotes();
}
async function addQuote(ev) {
if (ev) ev.preventDefault();
const date = elQuoteDate && elQuoteDate.value;
const content = elQuoteContent && elQuoteContent.value.trim();
if (!date || !content) return;
const r = await apiFetch("/api/archive/quotes", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ quote_date: date, content: content }),
});
const j = await r.json();
if (!r.ok) {
setStatus(j.detail || "添加失败");
return;
}
if (elQuoteContent) elQuoteContent.value = "";
await loadQuotes();
setStatus("语录已添加");
}
async function saveQuote(id, quoteDate, content, cardEl) {
let card = cardEl;
if (!card && elQuotesList) {
const ta = elQuotesList.querySelector('.archive-quote-edit[data-id="' + id + '"]');
card = ta ? ta.closest(".archive-quote-card") : null;
}
const date =
quoteDate ||
(card &&
card.querySelector(".archive-quote-date") &&
card.querySelector(".archive-quote-date").textContent) ||
"";
const r = await apiFetch("/api/archive/quotes/" + id, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ quote_date: date.trim(), content: content }),
});
const j = await r.json();
if (!r.ok) {
setStatus(j.detail || "保存失败");
return;
}
if (card) card.open = false;
await loadQuotes();
setStatus("语录已保存");
}
async function deleteQuote(id) {
if (!id || !window.confirm("确定删除这条复盘语录?")) return;
const r = await apiFetch("/api/archive/quotes/" + id, { method: "DELETE" });
if (!r.ok) {
const j = await r.json().catch(function () {
return {};
});
setStatus(j.detail || "删除失败");
return;
}
await loadQuotes();
setStatus("语录已删除");
} }
function pickAnchorTrade() { function pickAnchorTrade() {
@@ -326,9 +465,7 @@
function anchorMsForTrade(tr) { function anchorMsForTrade(tr) {
if (!tr) return null; if (!tr) return null;
const mode = (elViewMode && elViewMode.value) || "hold"; const mode = (elViewMode && elViewMode.value) || "hold";
if (mode === "entry") { if (mode === "entry") return tradeOpenMs(tr);
return tradeOpenMs(tr);
}
return tradeCloseMs(tr) || tradeOpenMs(tr); return tradeCloseMs(tr) || tradeOpenMs(tr);
} }
@@ -359,12 +496,8 @@
function isLongDirection(dir) { function isLongDirection(dir) {
const d = String(dir || "").trim().toLowerCase(); const d = String(dir || "").trim().toLowerCase();
if (d === "short" || d === "空" || d === "sell" || d === "做空" || d === "shorts") { if (d === "short" || d === "空" || d === "sell" || d === "做空" || d === "shorts") return false;
return false; if (d === "long" || d === "多" || d === "buy" || d === "做多" || d === "longs") return true;
}
if (d === "long" || d === "多" || d === "buy" || d === "做多" || d === "longs") {
return true;
}
return true; return true;
} }
@@ -384,9 +517,7 @@
const openColor = openArrowColor(long, highlight); const openColor = openArrowColor(long, highlight);
let closeColor = highlight ? "#fbbf24" : "#f59e0b"; let closeColor = highlight ? "#fbbf24" : "#f59e0b";
const pnl = Number(tr.pnl_amount); const pnl = Number(tr.pnl_amount);
if (!highlight && Number.isFinite(pnl) && pnl < -0.0001) { if (!highlight && Number.isFinite(pnl) && pnl < -0.0001) closeColor = "#a855f7";
closeColor = "#a855f7";
}
const markers = []; const markers = [];
if (openMs) { if (openMs) {
markers.push({ markers.push({
@@ -438,7 +569,6 @@
candleSeries.setMarkers(buildChartMarkers(lastCandles, timeframe)); candleSeries.setMarkers(buildChartMarkers(lastCandles, timeframe));
} }
/** 初始只聚焦持仓段;完整历史已加载,可向左拖动/滚轮缩小查看建仓前全局。 */
function focusInitialTradeView(candles, tr, tf) { function focusInitialTradeView(candles, tr, tf) {
if (!chart || !candles.length || !tr) return; if (!chart || !candles.length || !tr) return;
const mode = (elViewMode && elViewMode.value) || "hold"; const mode = (elViewMode && elViewMode.value) || "hold";
@@ -472,9 +602,7 @@
fromIdx = Math.max(0, openIdx - 10); fromIdx = Math.max(0, openIdx - 10);
toIdx = Math.min(candles.length - 1, closeIdx + 14); toIdx = Math.min(candles.length - 1, closeIdx + 14);
} }
if (toIdx <= fromIdx) { if (toIdx <= fromIdx) toIdx = Math.min(candles.length - 1, fromIdx + 80);
toIdx = Math.min(candles.length - 1, fromIdx + 80);
}
chart.timeScale().setVisibleLogicalRange({ from: fromIdx, to: toIdx + 4 }); chart.timeScale().setVisibleLogicalRange({ from: fromIdx, to: toIdx + 4 });
} }
@@ -533,9 +661,7 @@
priceFormat: { type: "volume" }, priceFormat: { type: "volume" },
priceScaleId: "", priceScaleId: "",
}); });
volumeSeries.priceScale().applyOptions({ volumeSeries.priceScale().applyOptions({ scaleMargins: { top: 0.82, bottom: 0 } });
scaleMargins: { top: 0.82, bottom: 0 },
});
new ResizeObserver(function () { new ResizeObserver(function () {
if (chart && elChartHost) { if (chart && elChartHost) {
chart.applyOptions({ width: elChartHost.clientWidth, height: elChartHost.clientHeight }); chart.applyOptions({ width: elChartHost.clientWidth, height: elChartHost.clientHeight });
@@ -544,11 +670,21 @@
chart.applyOptions({ width: elChartHost.clientWidth, height: elChartHost.clientHeight }); chart.applyOptions({ width: elChartHost.clientWidth, height: elChartHost.clientHeight });
} }
async function loadSymbolTradesForChart(exKey, sym) {
const r = await apiFetch(
"/api/archive/detail?exchange_key=" +
encodeURIComponent(exKey) +
"&symbol=" +
encodeURIComponent(sym)
);
const j = await r.json();
trades = j.trades || [];
}
async function loadChart() { async function loadChart() {
if (!selected) return; if (!selected || !isChartOpen()) return;
const tr = pickAnchorTrade(); const tr = pickAnchorTrade();
const anchor = anchorMsForTrade(tr); const jump = (elJumpAt && elJumpAt.value) || "";
const jump = (elJumpAt && elJumpAt.value || "").trim();
let openMs = null; let openMs = null;
let closeMs = null; let closeMs = null;
if (markAuto && trades.length) { if (markAuto && trades.length) {
@@ -571,7 +707,8 @@
params.set("closed_ms", String(closeMs)); params.set("closed_ms", String(closeMs));
} else { } else {
params.set("bars", "200"); params.set("bars", "200");
if (jump) params.set("at", jump); const anchor = anchorMsForTrade(tr);
if (jump.trim()) params.set("at", jump.trim());
else if (anchor) params.set("anchor_ms", String(anchor)); else if (anchor) params.set("anchor_ms", String(anchor));
} }
setStatus("加载 K 线…"); setStatus("加载 K 线…");
@@ -604,66 +741,72 @@
} else if (candles.length > 10) { } else if (candles.length > 10) {
chart.timeScale().setVisibleLogicalRange({ from: candles.length - 120, to: candles.length + 5 }); chart.timeScale().setVisibleLogicalRange({ from: candles.length - 120, to: candles.length + 5 });
} }
const markHint = markAuto && trades.length > 1 ? " · 自动标注 " + trades.length + " 笔" : tr ? " · 已标注开/平" : ""; updateChartTitle();
const histHint = setStatus("K 线 " + candles.length + " 根 · " + timeframe);
openMs && closeMs }
? " · 建档30天历史 · 可拖动/滚轮缩放查看建仓前走势"
: ""; async function openTradeChart(tr) {
setStatus("K 线 " + candles.length + " 根 · " + timeframe + markHint + histHint); if (!tr) return;
const exKey = tr.exchange_key;
const sym = tr.symbol;
if (!exKey || !sym) return;
selected = { exchange_key: exKey, symbol: sym };
selectedTradeId = String(tr.trade_id || tr.id);
setChartOpen(true);
await loadSymbolTradesForChart(exKey, sym);
await loadChart();
} }
function renderTrades() { function renderTrades() {
if (!elTrades) return; if (!elTrades) return;
if (!trades.length) { if (!dailyTrades.length) {
elTrades.innerHTML = '<p class="archive-empty">该币种暂无已平仓记录。</p>'; elTrades.innerHTML =
'<p class="archive-empty">该日暂无交易记录。可调整日期或点击「同步」拉取数据。</p>';
return; return;
} }
elTrades.innerHTML = elTrades.innerHTML =
'<table class="archive-trades-table"><thead><tr>' + '<table class="archive-trades-table"><thead><tr>' +
"<th>开仓类型</th><th>开仓时间</th><th>平仓时间</th><th>持仓时长</th>" + "<th>交易所</th><th>开仓类型</th><th>开仓时间</th><th>平仓时间</th><th>持仓时长</th>" +
"<th>方向</th><th>结果</th><th>盈亏</th><th>标签</th><th>备注</th><th>操作</th>" + "<th>方向</th><th>结果</th><th>盈亏</th><th>标签</th><th>备注</th><th>操作</th>" +
"</tr></thead><tbody>" + "</tr></thead><tbody>" +
trades dailyTrades
.map(function (t) { .map(function (t) {
const tid = t.trade_id || t.id; const tid = t.trade_id || t.id;
const active = String(tid) === String(selectedTradeId) ? " is-active" : ""; const exKey = t.exchange_key || "";
const tag = t.behavior_tag || ""; const tag = t.behavior_tag || "";
const sick = tag === "sick";
const active = String(tid) === String(selectedTradeId) ? " is-active" : "";
const rev = reviewMark(t); const rev = reviewMark(t);
return ( return (
'<tr class="archive-trade-row' + '<tr class="archive-trade-row' +
active + active +
(sick ? " archive-trade-sick" : "") +
'" data-id="' + '" data-id="' +
tid + tid +
'" data-ex="' +
esc(exKey) +
'">' + '">' +
'<td' + "<td>" +
(rev ? ' title="复盘记录"' : "") + esc(exchangeLabel(exKey)) +
">" +
(rev ? '<span class="archive-review-mark">' + rev + "</span>" : "") +
fmtEntryType(t) +
"</td>" + "</td>" +
'<td class="archive-dt"' + "<td>" +
(rev ? ' title="复盘记录"' : "") +
">" +
(rev ? '<span class="archive-review-mark">' + rev + "</span>" : "") + (rev ? '<span class="archive-review-mark">' + rev + "</span>" : "") +
esc(fmtEntryType(t)) +
"</td>" +
'<td class="archive-dt">' +
fmtDt(t.opened_at) + fmtDt(t.opened_at) +
"</td>" + "</td>" +
'<td class="archive-dt"' + '<td class="archive-dt">' +
(rev ? ' title="复盘记录"' : "") +
">" +
(rev ? '<span class="archive-review-mark">' + rev + "</span>" : "") +
fmtDt(t.closed_at) + fmtDt(t.closed_at) +
"</td>" + "</td>" +
'<td class="archive-hold"' + '<td class="archive-hold">' +
(rev ? ' title="复盘记录"' : "") +
">" +
(rev ? '<span class="archive-review-mark">' + rev + "</span>" : "") +
fmtHoldMinutes(t) + fmtHoldMinutes(t) +
"</td>" + "</td>" +
"<td>" + "<td>" +
(t.direction || "—") + esc(t.direction || "—") +
"</td>" + "</td>" +
"<td>" + "<td>" +
(t.result || "—") + esc(t.result || "—") +
"</td>" + "</td>" +
'<td class="' + '<td class="' +
pnlClass(t.pnl_amount) + pnlClass(t.pnl_amount) +
@@ -672,6 +815,8 @@
"</td>" + "</td>" +
'<td><select class="archive-tag-select" data-id="' + '<td><select class="archive-tag-select" data-id="' +
tid + tid +
'" data-ex="' +
esc(exKey) +
'">' + '">' +
'<option value=""' + '<option value=""' +
(tag === "" ? " selected" : "") + (tag === "" ? " selected" : "") +
@@ -685,13 +830,19 @@
"</select></td>" + "</select></td>" +
'<td><input class="archive-note-input" data-id="' + '<td><input class="archive-note-input" data-id="' +
tid + tid +
'" data-ex="' +
esc(exKey) +
'" value="' + '" value="' +
String(t.note || "").replace(/"/g, "&quot;") + esc(t.note || "") +
'" placeholder="备注" /></td>' + '" placeholder="备注" /></td>' +
'<td><button type="button" class="archive-del-btn" data-id="' + '<td class="archive-actions-cell">' +
'<button type="button" class="ghost archive-chart-btn" data-id="' +
tid + tid +
'" title="从档案移除(不影响实例复盘库)">删除</button></td>' + '">图表</button>' +
"</tr>" '<button type="button" class="archive-del-btn" data-id="' +
tid +
'">删除</button>' +
"</td></tr>"
); );
}) })
.join("") + .join("") +
@@ -700,48 +851,49 @@
elTrades.querySelectorAll(".archive-del-btn").forEach(function (btn) { elTrades.querySelectorAll(".archive-del-btn").forEach(function (btn) {
btn.addEventListener("click", function (ev) { btn.addEventListener("click", function (ev) {
ev.stopPropagation(); ev.stopPropagation();
void deleteTrade(btn.getAttribute("data-id")); const row = btn.closest(".archive-trade-row");
void deleteTrade(btn.getAttribute("data-id"), row && row.getAttribute("data-ex"));
}); });
}); });
elTrades.querySelectorAll(".archive-trade-row").forEach(function (row) { elTrades.querySelectorAll(".archive-chart-btn").forEach(function (btn) {
row.addEventListener("click", function (ev) { btn.addEventListener("click", function (ev) {
if ( ev.stopPropagation();
ev.target.closest("select") || const row = btn.closest(".archive-trade-row");
ev.target.closest("input") || const tid = btn.getAttribute("data-id");
ev.target.closest(".archive-del-btn") const tr = dailyTrades.find(function (t) {
) { return String(t.trade_id || t.id) === String(tid);
return; });
} if (tr) void openTradeChart(tr);
selectedTradeId = row.getAttribute("data-id"); else if (row) {
renderTrades(); selectedTradeId = tid;
applyChartMarkers(); renderTrades();
const trSel = pickAnchorTrade();
if (trSel && tradeOpenMs(trSel) && tradeCloseMs(trSel)) {
focusInitialTradeView(lastCandles, trSel, timeframe);
} }
}); });
}); });
elTrades.querySelectorAll(".archive-tag-select").forEach(function (sel) { elTrades.querySelectorAll(".archive-tag-select").forEach(function (sel) {
sel.addEventListener("change", function () { sel.addEventListener("change", function () {
saveOverlay(sel.getAttribute("data-id"), sel.value, null); saveOverlay(sel.getAttribute("data-id"), sel.getAttribute("data-ex"), sel.value, null);
}); });
}); });
elTrades.querySelectorAll(".archive-note-input").forEach(function (inp) { elTrades.querySelectorAll(".archive-note-input").forEach(function (inp) {
inp.addEventListener("change", function () { inp.addEventListener("change", function () {
const row = inp.closest(".archive-trade-row"); const row = inp.closest(".archive-trade-row");
const tagSel = row && row.querySelector(".archive-tag-select"); const tagSel = row && row.querySelector(".archive-tag-select");
saveOverlay(inp.getAttribute("data-id"), tagSel ? tagSel.value : "", inp.value); saveOverlay(
inp.getAttribute("data-id"),
inp.getAttribute("data-ex"),
tagSel ? tagSel.value : "",
inp.value
);
}); });
}); });
} }
async function deleteTrade(tradeId) { async function deleteTrade(tradeId, exchangeKey) {
if (!selected || tradeId == null) return; const exKey = exchangeKey || (selected && selected.exchange_key);
if (!window.confirm("从币种档案移除该笔交易?(不影响交易所实例里的复盘记录)")) return; if (!exKey || tradeId == null) return;
const r = await apiFetch( if (!window.confirm("从档案移除该笔交易?(不影响交易所实例里的复盘记录)")) return;
"/api/archive/trade/" + selected.exchange_key + "/" + tradeId, const r = await apiFetch("/api/archive/trade/" + exKey + "/" + tradeId, { method: "DELETE" });
{ method: "DELETE" }
);
if (!r.ok) { if (!r.ok) {
const j = await r.json().catch(function () { const j = await r.json().catch(function () {
return {}; return {};
@@ -749,82 +901,53 @@
setStatus(j.detail || j.msg || "删除失败"); setStatus(j.detail || j.msg || "删除失败");
return; return;
} }
if (String(selectedTradeId) === String(tradeId)) { if (String(selectedTradeId) === String(tradeId)) selectedTradeId = null;
selectedTradeId = null; await loadDailyTrades();
}
trades = trades.filter(function (t) {
return String(t.trade_id || t.id) !== String(tradeId);
});
renderTrades();
applyChartMarkers();
await loadList();
setStatus("已移除 1 笔档案记录"); setStatus("已移除 1 笔档案记录");
} }
async function saveOverlay(tradeId, tag, note) { async function saveOverlay(tradeId, exchangeKey, tag, note) {
if (!selected) return; const exKey = exchangeKey || (selected && selected.exchange_key);
if (!exKey) return;
const body = { behavior_tag: tag || "", note: note != null ? note : undefined }; const body = { behavior_tag: tag || "", note: note != null ? note : undefined };
if (note == null) { if (note == null) {
const row = elTrades.querySelector('.archive-trade-row[data-id="' + tradeId + '"]'); const row = elTrades.querySelector('.archive-trade-row[data-id="' + tradeId + '"]');
const inp = row && row.querySelector(".archive-note-input"); const inp = row && row.querySelector(".archive-note-input");
body.note = inp ? inp.value : ""; body.note = inp ? inp.value : "";
} }
await apiFetch("/api/archive/trade/" + selected.exchange_key + "/" + tradeId, { await apiFetch("/api/archive/trade/" + exKey + "/" + tradeId, {
method: "PATCH", method: "PATCH",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify(body), body: JSON.stringify(body),
}); });
const tr = trades.find(function (t) { const tr = dailyTrades.find(function (t) {
return String(t.trade_id || t.id) === String(tradeId); return String(t.trade_id || t.id) === String(tradeId);
}); });
if (tr) { if (tr) {
tr.behavior_tag = body.behavior_tag; tr.behavior_tag = body.behavior_tag;
tr.note = body.note; 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(); renderTrades();
await loadChart();
} }
async function loadList() { async function loadDailyTrades() {
setStatus("加载列表…"); setStatus("加载交易记录…");
const r = await apiFetch("/api/archive/list?" + queryListParams()); const r = await apiFetch("/api/archive/daily-trades?" + queryDailyParams());
const j = await r.json(); const j = await r.json();
listRows = j.rows || []; if (!r.ok) {
renderList(); setStatus(j.detail || "加载失败");
setStatus("共 " + listRows.length + " 个币种档案 · " + new Date().toLocaleTimeString()); return;
}
tradingDay = j.trading_day || tradingDay;
if (elTradingDay && tradingDay && !elTradingDay.value) elTradingDay.value = tradingDay;
if (elQuoteDate && tradingDay && !elQuoteDate.value) elQuoteDate.value = tradingDay;
dailyTrades = j.trades || [];
dailyStats = j.stats || { open_count: 0, by_exchange: {} };
renderStats();
renderTrades();
setStatus(
(tradingDay || "当日") + " · " + dailyTrades.length + " 笔 · " + new Date().toLocaleTimeString()
);
} }
async function loadMeta() { async function loadMeta() {
@@ -850,14 +973,10 @@
const parts = ["同步完成 · " + okN + "/" + (j.exchanges || 0) + " 所"]; const parts = ["同步完成 · " + okN + "/" + (j.exchanges || 0) + " 所"];
results.forEach(function (row) { results.forEach(function (row) {
const label = row.exchange_key || row.name || "?"; const label = row.exchange_key || row.name || "?";
if (row.ok === false) { if (row.ok === false) parts.push(label + " 失败: " + (row.msg || "未知错误"));
parts.push(label + " 失败: " + (row.msg || "未知错误")); else {
} else { let line = label + " " + (row.trade_count != null ? row.trade_count : row.trades || 0) + " 笔";
let line = if (row.trades_removed > 0) line += " 清" + row.trades_removed;
label + " " + (row.trade_count != null ? row.trade_count : row.trades || 0) + " 笔";
if (row.trades_removed > 0) {
line += " 清" + row.trades_removed;
}
parts.push(line); parts.push(line);
} }
}); });
@@ -866,27 +985,50 @@
async function syncAll() { async function syncAll() {
setStatus("同步中(可能需数分钟)…"); setStatus("同步中(可能需数分钟)…");
elBtnSync && (elBtnSync.disabled = true); if (elBtnSync) elBtnSync.disabled = true;
try { try {
const r = await apiFetch("/api/archive/sync", { method: "POST" }); const r = await apiFetch("/api/archive/sync", { method: "POST" });
const j = await r.json(); const j = await r.json();
setStatus(formatSyncSummary(j)); setStatus(formatSyncSummary(j));
await loadList(); await loadDailyTrades();
if (selected) await openDetail(selected.exchange_key, selected.symbol); await loadQuotes();
if (isChartOpen() && selected) await loadChart();
} catch (e) { } catch (e) {
setStatus(String(e)); setStatus(String(e));
} finally { } finally {
elBtnSync && (elBtnSync.disabled = false); if (elBtnSync) elBtnSync.disabled = false;
} }
} }
function bindEvents() { function bindEvents() {
if (elBtnRefresh) elBtnRefresh.addEventListener("click", loadList); if (elBtnRefresh) elBtnRefresh.addEventListener("click", loadDailyTrades);
if (elBtnSync) elBtnSync.addEventListener("click", syncAll); if (elBtnSync) elBtnSync.addEventListener("click", syncAll);
if (elExchange) elExchange.addEventListener("change", loadList); if (elExchange) elExchange.addEventListener("change", loadDailyTrades);
[elFilterProfit, elFilterLoss, elFilterSick, elFilterEmotion].forEach(function (el) { if (elTradingDay) elTradingDay.addEventListener("change", loadDailyTrades);
if (el) el.addEventListener("change", loadList); [elFilterProfit, elFilterLoss, elFilterSick].forEach(function (el) {
if (el) el.addEventListener("change", loadDailyTrades);
}); });
if (elSearch) {
elSearch.addEventListener("input", function () {
clearTimeout(searchTimer);
searchTimer = setTimeout(loadDailyTrades, 320);
});
}
if (elBtnChartToggle) {
elBtnChartToggle.addEventListener("click", function () {
const next = !isChartOpen();
setChartOpen(next);
if (next && selected) void loadChart();
});
}
if (elChartSection) {
elChartSection.addEventListener("toggle", function () {
if (elBtnChartToggle) elBtnChartToggle.classList.toggle("is-active", elChartSection.open);
if (elChartSection.open && selected) void loadChart();
else if (!elChartSection.open) destroyChart();
});
}
if (elQuoteForm) elQuoteForm.addEventListener("submit", addQuote);
if (elTfTabs) { if (elTfTabs) {
elTfTabs.addEventListener("click", function (ev) { elTfTabs.addEventListener("click", function (ev) {
const btn = ev.target.closest(".archive-tf-btn"); const btn = ev.target.closest(".archive-tf-btn");
@@ -908,24 +1050,20 @@
loadChart(); loadChart();
}); });
} }
if (elBtnJump) { if (elBtnJump) elBtnJump.addEventListener("click", loadChart);
elBtnJump.addEventListener("click", function () {
loadChart();
});
}
} }
async function init() { async function init() {
if (!document.getElementById("page-archive") || document.getElementById("page-archive").classList.contains("hidden")) { if (!page || page.classList.contains("hidden")) return;
return;
}
if (!inited) { if (!inited) {
loadMarkAutoPref(); loadMarkAutoPref();
setChartOpen(false);
bindEvents(); bindEvents();
inited = true; inited = true;
} }
await loadMeta(); await loadMeta();
await loadList(); await loadQuotes();
await loadDailyTrades();
} }
function destroy() { function destroy() {
+71 -51
View File
@@ -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=20260612-hub-ai-pwa-nav" /> <link rel="stylesheet" href="/assets/app.css?v=20260612-inner-light-mind" />
<link rel="stylesheet" href="/assets/dashboard.css?v=20260612-dash-monitor-count" /> <link rel="stylesheet" href="/assets/dashboard.css?v=20260612-dash-monitor-count" />
</head> </head>
<body> <body>
@@ -48,7 +48,7 @@
<a href="/funds" id="nav-funds">资金概况</a> <a href="/funds" id="nav-funds">资金概况</a>
<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="/archive" id="nav-archive">内照明心</a>
<a href="/dashboard" id="nav-dashboard">数据看板</a> <a href="/dashboard" id="nav-dashboard">数据看板</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>
@@ -259,58 +259,78 @@
<div id="page-archive" class="page hidden"> <div id="page-archive" class="page hidden">
<div class="page-head"> <div class="page-head">
<h1><span class="head-tag">ARC</span> 币种档案</h1> <h1><span class="head-tag">IN</span> 内照明心</h1>
<p class="page-desc">一所一币一行 · 交易时间线 · 建档 30 天 K 线可拖动缩放(默认聚焦持仓段,不含拉到「现在」)</p> <p class="page-desc">复盘语录 · 当日交易记录 · 按需查看 K 线</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>
<div class="archive-layout"> <div class="archive-layout">
<section class="archive-list-panel"> <aside class="archive-quotes-panel">
<div id="archive-list" class="archive-list" role="list"></div> <div class="archive-panel-head">
</section> <h2>复盘语录</h2>
<section class="archive-detail-panel hidden" id="archive-detail-panel"> <span id="archive-quotes-count" class="archive-panel-meta"></span>
<div class="archive-detail-head">
<h2 id="archive-detail-title"></h2>
<span id="archive-detail-stats" class="archive-detail-stats"></span>
</div> </div>
<div class="archive-chart-toolbar toolbar"> <form id="archive-quote-form" class="archive-quote-form">
<div class="archive-tf-tabs" id="archive-tf-tabs" role="tablist"> <input id="archive-quote-date" type="date" required />
<button type="button" class="archive-tf-btn" data-tf="5m">5m</button> <textarea id="archive-quote-content" rows="3" placeholder="今日复盘心得…" required></textarea>
<button type="button" class="archive-tf-btn is-active" data-tf="15m">15m</button> <button type="submit" class="primary">添加语录</button>
<button type="button" class="archive-tf-btn" data-tf="1h">1h</button> </form>
<button type="button" class="archive-tf-btn" data-tf="4h">4h</button> <div id="archive-quotes-list" class="archive-quotes-list"></div>
</aside>
<main class="archive-main-panel">
<div class="archive-toolbar toolbar">
<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="archive-field">
<span>日期</span>
<input id="archive-trading-day" type="date" />
</label>
<button type="button" id="archive-btn-chart-toggle" class="ghost">图表</button>
<label class="archive-field archive-search-field">
<span>搜索</span>
<input id="archive-search" type="search" placeholder="合约 / 交易所 / 备注" autocomplete="off" />
</label>
<label class="archive-field">
<span>交易所</span>
<select id="archive-exchange"><option value="">全部</option></select>
</label>
<button type="button" id="archive-btn-refresh" class="primary">刷新</button>
<button type="button" id="archive-btn-sync" class="ghost">同步</button>
<span id="archive-status" class="toolbar-meta"></span>
</div>
<div id="archive-stats" class="archive-stats-bar"></div>
<details id="archive-chart-section" class="archive-acc-section archive-chart-section">
<summary class="archive-acc-summary">K 线图表 <span id="archive-chart-title" class="archive-acc-sub"></span></summary>
<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>
<label class="archive-field"> <div class="archive-chart-wrap">
<span>视窗</span> <div id="archive-chart" class="archive-chart-host"></div>
<select id="archive-view-mode"> <button type="button" id="archive-mark-auto" class="archive-mark-auto is-on" title="开启:该币种全部交易均标注开/平;关闭:仅当前选中一笔">自动</button>
<option value="hold">持仓过程(锚平仓)</option> </div>
<option value="entry">进场决策(锚开仓)</option> </details>
</select> <details id="archive-trades-section" class="archive-acc-section archive-trades-section" open>
</label> <summary class="archive-acc-summary">交易记录</summary>
<label class="archive-field"> <div id="archive-trades" class="archive-trades"></div>
<span>跳转时间</span> </details>
<input id="archive-jump-at" type="text" placeholder="2026-06-07 14:30" autocomplete="off" /> </main>
</label>
<button type="button" id="archive-btn-jump" class="ghost">跳转</button>
<button type="button" id="archive-btn-reload-chart" class="primary">重载图表</button>
</div>
<div class="archive-chart-wrap">
<div id="archive-chart" class="archive-chart-host"></div>
<button type="button" id="archive-mark-auto" class="archive-mark-auto is-on" title="开启:该币种全部交易均标注开/平;关闭:仅当前选中一笔">自动</button>
</div>
<div id="archive-trades" class="archive-trades"></div>
</section>
</div> </div>
</div> </div>
@@ -553,7 +573,7 @@
<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_draw.js?v=20260609-market-day-split"></script> <script src="/assets/chart_draw.js?v=20260609-market-day-split"></script>
<script src="/assets/chart.js?v=20260609-market-day-split"></script> <script src="/assets/chart.js?v=20260609-market-day-split"></script>
<script src="/assets/archive.js?v=20260608-hub-archive-history"></script> <script src="/assets/archive.js?v=20260612-inner-light-mind"></script>
<script src="/assets/funds.js?v=20260609-hub-funds-fold"></script> <script src="/assets/funds.js?v=20260609-hub-funds-fold"></script>
<script src="/assets/dashboard.js?v=20260612-dash-monitor-count"></script> <script src="/assets/dashboard.js?v=20260612-dash-monitor-count"></script>
<script src="/assets/ai_review_render.js?v=2"></script> <script src="/assets/ai_review_render.js?v=2"></script>