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 sqlite3
import time
from datetime import datetime, timezone
from datetime import datetime, timedelta, timezone
from pathlib import Path
from typing import Any, Callable, Optional
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_TRADE_DAYS = int(os.getenv("HUB_ARCHIVE_TRADE_DAYS", "365"))
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"})
@@ -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:
conn.close()
@@ -1121,3 +1141,236 @@ def sync_exchange_symbol_archives(
"appended_bars": appended,
"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()