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:
+254
-1
@@ -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()
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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, "&")
|
||||||
|
.replace(/</g, "<")
|
||||||
|
.replace(/>/g, ">")
|
||||||
|
.replace(/"/g, """);
|
||||||
|
}
|
||||||
|
|
||||||
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, """) +
|
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() {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user