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 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()
|
||||
|
||||
Reference in New Issue
Block a user