Add entry plan page with CRUD, archive flow, and win-rate stats.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,448 @@
|
|||||||
|
"""中控开仓计划:进行中 / 历史归档 / 胜率统计。"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sqlite3
|
||||||
|
import time
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
from zoneinfo import ZoneInfo
|
||||||
|
|
||||||
|
PLAN_TYPES = {
|
||||||
|
"trend": "趋势单",
|
||||||
|
"swing": "波段单",
|
||||||
|
"intraday": "日内短线",
|
||||||
|
}
|
||||||
|
TREND_TIMEFRAMES = ("5m", "15m", "30m", "1h", "4h", "1d")
|
||||||
|
ENTRY_TIMEFRAMES = ("1m", "5m", "15m", "30m", "1h")
|
||||||
|
DIRECTIONS = {"long": "多", "short": "空"}
|
||||||
|
ENTRY_SCHEMES = {
|
||||||
|
"breakout": "突破方案",
|
||||||
|
"false_breakout": "假突破突破方案",
|
||||||
|
"box_inflection": "箱体拐点方案",
|
||||||
|
}
|
||||||
|
RESULTS = {"win": "盈", "loss": "亏"}
|
||||||
|
STAT_DIMENSIONS = ("symbol", "trend_tf", "entry_scheme")
|
||||||
|
|
||||||
|
DISPLAY_TZ = ZoneInfo(
|
||||||
|
(os.getenv("HUB_ENTRY_PLAN_TZ") or os.getenv("HUB_VOLUME_RANK_TZ") or "Asia/Shanghai").strip()
|
||||||
|
or "Asia/Shanghai"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def default_db_path() -> Path:
|
||||||
|
raw = (os.getenv("HUB_ENTRY_PLAN_DB_PATH") or "").strip()
|
||||||
|
if raw:
|
||||||
|
return Path(raw)
|
||||||
|
hub_dir = Path(__file__).resolve().parent / "manual_trading_hub" / "data"
|
||||||
|
hub_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
return hub_dir / "hub_entry_plans.db"
|
||||||
|
|
||||||
|
|
||||||
|
def _now_ms() -> int:
|
||||||
|
return int(time.time() * 1000)
|
||||||
|
|
||||||
|
|
||||||
|
def _connect(db_path: Path | None = None) -> sqlite3.Connection:
|
||||||
|
path = db_path or default_db_path()
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
conn = sqlite3.connect(str(path), timeout=30, isolation_level=None)
|
||||||
|
conn.row_factory = sqlite3.Row
|
||||||
|
conn.execute("PRAGMA journal_mode=WAL")
|
||||||
|
conn.execute("PRAGMA synchronous=NORMAL")
|
||||||
|
return conn
|
||||||
|
|
||||||
|
|
||||||
|
def init_db(db_path: Path | None = None) -> None:
|
||||||
|
conn = _connect(db_path)
|
||||||
|
try:
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS entry_plans (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
plan_date TEXT NOT NULL,
|
||||||
|
exchange_key TEXT NOT NULL,
|
||||||
|
symbol TEXT NOT NULL,
|
||||||
|
plan_type TEXT NOT NULL,
|
||||||
|
trend_timeframe TEXT NOT NULL,
|
||||||
|
entry_timeframe TEXT NOT NULL,
|
||||||
|
direction TEXT NOT NULL,
|
||||||
|
target_level TEXT NOT NULL DEFAULT '',
|
||||||
|
current_range TEXT NOT NULL DEFAULT '',
|
||||||
|
entry_scheme TEXT NOT NULL,
|
||||||
|
result TEXT,
|
||||||
|
pnl_amount REAL,
|
||||||
|
note TEXT NOT NULL DEFAULT '',
|
||||||
|
status TEXT NOT NULL DEFAULT 'active',
|
||||||
|
created_at INTEGER NOT NULL,
|
||||||
|
updated_at INTEGER NOT NULL,
|
||||||
|
archived_at INTEGER
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_entry_plans_status_date
|
||||||
|
ON entry_plans (status, plan_date DESC, id DESC)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_plan_symbol(raw: str) -> str:
|
||||||
|
s = str(raw or "").strip().upper()
|
||||||
|
if not s:
|
||||||
|
raise ValueError("缺少币种")
|
||||||
|
if ":" in s:
|
||||||
|
s = s.split(":", 1)[0]
|
||||||
|
if "/" in s:
|
||||||
|
base, quote = s.split("/", 1)
|
||||||
|
base = base.strip()
|
||||||
|
quote = (quote or "USDT").strip() or "USDT"
|
||||||
|
if not base:
|
||||||
|
raise ValueError("币种无效")
|
||||||
|
return f"{base}/{quote}"
|
||||||
|
if s.endswith("USDT") and len(s) > 4:
|
||||||
|
return f"{s[:-4]}/{s[-4:]}"
|
||||||
|
return f"{s}/USDT"
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_choice(value: str, allowed: dict[str, str] | tuple[str, ...], field: str) -> str:
|
||||||
|
key = str(value or "").strip().lower()
|
||||||
|
if isinstance(allowed, dict):
|
||||||
|
if key not in allowed:
|
||||||
|
raise ValueError(f"{field} 无效")
|
||||||
|
return key
|
||||||
|
if key not in allowed:
|
||||||
|
raise ValueError(f"{field} 无效")
|
||||||
|
return key
|
||||||
|
|
||||||
|
|
||||||
|
def _row_to_dict(row: sqlite3.Row | None) -> dict[str, Any] | None:
|
||||||
|
if row is None:
|
||||||
|
return None
|
||||||
|
d = dict(row)
|
||||||
|
d["plan_type_label"] = PLAN_TYPES.get(d.get("plan_type") or "", d.get("plan_type") or "")
|
||||||
|
d["direction_label"] = DIRECTIONS.get(d.get("direction") or "", d.get("direction") or "")
|
||||||
|
d["entry_scheme_label"] = ENTRY_SCHEMES.get(
|
||||||
|
d.get("entry_scheme") or "", d.get("entry_scheme") or ""
|
||||||
|
)
|
||||||
|
res = d.get("result")
|
||||||
|
d["result_label"] = RESULTS.get(res, "") if res else ""
|
||||||
|
return d
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_optional_pnl(raw: Any) -> float | None:
|
||||||
|
if raw is None or raw == "":
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return round(float(raw), 4)
|
||||||
|
except (TypeError, ValueError) as e:
|
||||||
|
raise ValueError("盈亏金额无效") from e
|
||||||
|
|
||||||
|
|
||||||
|
def create_entry_plan(payload: dict[str, Any], *, db_path: Path | None = None) -> dict[str, Any]:
|
||||||
|
init_db(db_path)
|
||||||
|
plan_date = str(payload.get("plan_date") or "").strip()[:10]
|
||||||
|
if not plan_date:
|
||||||
|
raise ValueError("缺少 plan_date")
|
||||||
|
exchange_key = str(payload.get("exchange_key") or "").strip().lower()
|
||||||
|
if not exchange_key:
|
||||||
|
raise ValueError("缺少 exchange_key")
|
||||||
|
symbol = normalize_plan_symbol(payload.get("symbol") or "")
|
||||||
|
plan_type = _validate_choice(payload.get("plan_type"), PLAN_TYPES, "类型")
|
||||||
|
trend_tf = _validate_choice(payload.get("trend_timeframe"), TREND_TIMEFRAMES, "趋势周期")
|
||||||
|
entry_tf = _validate_choice(payload.get("entry_timeframe"), ENTRY_TIMEFRAMES, "入场周期")
|
||||||
|
direction = _validate_choice(payload.get("direction"), DIRECTIONS, "方向")
|
||||||
|
entry_scheme = _validate_choice(payload.get("entry_scheme"), ENTRY_SCHEMES, "入场方案")
|
||||||
|
target_level = str(payload.get("target_level") or "").strip()
|
||||||
|
current_range = str(payload.get("current_range") or "").strip()
|
||||||
|
note = str(payload.get("note") or "").strip()
|
||||||
|
now = _now_ms()
|
||||||
|
conn = _connect(db_path)
|
||||||
|
try:
|
||||||
|
cur = conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO entry_plans (
|
||||||
|
plan_date, exchange_key, symbol, plan_type, trend_timeframe, entry_timeframe,
|
||||||
|
direction, target_level, current_range, entry_scheme, note, status,
|
||||||
|
created_at, updated_at
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'active', ?, ?)
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
plan_date,
|
||||||
|
exchange_key,
|
||||||
|
symbol,
|
||||||
|
plan_type,
|
||||||
|
trend_tf,
|
||||||
|
entry_tf,
|
||||||
|
direction,
|
||||||
|
target_level,
|
||||||
|
current_range,
|
||||||
|
entry_scheme,
|
||||||
|
note,
|
||||||
|
now,
|
||||||
|
now,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
row = conn.execute(
|
||||||
|
"SELECT * FROM entry_plans WHERE id=?",
|
||||||
|
(int(cur.lastrowid),),
|
||||||
|
).fetchone()
|
||||||
|
return _row_to_dict(row) or {}
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def list_entry_plans(
|
||||||
|
*,
|
||||||
|
status: str = "active",
|
||||||
|
db_path: Path | None = None,
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
init_db(db_path)
|
||||||
|
st = (status or "active").strip().lower()
|
||||||
|
if st not in ("active", "archived"):
|
||||||
|
raise ValueError("status 无效")
|
||||||
|
conn = _connect(db_path)
|
||||||
|
try:
|
||||||
|
rows = conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT * FROM entry_plans
|
||||||
|
WHERE status=?
|
||||||
|
ORDER BY plan_date DESC, id DESC
|
||||||
|
""",
|
||||||
|
(st,),
|
||||||
|
).fetchall()
|
||||||
|
return [_row_to_dict(r) for r in rows if r]
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def get_entry_plan(plan_id: int, *, db_path: Path | None = None) -> dict[str, Any] | None:
|
||||||
|
init_db(db_path)
|
||||||
|
conn = _connect(db_path)
|
||||||
|
try:
|
||||||
|
row = conn.execute("SELECT * FROM entry_plans WHERE id=?", (int(plan_id),)).fetchone()
|
||||||
|
return _row_to_dict(row)
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def update_entry_plan(
|
||||||
|
plan_id: int,
|
||||||
|
payload: dict[str, Any],
|
||||||
|
*,
|
||||||
|
db_path: Path | None = None,
|
||||||
|
) -> dict[str, Any] | None:
|
||||||
|
init_db(db_path)
|
||||||
|
conn = _connect(db_path)
|
||||||
|
try:
|
||||||
|
row = conn.execute("SELECT * FROM entry_plans WHERE id=?", (int(plan_id),)).fetchone()
|
||||||
|
if not row:
|
||||||
|
return None
|
||||||
|
if row["status"] == "archived":
|
||||||
|
raise ValueError("已归档计划不可修改")
|
||||||
|
fields: dict[str, Any] = {}
|
||||||
|
if "plan_date" in payload:
|
||||||
|
qd = str(payload.get("plan_date") or "").strip()[:10]
|
||||||
|
if not qd:
|
||||||
|
raise ValueError("缺少 plan_date")
|
||||||
|
fields["plan_date"] = qd
|
||||||
|
if "exchange_key" in payload:
|
||||||
|
ex = str(payload.get("exchange_key") or "").strip().lower()
|
||||||
|
if not ex:
|
||||||
|
raise ValueError("缺少 exchange_key")
|
||||||
|
fields["exchange_key"] = ex
|
||||||
|
if "symbol" in payload:
|
||||||
|
fields["symbol"] = normalize_plan_symbol(payload.get("symbol") or "")
|
||||||
|
if "plan_type" in payload:
|
||||||
|
fields["plan_type"] = _validate_choice(payload.get("plan_type"), PLAN_TYPES, "类型")
|
||||||
|
if "trend_timeframe" in payload:
|
||||||
|
fields["trend_timeframe"] = _validate_choice(
|
||||||
|
payload.get("trend_timeframe"), TREND_TIMEFRAMES, "趋势周期"
|
||||||
|
)
|
||||||
|
if "entry_timeframe" in payload:
|
||||||
|
fields["entry_timeframe"] = _validate_choice(
|
||||||
|
payload.get("entry_timeframe"), ENTRY_TIMEFRAMES, "入场周期"
|
||||||
|
)
|
||||||
|
if "direction" in payload:
|
||||||
|
fields["direction"] = _validate_choice(payload.get("direction"), DIRECTIONS, "方向")
|
||||||
|
if "entry_scheme" in payload:
|
||||||
|
fields["entry_scheme"] = _validate_choice(
|
||||||
|
payload.get("entry_scheme"), ENTRY_SCHEMES, "入场方案"
|
||||||
|
)
|
||||||
|
if "target_level" in payload:
|
||||||
|
fields["target_level"] = str(payload.get("target_level") or "").strip()
|
||||||
|
if "current_range" in payload:
|
||||||
|
fields["current_range"] = str(payload.get("current_range") or "").strip()
|
||||||
|
if "note" in payload:
|
||||||
|
fields["note"] = str(payload.get("note") or "").strip()
|
||||||
|
if "pnl_amount" in payload:
|
||||||
|
fields["pnl_amount"] = _parse_optional_pnl(payload.get("pnl_amount"))
|
||||||
|
archive_now = False
|
||||||
|
if "result" in payload:
|
||||||
|
res_raw = payload.get("result")
|
||||||
|
if res_raw is None or str(res_raw).strip() == "":
|
||||||
|
fields["result"] = None
|
||||||
|
else:
|
||||||
|
fields["result"] = _validate_choice(res_raw, RESULTS, "结果")
|
||||||
|
archive_now = True
|
||||||
|
if not fields:
|
||||||
|
return _row_to_dict(row)
|
||||||
|
now = _now_ms()
|
||||||
|
fields["updated_at"] = now
|
||||||
|
if archive_now:
|
||||||
|
fields["status"] = "archived"
|
||||||
|
fields["archived_at"] = now
|
||||||
|
sets = ", ".join(f"{k}=?" for k in fields)
|
||||||
|
conn.execute(
|
||||||
|
f"UPDATE entry_plans SET {sets} WHERE id=?",
|
||||||
|
(*fields.values(), int(plan_id)),
|
||||||
|
)
|
||||||
|
updated = conn.execute("SELECT * FROM entry_plans WHERE id=?", (int(plan_id),)).fetchone()
|
||||||
|
return _row_to_dict(updated)
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def delete_entry_plan(plan_id: int, *, db_path: Path | None = None) -> bool:
|
||||||
|
init_db(db_path)
|
||||||
|
conn = _connect(db_path)
|
||||||
|
try:
|
||||||
|
row = conn.execute("SELECT status FROM entry_plans WHERE id=?", (int(plan_id),)).fetchone()
|
||||||
|
if not row:
|
||||||
|
return False
|
||||||
|
if row["status"] != "active":
|
||||||
|
raise ValueError("仅进行中的计划可删除")
|
||||||
|
cur = conn.execute("DELETE FROM entry_plans WHERE id=? AND status='active'", (int(plan_id),))
|
||||||
|
return int(cur.rowcount or 0) > 0
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def _today_iso() -> str:
|
||||||
|
return datetime.now(DISPLAY_TZ).strftime("%Y-%m-%d")
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_stats_date_bounds(
|
||||||
|
*,
|
||||||
|
period: str = "all",
|
||||||
|
date_from: str = "",
|
||||||
|
date_to: str = "",
|
||||||
|
) -> tuple[str | None, str | None, str]:
|
||||||
|
"""返回 (date_from, date_to, label);all 时 bounds 为 None。"""
|
||||||
|
p = (period or "all").strip().lower() or "all"
|
||||||
|
today = _today_iso()
|
||||||
|
if p == "all":
|
||||||
|
return None, None, "全部历史"
|
||||||
|
if p == "week":
|
||||||
|
day_dt = datetime.strptime(today, "%Y-%m-%d")
|
||||||
|
monday = (day_dt - timedelta(days=day_dt.weekday())).strftime("%Y-%m-%d")
|
||||||
|
return monday, today, f"本周 {monday}~{today}"
|
||||||
|
if p == "month":
|
||||||
|
day_dt = datetime.strptime(today, "%Y-%m-%d")
|
||||||
|
first = day_dt.replace(day=1).strftime("%Y-%m-%d")
|
||||||
|
return first, today, f"本月 {first}~{today}"
|
||||||
|
if p == "range":
|
||||||
|
df = (date_from or "").strip()[:10] or today
|
||||||
|
dt = (date_to or "").strip()[:10] or df
|
||||||
|
if df > dt:
|
||||||
|
df, dt = dt, df
|
||||||
|
label = f"区间 {df}~{dt}" if df != dt else f"区间 {df}"
|
||||||
|
return df, dt, label
|
||||||
|
return None, None, "全部历史"
|
||||||
|
|
||||||
|
|
||||||
|
def compute_entry_plan_stats(
|
||||||
|
*,
|
||||||
|
dimension: str = "symbol",
|
||||||
|
period: str = "all",
|
||||||
|
date_from: str = "",
|
||||||
|
date_to: str = "",
|
||||||
|
db_path: Path | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
init_db(db_path)
|
||||||
|
dim = (dimension or "symbol").strip().lower()
|
||||||
|
if dim not in STAT_DIMENSIONS:
|
||||||
|
raise ValueError("dimension 无效")
|
||||||
|
df_bound, dt_bound, period_label = resolve_stats_date_bounds(
|
||||||
|
period=period, date_from=date_from, date_to=date_to
|
||||||
|
)
|
||||||
|
col_map = {
|
||||||
|
"symbol": "symbol",
|
||||||
|
"trend_tf": "trend_timeframe",
|
||||||
|
"entry_scheme": "entry_scheme",
|
||||||
|
}
|
||||||
|
col = col_map[dim]
|
||||||
|
conn = _connect(db_path)
|
||||||
|
try:
|
||||||
|
where = "status='archived' AND result IN ('win','loss')"
|
||||||
|
params: list[Any] = []
|
||||||
|
if df_bound:
|
||||||
|
where += " AND plan_date >= ? AND plan_date <= ?"
|
||||||
|
params.extend([df_bound, dt_bound])
|
||||||
|
rows = conn.execute(
|
||||||
|
f"""
|
||||||
|
SELECT {col} AS dim_key,
|
||||||
|
COUNT(*) AS total,
|
||||||
|
SUM(CASE WHEN result='win' THEN 1 ELSE 0 END) AS win_count,
|
||||||
|
SUM(CASE WHEN result='loss' THEN 1 ELSE 0 END) AS loss_count
|
||||||
|
FROM entry_plans
|
||||||
|
WHERE {where}
|
||||||
|
GROUP BY {col}
|
||||||
|
ORDER BY total DESC, dim_key ASC
|
||||||
|
""",
|
||||||
|
params,
|
||||||
|
).fetchall()
|
||||||
|
items = []
|
||||||
|
for r in rows:
|
||||||
|
total = int(r["total"] or 0)
|
||||||
|
wins = int(r["win_count"] or 0)
|
||||||
|
losses = int(r["loss_count"] or 0)
|
||||||
|
key = str(r["dim_key"] or "")
|
||||||
|
label = key
|
||||||
|
if dim == "entry_scheme":
|
||||||
|
label = ENTRY_SCHEMES.get(key, key)
|
||||||
|
elif dim == "trend_tf":
|
||||||
|
label = key
|
||||||
|
win_rate = round(wins / total * 100, 1) if total else None
|
||||||
|
items.append(
|
||||||
|
{
|
||||||
|
"key": key,
|
||||||
|
"label": label,
|
||||||
|
"total": total,
|
||||||
|
"win_count": wins,
|
||||||
|
"loss_count": losses,
|
||||||
|
"win_rate": win_rate,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"dimension": dim,
|
||||||
|
"period": period,
|
||||||
|
"period_label": period_label,
|
||||||
|
"date_from": df_bound,
|
||||||
|
"date_to": dt_bound,
|
||||||
|
"items": items,
|
||||||
|
}
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def meta_payload(exchanges: list[dict[str, Any]] | None = None) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"plan_types": [{"value": k, "label": v} for k, v in PLAN_TYPES.items()],
|
||||||
|
"trend_timeframes": list(TREND_TIMEFRAMES),
|
||||||
|
"entry_timeframes": list(ENTRY_TIMEFRAMES),
|
||||||
|
"directions": [{"value": k, "label": v} for k, v in DIRECTIONS.items()],
|
||||||
|
"entry_schemes": [{"value": k, "label": v} for k, v in ENTRY_SCHEMES.items()],
|
||||||
|
"results": [{"value": k, "label": v} for k, v in RESULTS.items()],
|
||||||
|
"stat_dimensions": [
|
||||||
|
{"value": "symbol", "label": "币种"},
|
||||||
|
{"value": "trend_tf", "label": "趋势周期"},
|
||||||
|
{"value": "entry_scheme", "label": "入场方案"},
|
||||||
|
],
|
||||||
|
"exchanges": exchanges or [],
|
||||||
|
}
|
||||||
+118
-1
@@ -60,7 +60,16 @@ from hub_symbol_archive_lib import (
|
|||||||
update_review_quote,
|
update_review_quote,
|
||||||
upsert_trade_overlay,
|
upsert_trade_overlay,
|
||||||
)
|
)
|
||||||
from hub_macro_calendar_lib import (
|
from hub_entry_plan_lib import (
|
||||||
|
compute_entry_plan_stats,
|
||||||
|
create_entry_plan,
|
||||||
|
delete_entry_plan,
|
||||||
|
get_entry_plan,
|
||||||
|
init_db as init_entry_plan_db,
|
||||||
|
list_entry_plans,
|
||||||
|
meta_payload as entry_plan_meta_payload,
|
||||||
|
update_entry_plan,
|
||||||
|
)
|
||||||
MACRO_EVENT_LABELS,
|
MACRO_EVENT_LABELS,
|
||||||
MACRO_EVENT_TYPES,
|
MACRO_EVENT_TYPES,
|
||||||
create_event as create_macro_event,
|
create_event as create_macro_event,
|
||||||
@@ -691,6 +700,7 @@ def root_redirect():
|
|||||||
|
|
||||||
|
|
||||||
@app.get("/monitor")
|
@app.get("/monitor")
|
||||||
|
@app.get("/plan")
|
||||||
@app.get("/market")
|
@app.get("/market")
|
||||||
@app.get("/archive")
|
@app.get("/archive")
|
||||||
@app.get("/dashboard")
|
@app.get("/dashboard")
|
||||||
@@ -2375,6 +2385,113 @@ async def api_archive_sync():
|
|||||||
return body
|
return body
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/entry-plans/meta")
|
||||||
|
def api_entry_plans_meta():
|
||||||
|
init_entry_plan_db()
|
||||||
|
exchanges = []
|
||||||
|
for ex in enabled_exchanges(load_settings()):
|
||||||
|
exchanges.append(
|
||||||
|
{
|
||||||
|
"id": ex.get("id"),
|
||||||
|
"key": ex.get("key"),
|
||||||
|
"name": ex.get("name"),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return {"ok": True, **entry_plan_meta_payload(exchanges)}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/entry-plans")
|
||||||
|
def api_entry_plans_list(status: str = "active"):
|
||||||
|
init_entry_plan_db()
|
||||||
|
try:
|
||||||
|
rows = list_entry_plans(status=status)
|
||||||
|
except ValueError as e:
|
||||||
|
raise HTTPException(status_code=400, detail=str(e)) from e
|
||||||
|
return {"ok": True, "plans": rows, "count": len(rows), "status": status.strip().lower()}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/entry-plans/stats")
|
||||||
|
def api_entry_plan_stats(
|
||||||
|
dimension: str = "symbol",
|
||||||
|
period: str = "all",
|
||||||
|
date_from: str = "",
|
||||||
|
date_to: str = "",
|
||||||
|
):
|
||||||
|
init_entry_plan_db()
|
||||||
|
try:
|
||||||
|
stats = compute_entry_plan_stats(
|
||||||
|
dimension=dimension,
|
||||||
|
period=period,
|
||||||
|
date_from=date_from,
|
||||||
|
date_to=date_to,
|
||||||
|
)
|
||||||
|
except ValueError as e:
|
||||||
|
raise HTTPException(status_code=400, detail=str(e)) from e
|
||||||
|
return {"ok": True, "stats": stats}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/entry-plans/{plan_id}")
|
||||||
|
def api_entry_plan_detail(plan_id: int):
|
||||||
|
init_entry_plan_db()
|
||||||
|
row = get_entry_plan(int(plan_id))
|
||||||
|
if not row:
|
||||||
|
raise HTTPException(status_code=404, detail="计划不存在")
|
||||||
|
return {"ok": True, "plan": row}
|
||||||
|
|
||||||
|
|
||||||
|
class EntryPlanBody(BaseModel):
|
||||||
|
plan_date: str = ""
|
||||||
|
exchange_key: str = ""
|
||||||
|
symbol: str = ""
|
||||||
|
plan_type: str = ""
|
||||||
|
trend_timeframe: str = ""
|
||||||
|
entry_timeframe: str = ""
|
||||||
|
direction: str = ""
|
||||||
|
target_level: str = ""
|
||||||
|
current_range: str = ""
|
||||||
|
entry_scheme: str = ""
|
||||||
|
result: str | None = None
|
||||||
|
pnl_amount: float | None = None
|
||||||
|
note: str = ""
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/entry-plans")
|
||||||
|
def api_entry_plan_create(body: EntryPlanBody = Body(...)):
|
||||||
|
init_entry_plan_db()
|
||||||
|
try:
|
||||||
|
row = create_entry_plan(body.model_dump(exclude_unset=True))
|
||||||
|
except ValueError as e:
|
||||||
|
raise HTTPException(status_code=400, detail=str(e)) from e
|
||||||
|
return {"ok": True, "plan": row}
|
||||||
|
|
||||||
|
|
||||||
|
@app.patch("/api/entry-plans/{plan_id}")
|
||||||
|
def api_entry_plan_update(plan_id: int, body: EntryPlanBody = Body(...)):
|
||||||
|
init_entry_plan_db()
|
||||||
|
payload = body.model_dump(exclude_unset=True)
|
||||||
|
if not payload:
|
||||||
|
raise HTTPException(status_code=400, detail="无更新字段")
|
||||||
|
try:
|
||||||
|
row = update_entry_plan(int(plan_id), payload)
|
||||||
|
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, "plan": row}
|
||||||
|
|
||||||
|
|
||||||
|
@app.delete("/api/entry-plans/{plan_id}")
|
||||||
|
def api_entry_plan_delete(plan_id: int):
|
||||||
|
init_entry_plan_db()
|
||||||
|
try:
|
||||||
|
ok = delete_entry_plan(int(plan_id))
|
||||||
|
except ValueError as e:
|
||||||
|
raise HTTPException(status_code=400, detail=str(e)) from e
|
||||||
|
if not ok:
|
||||||
|
raise HTTPException(status_code=404, detail="计划不存在或已归档")
|
||||||
|
return {"ok": True, "id": int(plan_id)}
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/hub/fund-overview")
|
@app.get("/api/hub/fund-overview")
|
||||||
def api_hub_fund_overview():
|
def api_hub_fund_overview():
|
||||||
from hub_fund_history_lib import build_fund_overview
|
from hub_fund_history_lib import build_fund_overview
|
||||||
|
|||||||
@@ -6456,3 +6456,267 @@ body.funds-fullscreen-open {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* —— 开仓计划 —— */
|
||||||
|
#page-plan .plan-layout {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(320px, 420px) minmax(0, 1fr);
|
||||||
|
gap: 14px;
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
.plan-left-panel,
|
||||||
|
.plan-right-panel {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 14px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.plan-form-section,
|
||||||
|
.plan-active-section,
|
||||||
|
.plan-history-section,
|
||||||
|
.plan-stats-section {
|
||||||
|
background: var(--panel);
|
||||||
|
border: 1px solid var(--border-soft);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
.plan-panel-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
.plan-panel-head h2 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
.plan-panel-meta {
|
||||||
|
font-size: 0.72rem;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
.plan-form-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 8px 10px;
|
||||||
|
}
|
||||||
|
.plan-field {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
}
|
||||||
|
.plan-field-full {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
.plan-field span {
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
.plan-field input,
|
||||||
|
.plan-field select,
|
||||||
|
.plan-field textarea {
|
||||||
|
width: 100%;
|
||||||
|
padding: 7px 9px;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid var(--border-soft);
|
||||||
|
background: var(--inset-surface);
|
||||||
|
color: var(--text);
|
||||||
|
font-family: var(--font);
|
||||||
|
font-size: 0.82rem;
|
||||||
|
}
|
||||||
|
.plan-field-inline {
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
.plan-field-inline span {
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.plan-field-inline input,
|
||||||
|
.plan-field-inline select {
|
||||||
|
width: auto;
|
||||||
|
min-width: 88px;
|
||||||
|
}
|
||||||
|
.plan-radio-row {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
.plan-radio-label {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
}
|
||||||
|
.plan-submit-btn {
|
||||||
|
margin-top: 10px;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
.plan-active-list,
|
||||||
|
.plan-history-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
max-height: 48vh;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
.plan-empty {
|
||||||
|
margin: 0;
|
||||||
|
padding: 12px 4px;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.82rem;
|
||||||
|
}
|
||||||
|
.plan-active-card {
|
||||||
|
border: 1px solid var(--border-soft);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--inset-surface);
|
||||||
|
padding: 10px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
.plan-active-head {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
.plan-active-title {
|
||||||
|
font-size: 0.84rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.plan-active-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.plan-active-meta,
|
||||||
|
.plan-active-levels {
|
||||||
|
font-size: 0.74rem;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
.plan-active-note {
|
||||||
|
font-size: 0.78rem;
|
||||||
|
color: var(--text);
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
.plan-close-row {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: flex-end;
|
||||||
|
margin-top: 4px;
|
||||||
|
padding-top: 8px;
|
||||||
|
border-top: 1px dashed var(--border-soft);
|
||||||
|
}
|
||||||
|
.plan-history-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 92px minmax(0, 1fr) auto auto;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: center;
|
||||||
|
width: 100%;
|
||||||
|
text-align: left;
|
||||||
|
padding: 9px 10px;
|
||||||
|
border: 1px solid var(--border-soft);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--inset-surface);
|
||||||
|
color: var(--text);
|
||||||
|
font-family: var(--font);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.plan-history-row:hover {
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
.plan-history-date {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.74rem;
|
||||||
|
}
|
||||||
|
.plan-history-result.plan-res-win {
|
||||||
|
color: var(--pos);
|
||||||
|
}
|
||||||
|
.plan-history-result.plan-res-loss {
|
||||||
|
color: var(--neg);
|
||||||
|
}
|
||||||
|
.plan-stats-toolbar {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 10px;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
.plan-period-tabs,
|
||||||
|
.plan-dim-tabs {
|
||||||
|
display: inline-flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
.plan-period-btn,
|
||||||
|
.plan-dim-btn {
|
||||||
|
padding: 5px 10px;
|
||||||
|
border-radius: 999px;
|
||||||
|
border: 1px solid var(--border-soft);
|
||||||
|
background: transparent;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.74rem;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.plan-period-btn.is-active,
|
||||||
|
.plan-dim-btn.is-active {
|
||||||
|
border-color: var(--accent);
|
||||||
|
color: var(--accent);
|
||||||
|
background: color-mix(in srgb, var(--accent) 12%, transparent);
|
||||||
|
}
|
||||||
|
.plan-stats-range.hidden {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.plan-period-sep {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.78rem;
|
||||||
|
}
|
||||||
|
.plan-stats-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
.plan-stats-table th,
|
||||||
|
.plan-stats-table td {
|
||||||
|
padding: 7px 10px;
|
||||||
|
border-bottom: 1px solid var(--border-soft);
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
.plan-stats-table th {
|
||||||
|
color: var(--muted);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
.plan-detail-body {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 4px 0 8px;
|
||||||
|
}
|
||||||
|
.plan-detail-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 88px minmax(0, 1fr);
|
||||||
|
gap: 8px;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
}
|
||||||
|
.plan-detail-k {
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
.plan-edit-card {
|
||||||
|
width: min(520px, 94vw);
|
||||||
|
}
|
||||||
|
@media (max-width: 960px) {
|
||||||
|
#page-plan .plan-layout {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
.plan-active-list,
|
||||||
|
.plan-history-list {
|
||||||
|
max-height: none;
|
||||||
|
}
|
||||||
|
.plan-history-row {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1007,6 +1007,7 @@
|
|||||||
if (p.includes("archive")) return "archive";
|
if (p.includes("archive")) return "archive";
|
||||||
if (p.includes("dashboard")) return "dashboard";
|
if (p.includes("dashboard")) return "dashboard";
|
||||||
if (p.includes("funds")) return "funds";
|
if (p.includes("funds")) return "funds";
|
||||||
|
if (p.includes("plan")) return "plan";
|
||||||
if (p.includes("market")) return "market";
|
if (p.includes("market")) return "market";
|
||||||
if (p.includes("/ai")) return "ai";
|
if (p.includes("/ai")) return "ai";
|
||||||
return "monitor";
|
return "monitor";
|
||||||
@@ -1017,6 +1018,7 @@
|
|||||||
if (page === "archive") return "page-archive";
|
if (page === "archive") return "page-archive";
|
||||||
if (page === "dashboard") return "page-dashboard";
|
if (page === "dashboard") return "page-dashboard";
|
||||||
if (page === "funds") return "page-funds";
|
if (page === "funds") return "page-funds";
|
||||||
|
if (page === "plan") return "page-plan";
|
||||||
if (page === "market") return "page-market";
|
if (page === "market") return "page-market";
|
||||||
if (page === "ai") return "page-ai";
|
if (page === "ai") return "page-ai";
|
||||||
return "page-monitor";
|
return "page-monitor";
|
||||||
@@ -1057,6 +1059,11 @@
|
|||||||
} else if (window.hubArchivePage && window.hubArchivePage.destroy) {
|
} else if (window.hubArchivePage && window.hubArchivePage.destroy) {
|
||||||
window.hubArchivePage.destroy();
|
window.hubArchivePage.destroy();
|
||||||
}
|
}
|
||||||
|
if (page === "plan" && window.hubPlanPage) {
|
||||||
|
window.hubPlanPage.init();
|
||||||
|
} else if (window.hubPlanPage && window.hubPlanPage.destroy) {
|
||||||
|
window.hubPlanPage.destroy();
|
||||||
|
}
|
||||||
if (page === "funds" && window.hubFundsPage) {
|
if (page === "funds" && window.hubFundsPage) {
|
||||||
window.hubFundsPage.init();
|
window.hubFundsPage.init();
|
||||||
} else if (window.hubFundsPage && window.hubFundsPage.destroy) {
|
} else if (window.hubFundsPage && window.hubFundsPage.destroy) {
|
||||||
|
|||||||
@@ -48,6 +48,7 @@
|
|||||||
<span id="sys-status" class="sys-pill" title="系统状态">SYNC</span>
|
<span id="sys-status" class="sys-pill" title="系统状态">SYNC</span>
|
||||||
<nav class="top-nav">
|
<nav class="top-nav">
|
||||||
<a href="/funds" id="nav-funds">资金概况</a>
|
<a href="/funds" id="nav-funds">资金概况</a>
|
||||||
|
<a href="/plan" id="nav-plan">开仓计划</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>
|
||||||
@@ -59,6 +60,112 @@
|
|||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
<div id="page-plan" class="page hidden">
|
||||||
|
<div class="page-head">
|
||||||
|
<h1><span class="head-tag">PLN</span> 开仓计划</h1>
|
||||||
|
<p class="page-desc">计划录入 · 进行中跟踪 · 历史归档与胜率统计</p>
|
||||||
|
</div>
|
||||||
|
<div class="plan-layout">
|
||||||
|
<aside class="plan-left-panel">
|
||||||
|
<section class="plan-form-section card">
|
||||||
|
<div class="plan-panel-head">
|
||||||
|
<h2>新建计划</h2>
|
||||||
|
</div>
|
||||||
|
<form id="plan-create-form" class="plan-form">
|
||||||
|
<div class="plan-form-grid">
|
||||||
|
<label class="plan-field">
|
||||||
|
<span>日期</span>
|
||||||
|
<input id="plan-create-date" type="date" required />
|
||||||
|
</label>
|
||||||
|
<label class="plan-field">
|
||||||
|
<span>交易所</span>
|
||||||
|
<select id="plan-create-exchange" required></select>
|
||||||
|
</label>
|
||||||
|
<label class="plan-field">
|
||||||
|
<span>币种</span>
|
||||||
|
<input id="plan-create-symbol" type="text" placeholder="BTC 或 BTC/USDT" required autocomplete="off" />
|
||||||
|
</label>
|
||||||
|
<label class="plan-field">
|
||||||
|
<span>类型</span>
|
||||||
|
<select id="plan-create-type" required></select>
|
||||||
|
</label>
|
||||||
|
<label class="plan-field">
|
||||||
|
<span>趋势周期</span>
|
||||||
|
<select id="plan-create-trend-tf" required></select>
|
||||||
|
</label>
|
||||||
|
<label class="plan-field">
|
||||||
|
<span>入场周期</span>
|
||||||
|
<select id="plan-create-entry-tf" required></select>
|
||||||
|
</label>
|
||||||
|
<label class="plan-field plan-field-dir">
|
||||||
|
<span>方向</span>
|
||||||
|
<span class="plan-radio-row" id="plan-create-direction"></span>
|
||||||
|
</label>
|
||||||
|
<label class="plan-field">
|
||||||
|
<span>目标位</span>
|
||||||
|
<input id="plan-create-target" type="text" placeholder="如 68500" />
|
||||||
|
</label>
|
||||||
|
<label class="plan-field">
|
||||||
|
<span>当前区间</span>
|
||||||
|
<input id="plan-create-range" type="text" placeholder="如 67000-68000" />
|
||||||
|
</label>
|
||||||
|
<label class="plan-field plan-field-full">
|
||||||
|
<span>入场方案</span>
|
||||||
|
<select id="plan-create-scheme" required></select>
|
||||||
|
</label>
|
||||||
|
<label class="plan-field plan-field-full">
|
||||||
|
<span>备注</span>
|
||||||
|
<textarea id="plan-create-note" rows="2" placeholder="计划说明…"></textarea>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="primary plan-submit-btn">保存并进入进行中</button>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
<section class="plan-active-section card">
|
||||||
|
<div class="plan-panel-head">
|
||||||
|
<h2>进行中的计划</h2>
|
||||||
|
<span id="plan-active-count" class="plan-panel-meta"></span>
|
||||||
|
</div>
|
||||||
|
<div id="plan-active-list" class="plan-active-list"></div>
|
||||||
|
</section>
|
||||||
|
</aside>
|
||||||
|
<main class="plan-right-panel">
|
||||||
|
<section class="plan-history-section card">
|
||||||
|
<div class="plan-panel-head">
|
||||||
|
<h2>计划历史</h2>
|
||||||
|
<span id="plan-history-count" class="plan-panel-meta"></span>
|
||||||
|
</div>
|
||||||
|
<div id="plan-history-list" class="plan-history-list"></div>
|
||||||
|
</section>
|
||||||
|
<section class="plan-stats-section card">
|
||||||
|
<div class="plan-panel-head">
|
||||||
|
<h2>数据统计</h2>
|
||||||
|
<span id="plan-stats-label" class="plan-panel-meta"></span>
|
||||||
|
</div>
|
||||||
|
<div class="plan-stats-toolbar">
|
||||||
|
<div class="plan-period-tabs" id="plan-stats-period-tabs" role="tablist">
|
||||||
|
<button type="button" class="plan-period-btn is-active" data-period="all">全部</button>
|
||||||
|
<button type="button" class="plan-period-btn" data-period="week">本周</button>
|
||||||
|
<button type="button" class="plan-period-btn" data-period="month">本月</button>
|
||||||
|
<button type="button" class="plan-period-btn" data-period="range">区间</button>
|
||||||
|
</div>
|
||||||
|
<span id="plan-stats-range-wrap" class="plan-stats-range hidden">
|
||||||
|
<input id="plan-stats-date-from" type="date" title="起始日" />
|
||||||
|
<span class="plan-period-sep">~</span>
|
||||||
|
<input id="plan-stats-date-to" type="date" title="结束日" />
|
||||||
|
</span>
|
||||||
|
<div class="plan-dim-tabs" id="plan-stats-dim-tabs" role="tablist">
|
||||||
|
<button type="button" class="plan-dim-btn is-active" data-dim="symbol">币种</button>
|
||||||
|
<button type="button" class="plan-dim-btn" data-dim="trend_tf">趋势周期</button>
|
||||||
|
<button type="button" class="plan-dim-btn" data-dim="entry_scheme">入场方案</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="plan-stats-table" class="plan-stats-table-wrap"></div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div id="page-monitor" class="page">
|
<div id="page-monitor" class="page">
|
||||||
<div class="page-head">
|
<div class="page-head">
|
||||||
<h1><span class="head-tag">MON</span> 监控区</h1>
|
<h1><span class="head-tag">MON</span> 监控区</h1>
|
||||||
@@ -681,10 +788,33 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div id="plan-detail-modal" class="modal hidden" aria-hidden="true">
|
||||||
|
<div class="modal-backdrop" data-plan-modal-close></div>
|
||||||
|
<div class="modal-card plan-detail-card">
|
||||||
|
<div class="modal-head">
|
||||||
|
<h3 id="plan-detail-title">计划详情</h3>
|
||||||
|
<button type="button" class="ghost plan-modal-close" data-plan-modal-close aria-label="关闭">×</button>
|
||||||
|
</div>
|
||||||
|
<div id="plan-detail-body" class="plan-detail-body"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="plan-edit-modal" class="modal hidden" aria-hidden="true">
|
||||||
|
<div class="modal-backdrop" data-plan-edit-close></div>
|
||||||
|
<div class="modal-card plan-edit-card">
|
||||||
|
<div class="modal-head">
|
||||||
|
<h3>修改计划</h3>
|
||||||
|
<button type="button" class="ghost plan-modal-close" data-plan-edit-close aria-label="关闭">×</button>
|
||||||
|
</div>
|
||||||
|
<form id="plan-edit-form" class="plan-form plan-edit-form"></form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div id="toast"></div>
|
<div id="toast"></div>
|
||||||
<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-prev-day-lines"></script>
|
<script src="/assets/chart.js?v=20260609-prev-day-lines"></script>
|
||||||
|
<script src="/assets/plan.js?v=20260614-entry-plan"></script>
|
||||||
<script src="/assets/archive.js?v=20260612-archive-ai-chat"></script>
|
<script src="/assets/archive.js?v=20260612-archive-ai-chat"></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>
|
||||||
|
|||||||
@@ -0,0 +1,693 @@
|
|||||||
|
/**
|
||||||
|
* 开仓计划:新建 / 进行中 / 历史 / 胜率统计
|
||||||
|
*/
|
||||||
|
(function () {
|
||||||
|
const page = document.getElementById("page-plan");
|
||||||
|
if (!page) return;
|
||||||
|
|
||||||
|
let meta = null;
|
||||||
|
let activePlans = [];
|
||||||
|
let archivedPlans = [];
|
||||||
|
let statsPeriod = "all";
|
||||||
|
let statsDim = "symbol";
|
||||||
|
let statsDateFrom = "";
|
||||||
|
let statsDateTo = "";
|
||||||
|
let editingPlanId = null;
|
||||||
|
let inited = false;
|
||||||
|
|
||||||
|
function $(id) {
|
||||||
|
return document.getElementById(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
function esc(s) {
|
||||||
|
return String(s == null ? "" : s)
|
||||||
|
.replace(/&/g, "&")
|
||||||
|
.replace(/</g, "<")
|
||||||
|
.replace(/>/g, ">")
|
||||||
|
.replace(/"/g, """);
|
||||||
|
}
|
||||||
|
|
||||||
|
function toast(msg, isErr) {
|
||||||
|
const el = $("toast");
|
||||||
|
if (!el) return;
|
||||||
|
el.textContent = msg;
|
||||||
|
el.className = isErr ? "err" : "ok";
|
||||||
|
clearTimeout(el._t);
|
||||||
|
el._t = setTimeout(function () {
|
||||||
|
el.className = "";
|
||||||
|
el.textContent = "";
|
||||||
|
}, 3200);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function api(path, opts) {
|
||||||
|
const r = await fetch(path, Object.assign({ credentials: "same-origin" }, opts || {}));
|
||||||
|
let data = {};
|
||||||
|
try {
|
||||||
|
data = await r.json();
|
||||||
|
} catch (_e) {
|
||||||
|
data = {};
|
||||||
|
}
|
||||||
|
if (!r.ok) {
|
||||||
|
const detail = (data && data.detail) || r.statusText || "请求失败";
|
||||||
|
throw new Error(typeof detail === "string" ? detail : JSON.stringify(detail));
|
||||||
|
}
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
function todayIso() {
|
||||||
|
const d = new Date();
|
||||||
|
const y = d.getFullYear();
|
||||||
|
const m = String(d.getMonth() + 1).padStart(2, "0");
|
||||||
|
const day = String(d.getDate()).padStart(2, "0");
|
||||||
|
return y + "-" + m + "-" + day;
|
||||||
|
}
|
||||||
|
|
||||||
|
function exchangeLabel(key) {
|
||||||
|
const ex = (meta && meta.exchanges) || [];
|
||||||
|
const row = ex.find(function (e) {
|
||||||
|
return String(e.key) === String(key);
|
||||||
|
});
|
||||||
|
return (row && row.name) || key || "—";
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtPnl(v) {
|
||||||
|
if (v == null || v === "") return "";
|
||||||
|
const n = Number(v);
|
||||||
|
if (!Number.isFinite(n)) return String(v);
|
||||||
|
return (n >= 0 ? "+" : "") + n.toFixed(2) + "U";
|
||||||
|
}
|
||||||
|
|
||||||
|
function fillSelect(el, options, valueKey, labelKey) {
|
||||||
|
if (!el) return;
|
||||||
|
el.innerHTML = "";
|
||||||
|
(options || []).forEach(function (opt) {
|
||||||
|
const o = document.createElement("option");
|
||||||
|
if (typeof opt === "string") {
|
||||||
|
o.value = opt;
|
||||||
|
o.textContent = opt;
|
||||||
|
} else {
|
||||||
|
o.value = opt[valueKey];
|
||||||
|
o.textContent = opt[labelKey];
|
||||||
|
}
|
||||||
|
el.appendChild(o);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderDirectionRadios(container, name, selected) {
|
||||||
|
if (!container || !meta) return;
|
||||||
|
container.innerHTML = "";
|
||||||
|
(meta.directions || []).forEach(function (d) {
|
||||||
|
const label = document.createElement("label");
|
||||||
|
label.className = "plan-radio-label";
|
||||||
|
const input = document.createElement("input");
|
||||||
|
input.type = "radio";
|
||||||
|
input.name = name;
|
||||||
|
input.value = d.value;
|
||||||
|
if (d.value === selected) input.checked = true;
|
||||||
|
label.appendChild(input);
|
||||||
|
label.appendChild(document.createTextNode(" " + d.label));
|
||||||
|
container.appendChild(label);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function bindMetaToCreateForm() {
|
||||||
|
fillSelect($("plan-create-exchange"), meta.exchanges, "key", "name");
|
||||||
|
fillSelect($("plan-create-type"), meta.plan_types, "value", "label");
|
||||||
|
fillSelect($("plan-create-trend-tf"), meta.trend_timeframes);
|
||||||
|
fillSelect($("plan-create-entry-tf"), meta.entry_timeframes);
|
||||||
|
fillSelect($("plan-create-scheme"), meta.entry_schemes, "value", "label");
|
||||||
|
renderDirectionRadios($("plan-create-direction"), "plan-direction", "long");
|
||||||
|
const dateEl = $("plan-create-date");
|
||||||
|
if (dateEl && !dateEl.value) dateEl.value = todayIso();
|
||||||
|
}
|
||||||
|
|
||||||
|
function planSummaryLine(p) {
|
||||||
|
return (
|
||||||
|
esc(p.symbol) +
|
||||||
|
" · " +
|
||||||
|
esc(exchangeLabel(p.exchange_key)) +
|
||||||
|
" · " +
|
||||||
|
esc(p.direction_label || p.direction) +
|
||||||
|
" · " +
|
||||||
|
esc(p.plan_type_label || p.plan_type)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderActiveList() {
|
||||||
|
const host = $("plan-active-list");
|
||||||
|
const cnt = $("plan-active-count");
|
||||||
|
if (!host) return;
|
||||||
|
if (cnt) cnt.textContent = activePlans.length ? activePlans.length + " 条" : "";
|
||||||
|
if (!activePlans.length) {
|
||||||
|
host.innerHTML = '<p class="plan-empty">暂无进行中的计划</p>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
host.innerHTML = activePlans
|
||||||
|
.map(function (p) {
|
||||||
|
return (
|
||||||
|
'<article class="plan-active-card" data-id="' +
|
||||||
|
esc(p.id) +
|
||||||
|
'">' +
|
||||||
|
'<div class="plan-active-head">' +
|
||||||
|
'<div class="plan-active-title">' +
|
||||||
|
planSummaryLine(p) +
|
||||||
|
"</div>" +
|
||||||
|
'<div class="plan-active-actions">' +
|
||||||
|
'<button type="button" class="ghost plan-btn-edit" data-id="' +
|
||||||
|
esc(p.id) +
|
||||||
|
'">修改</button>' +
|
||||||
|
'<button type="button" class="ghost plan-btn-del" data-id="' +
|
||||||
|
esc(p.id) +
|
||||||
|
'">删除</button>' +
|
||||||
|
"</div></div>" +
|
||||||
|
'<div class="plan-active-meta">' +
|
||||||
|
esc(p.plan_date) +
|
||||||
|
" · 趋势 " +
|
||||||
|
esc(p.trend_timeframe) +
|
||||||
|
" / 入场 " +
|
||||||
|
esc(p.entry_timeframe) +
|
||||||
|
" · " +
|
||||||
|
esc(p.entry_scheme_label || p.entry_scheme) +
|
||||||
|
"</div>" +
|
||||||
|
'<div class="plan-active-levels">目标 ' +
|
||||||
|
esc(p.target_level || "—") +
|
||||||
|
" · 区间 " +
|
||||||
|
esc(p.current_range || "—") +
|
||||||
|
"</div>" +
|
||||||
|
(p.note ? '<div class="plan-active-note">' + esc(p.note) + "</div>" : "") +
|
||||||
|
'<div class="plan-close-row">' +
|
||||||
|
'<label class="plan-field plan-field-inline"><span>结果</span>' +
|
||||||
|
'<select class="plan-close-result" data-id="' +
|
||||||
|
esc(p.id) +
|
||||||
|
'"><option value="">—</option>' +
|
||||||
|
(meta.results || [])
|
||||||
|
.map(function (r) {
|
||||||
|
return (
|
||||||
|
'<option value="' +
|
||||||
|
esc(r.value) +
|
||||||
|
'"' +
|
||||||
|
(p.result === r.value ? " selected" : "") +
|
||||||
|
">" +
|
||||||
|
esc(r.label) +
|
||||||
|
"</option>"
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.join("") +
|
||||||
|
"</select></label>" +
|
||||||
|
'<label class="plan-field plan-field-inline"><span>盈亏</span>' +
|
||||||
|
'<input class="plan-close-pnl" data-id="' +
|
||||||
|
esc(p.id) +
|
||||||
|
'" type="number" step="any" placeholder="U(可选)" value="' +
|
||||||
|
(p.pnl_amount != null ? esc(p.pnl_amount) : "") +
|
||||||
|
'" /></label>' +
|
||||||
|
'<button type="button" class="primary plan-btn-archive" data-id="' +
|
||||||
|
esc(p.id) +
|
||||||
|
'">填写结果并归档</button>' +
|
||||||
|
"</div></article>"
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.join("");
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderHistoryList() {
|
||||||
|
const host = $("plan-history-list");
|
||||||
|
const cnt = $("plan-history-count");
|
||||||
|
if (!host) return;
|
||||||
|
if (cnt) cnt.textContent = archivedPlans.length ? archivedPlans.length + " 条" : "";
|
||||||
|
if (!archivedPlans.length) {
|
||||||
|
host.innerHTML = '<p class="plan-empty">暂无历史计划</p>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
host.innerHTML = archivedPlans
|
||||||
|
.map(function (p) {
|
||||||
|
const pnlTxt = fmtPnl(p.pnl_amount);
|
||||||
|
const resCls = p.result === "win" ? "plan-res-win" : "plan-res-loss";
|
||||||
|
return (
|
||||||
|
'<button type="button" class="plan-history-row" data-id="' +
|
||||||
|
esc(p.id) +
|
||||||
|
'">' +
|
||||||
|
'<span class="plan-history-date">' +
|
||||||
|
esc(p.plan_date) +
|
||||||
|
"</span>" +
|
||||||
|
'<span class="plan-history-main">' +
|
||||||
|
esc(p.symbol) +
|
||||||
|
" · " +
|
||||||
|
esc(exchangeLabel(p.exchange_key)) +
|
||||||
|
"</span>" +
|
||||||
|
'<span class="plan-history-scheme">' +
|
||||||
|
esc(p.entry_scheme_label || p.entry_scheme) +
|
||||||
|
"</span>" +
|
||||||
|
'<span class="plan-history-result ' +
|
||||||
|
resCls +
|
||||||
|
'">' +
|
||||||
|
esc(p.result_label || p.result) +
|
||||||
|
(pnlTxt ? " " + esc(pnlTxt) : "") +
|
||||||
|
"</span></button>"
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.join("");
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderStatsTable(stats) {
|
||||||
|
const host = $("plan-stats-table");
|
||||||
|
const labelEl = $("plan-stats-label");
|
||||||
|
if (labelEl) labelEl.textContent = (stats && stats.period_label) || "";
|
||||||
|
if (!host) return;
|
||||||
|
const items = (stats && stats.items) || [];
|
||||||
|
if (!items.length) {
|
||||||
|
host.innerHTML = '<p class="plan-empty">该范围内暂无已归档且有结果的计划</p>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const dimLabel =
|
||||||
|
stats.dimension === "trend_tf"
|
||||||
|
? "趋势周期"
|
||||||
|
: stats.dimension === "entry_scheme"
|
||||||
|
? "入场方案"
|
||||||
|
: "币种";
|
||||||
|
let rows = items
|
||||||
|
.map(function (it) {
|
||||||
|
return (
|
||||||
|
"<tr><td>" +
|
||||||
|
esc(it.label || it.key) +
|
||||||
|
"</td><td>" +
|
||||||
|
(it.total || 0) +
|
||||||
|
"</td><td>" +
|
||||||
|
(it.win_count || 0) +
|
||||||
|
"</td><td>" +
|
||||||
|
(it.loss_count || 0) +
|
||||||
|
"</td><td>" +
|
||||||
|
(it.win_rate != null ? it.win_rate + "%" : "—") +
|
||||||
|
"</td></tr>"
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.join("");
|
||||||
|
host.innerHTML =
|
||||||
|
'<table class="plan-stats-table"><thead><tr>' +
|
||||||
|
"<th>" +
|
||||||
|
esc(dimLabel) +
|
||||||
|
"</th><th>计划数</th><th>盈利</th><th>亏损</th><th>胜率</th>" +
|
||||||
|
"</tr></thead><tbody>" +
|
||||||
|
rows +
|
||||||
|
"</tbody></table>";
|
||||||
|
}
|
||||||
|
|
||||||
|
function statsQuery() {
|
||||||
|
const q = new URLSearchParams();
|
||||||
|
q.set("dimension", statsDim);
|
||||||
|
q.set("period", statsPeriod);
|
||||||
|
if (statsPeriod === "range") {
|
||||||
|
if (statsDateFrom) q.set("date_from", statsDateFrom);
|
||||||
|
if (statsDateTo) q.set("date_to", statsDateTo);
|
||||||
|
}
|
||||||
|
return q.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadMeta() {
|
||||||
|
const data = await api("/api/entry-plans/meta");
|
||||||
|
meta = data;
|
||||||
|
bindMetaToCreateForm();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadActive() {
|
||||||
|
const data = await api("/api/entry-plans?status=active");
|
||||||
|
activePlans = data.plans || [];
|
||||||
|
renderActiveList();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadHistory() {
|
||||||
|
const data = await api("/api/entry-plans?status=archived");
|
||||||
|
archivedPlans = data.plans || [];
|
||||||
|
renderHistoryList();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadStats() {
|
||||||
|
const data = await api("/api/entry-plans/stats?" + statsQuery());
|
||||||
|
renderStatsTable(data.stats || {});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshAll() {
|
||||||
|
await Promise.all([loadActive(), loadHistory(), loadStats()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function readCreateForm() {
|
||||||
|
const dir = document.querySelector('input[name="plan-direction"]:checked');
|
||||||
|
return {
|
||||||
|
plan_date: ($("plan-create-date") && $("plan-create-date").value) || "",
|
||||||
|
exchange_key: ($("plan-create-exchange") && $("plan-create-exchange").value) || "",
|
||||||
|
symbol: ($("plan-create-symbol") && $("plan-create-symbol").value) || "",
|
||||||
|
plan_type: ($("plan-create-type") && $("plan-create-type").value) || "",
|
||||||
|
trend_timeframe: ($("plan-create-trend-tf") && $("plan-create-trend-tf").value) || "",
|
||||||
|
entry_timeframe: ($("plan-create-entry-tf") && $("plan-create-entry-tf").value) || "",
|
||||||
|
direction: (dir && dir.value) || "",
|
||||||
|
target_level: ($("plan-create-target") && $("plan-create-target").value) || "",
|
||||||
|
current_range: ($("plan-create-range") && $("plan-create-range").value) || "",
|
||||||
|
entry_scheme: ($("plan-create-scheme") && $("plan-create-scheme").value) || "",
|
||||||
|
note: ($("plan-create-note") && $("plan-create-note").value) || "",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetCreateForm() {
|
||||||
|
const form = $("plan-create-form");
|
||||||
|
if (form) form.reset();
|
||||||
|
bindMetaToCreateForm();
|
||||||
|
if ($("plan-create-date")) $("plan-create-date").value = todayIso();
|
||||||
|
}
|
||||||
|
|
||||||
|
function openDetailModal(plan) {
|
||||||
|
const modal = $("plan-detail-modal");
|
||||||
|
const body = $("plan-detail-body");
|
||||||
|
const title = $("plan-detail-title");
|
||||||
|
if (!modal || !body || !plan) return;
|
||||||
|
if (title) title.textContent = plan.symbol + " · " + (plan.result_label || "计划");
|
||||||
|
const rows = [
|
||||||
|
["日期", plan.plan_date],
|
||||||
|
["交易所", exchangeLabel(plan.exchange_key)],
|
||||||
|
["币种", plan.symbol],
|
||||||
|
["类型", plan.plan_type_label],
|
||||||
|
["趋势周期", plan.trend_timeframe],
|
||||||
|
["入场周期", plan.entry_timeframe],
|
||||||
|
["方向", plan.direction_label],
|
||||||
|
["目标位", plan.target_level || "—"],
|
||||||
|
["当前区间", plan.current_range || "—"],
|
||||||
|
["入场方案", plan.entry_scheme_label],
|
||||||
|
["结果", plan.result_label || "—"],
|
||||||
|
["盈亏", fmtPnl(plan.pnl_amount) || "—"],
|
||||||
|
["备注", plan.note || "—"],
|
||||||
|
];
|
||||||
|
body.innerHTML = rows
|
||||||
|
.map(function (pair) {
|
||||||
|
return (
|
||||||
|
'<div class="plan-detail-row"><span class="plan-detail-k">' +
|
||||||
|
esc(pair[0]) +
|
||||||
|
'</span><span class="plan-detail-v">' +
|
||||||
|
esc(pair[1]) +
|
||||||
|
"</span></div>"
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.join("");
|
||||||
|
modal.classList.remove("hidden");
|
||||||
|
modal.setAttribute("aria-hidden", "false");
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeDetailModal() {
|
||||||
|
const modal = $("plan-detail-modal");
|
||||||
|
if (!modal) return;
|
||||||
|
modal.classList.add("hidden");
|
||||||
|
modal.setAttribute("aria-hidden", "true");
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildEditFormHtml(p) {
|
||||||
|
const dirs = (meta.directions || [])
|
||||||
|
.map(function (d) {
|
||||||
|
return (
|
||||||
|
'<label class="plan-radio-label"><input type="radio" name="edit-direction" value="' +
|
||||||
|
esc(d.value) +
|
||||||
|
'"' +
|
||||||
|
(p.direction === d.value ? " checked" : "") +
|
||||||
|
" /> " +
|
||||||
|
esc(d.label) +
|
||||||
|
"</label>"
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.join("");
|
||||||
|
function opts(list, key, valKey, labelKey) {
|
||||||
|
return (list || [])
|
||||||
|
.map(function (o) {
|
||||||
|
const v = typeof o === "string" ? o : o[valKey];
|
||||||
|
const lbl = typeof o === "string" ? o : o[labelKey];
|
||||||
|
return (
|
||||||
|
'<option value="' +
|
||||||
|
esc(v) +
|
||||||
|
'"' +
|
||||||
|
(String(p[key]) === String(v) ? " selected" : "") +
|
||||||
|
">" +
|
||||||
|
esc(lbl) +
|
||||||
|
"</option>"
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.join("");
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
'<div class="plan-form-grid">' +
|
||||||
|
'<label class="plan-field"><span>日期</span><input name="plan_date" type="date" value="' +
|
||||||
|
esc(p.plan_date) +
|
||||||
|
'" required /></label>' +
|
||||||
|
'<label class="plan-field"><span>交易所</span><select name="exchange_key" required>' +
|
||||||
|
opts(meta.exchanges, "exchange_key", "key", "name") +
|
||||||
|
"</select></label>" +
|
||||||
|
'<label class="plan-field"><span>币种</span><input name="symbol" type="text" value="' +
|
||||||
|
esc(p.symbol) +
|
||||||
|
'" required /></label>' +
|
||||||
|
'<label class="plan-field"><span>类型</span><select name="plan_type" required>' +
|
||||||
|
opts(meta.plan_types, "plan_type", "value", "label") +
|
||||||
|
"</select></label>" +
|
||||||
|
'<label class="plan-field"><span>趋势周期</span><select name="trend_timeframe" required>' +
|
||||||
|
opts(meta.trend_timeframes, "trend_timeframe") +
|
||||||
|
"</select></label>" +
|
||||||
|
'<label class="plan-field"><span>入场周期</span><select name="entry_timeframe" required>' +
|
||||||
|
opts(meta.entry_timeframes, "entry_timeframe") +
|
||||||
|
"</select></label>" +
|
||||||
|
'<label class="plan-field plan-field-full"><span>方向</span><span class="plan-radio-row">' +
|
||||||
|
dirs +
|
||||||
|
"</span></label>" +
|
||||||
|
'<label class="plan-field"><span>目标位</span><input name="target_level" type="text" value="' +
|
||||||
|
esc(p.target_level || "") +
|
||||||
|
'" /></label>' +
|
||||||
|
'<label class="plan-field"><span>当前区间</span><input name="current_range" type="text" value="' +
|
||||||
|
esc(p.current_range || "") +
|
||||||
|
'" /></label>' +
|
||||||
|
'<label class="plan-field plan-field-full"><span>入场方案</span><select name="entry_scheme" required>' +
|
||||||
|
opts(meta.entry_schemes, "entry_scheme", "value", "label") +
|
||||||
|
"</select></label>" +
|
||||||
|
'<label class="plan-field plan-field-full"><span>备注</span><textarea name="note" rows="2">' +
|
||||||
|
esc(p.note || "") +
|
||||||
|
"</textarea></label>" +
|
||||||
|
"</div>" +
|
||||||
|
'<div class="modal-actions"><button type="button" class="ghost" data-plan-edit-close>取消</button>' +
|
||||||
|
'<button type="submit" class="primary">保存修改</button></div>'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEditModal(plan) {
|
||||||
|
const modal = $("plan-edit-modal");
|
||||||
|
const form = $("plan-edit-form");
|
||||||
|
if (!modal || !form || !plan) return;
|
||||||
|
editingPlanId = plan.id;
|
||||||
|
form.innerHTML = buildEditFormHtml(plan);
|
||||||
|
modal.classList.remove("hidden");
|
||||||
|
modal.setAttribute("aria-hidden", "false");
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeEditModal() {
|
||||||
|
const modal = $("plan-edit-modal");
|
||||||
|
if (!modal) return;
|
||||||
|
editingPlanId = null;
|
||||||
|
modal.classList.add("hidden");
|
||||||
|
modal.setAttribute("aria-hidden", "true");
|
||||||
|
}
|
||||||
|
|
||||||
|
function readEditForm(form) {
|
||||||
|
const fd = new FormData(form);
|
||||||
|
const dir = form.querySelector('input[name="edit-direction"]:checked');
|
||||||
|
return {
|
||||||
|
plan_date: fd.get("plan_date") || "",
|
||||||
|
exchange_key: fd.get("exchange_key") || "",
|
||||||
|
symbol: fd.get("symbol") || "",
|
||||||
|
plan_type: fd.get("plan_type") || "",
|
||||||
|
trend_timeframe: fd.get("trend_timeframe") || "",
|
||||||
|
entry_timeframe: fd.get("entry_timeframe") || "",
|
||||||
|
direction: (dir && dir.value) || "",
|
||||||
|
target_level: fd.get("target_level") || "",
|
||||||
|
current_range: fd.get("current_range") || "",
|
||||||
|
entry_scheme: fd.get("entry_scheme") || "",
|
||||||
|
note: fd.get("note") || "",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function bindEvents() {
|
||||||
|
const createForm = $("plan-create-form");
|
||||||
|
if (createForm) {
|
||||||
|
createForm.addEventListener("submit", function (ev) {
|
||||||
|
ev.preventDefault();
|
||||||
|
api("/api/entry-plans", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(readCreateForm()),
|
||||||
|
})
|
||||||
|
.then(function () {
|
||||||
|
toast("计划已加入进行中");
|
||||||
|
resetCreateForm();
|
||||||
|
return refreshAll();
|
||||||
|
})
|
||||||
|
.catch(function (e) {
|
||||||
|
toast(e.message || "保存失败", true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const activeList = $("plan-active-list");
|
||||||
|
if (activeList) {
|
||||||
|
activeList.addEventListener("click", function (ev) {
|
||||||
|
const t = ev.target;
|
||||||
|
if (!(t instanceof HTMLElement)) return;
|
||||||
|
const id = t.getAttribute("data-id");
|
||||||
|
if (!id) return;
|
||||||
|
if (t.classList.contains("plan-btn-del")) {
|
||||||
|
if (!window.confirm("确定删除该进行中的计划?")) return;
|
||||||
|
api("/api/entry-plans/" + id, { method: "DELETE" })
|
||||||
|
.then(function () {
|
||||||
|
toast("已删除");
|
||||||
|
return refreshAll();
|
||||||
|
})
|
||||||
|
.catch(function (e) {
|
||||||
|
toast(e.message || "删除失败", true);
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (t.classList.contains("plan-btn-edit")) {
|
||||||
|
const plan = activePlans.find(function (p) {
|
||||||
|
return String(p.id) === String(id);
|
||||||
|
});
|
||||||
|
if (plan) openEditModal(plan);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (t.classList.contains("plan-btn-archive")) {
|
||||||
|
const card = t.closest(".plan-active-card");
|
||||||
|
const resultEl = card && card.querySelector('.plan-close-result[data-id="' + id + '"]');
|
||||||
|
const pnlEl = card && card.querySelector('.plan-close-pnl[data-id="' + id + '"]');
|
||||||
|
const result = resultEl && resultEl.value;
|
||||||
|
if (!result) {
|
||||||
|
toast("请先选择结果(盈/亏)", true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const payload = { result: result };
|
||||||
|
const pnlRaw = pnlEl && pnlEl.value;
|
||||||
|
if (pnlRaw !== "" && pnlRaw != null) payload.pnl_amount = Number(pnlRaw);
|
||||||
|
api("/api/entry-plans/" + id, {
|
||||||
|
method: "PATCH",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
})
|
||||||
|
.then(function () {
|
||||||
|
toast("已归档");
|
||||||
|
return refreshAll();
|
||||||
|
})
|
||||||
|
.catch(function (e) {
|
||||||
|
toast(e.message || "归档失败", true);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const historyList = $("plan-history-list");
|
||||||
|
if (historyList) {
|
||||||
|
historyList.addEventListener("click", function (ev) {
|
||||||
|
const row = ev.target.closest(".plan-history-row");
|
||||||
|
if (!row) return;
|
||||||
|
const id = row.getAttribute("data-id");
|
||||||
|
const plan = archivedPlans.find(function (p) {
|
||||||
|
return String(p.id) === String(id);
|
||||||
|
});
|
||||||
|
if (plan) openDetailModal(plan);
|
||||||
|
else {
|
||||||
|
api("/api/entry-plans/" + id).then(function (data) {
|
||||||
|
openDetailModal(data.plan);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
document.querySelectorAll("[data-plan-modal-close]").forEach(function (el) {
|
||||||
|
el.addEventListener("click", closeDetailModal);
|
||||||
|
});
|
||||||
|
document.querySelectorAll("[data-plan-edit-close]").forEach(function (el) {
|
||||||
|
el.addEventListener("click", closeEditModal);
|
||||||
|
});
|
||||||
|
|
||||||
|
const editForm = $("plan-edit-form");
|
||||||
|
if (editForm) {
|
||||||
|
editForm.addEventListener("submit", function (ev) {
|
||||||
|
ev.preventDefault();
|
||||||
|
if (!editingPlanId) return;
|
||||||
|
api("/api/entry-plans/" + editingPlanId, {
|
||||||
|
method: "PATCH",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(readEditForm(editForm)),
|
||||||
|
})
|
||||||
|
.then(function () {
|
||||||
|
toast("已保存");
|
||||||
|
closeEditModal();
|
||||||
|
return refreshAll();
|
||||||
|
})
|
||||||
|
.catch(function (e) {
|
||||||
|
toast(e.message || "保存失败", true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const periodTabs = $("plan-stats-period-tabs");
|
||||||
|
if (periodTabs) {
|
||||||
|
periodTabs.addEventListener("click", function (ev) {
|
||||||
|
const btn = ev.target.closest(".plan-period-btn");
|
||||||
|
if (!btn) return;
|
||||||
|
statsPeriod = btn.getAttribute("data-period") || "all";
|
||||||
|
periodTabs.querySelectorAll(".plan-period-btn").forEach(function (b) {
|
||||||
|
b.classList.toggle("is-active", b === btn);
|
||||||
|
});
|
||||||
|
const rangeWrap = $("plan-stats-range-wrap");
|
||||||
|
if (rangeWrap) rangeWrap.classList.toggle("hidden", statsPeriod !== "range");
|
||||||
|
loadStats().catch(function (e) {
|
||||||
|
toast(e.message || "统计加载失败", true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const dimTabs = $("plan-stats-dim-tabs");
|
||||||
|
if (dimTabs) {
|
||||||
|
dimTabs.addEventListener("click", function (ev) {
|
||||||
|
const btn = ev.target.closest(".plan-dim-btn");
|
||||||
|
if (!btn) return;
|
||||||
|
statsDim = btn.getAttribute("data-dim") || "symbol";
|
||||||
|
dimTabs.querySelectorAll(".plan-dim-btn").forEach(function (b) {
|
||||||
|
b.classList.toggle("is-active", b === btn);
|
||||||
|
});
|
||||||
|
loadStats().catch(function (e) {
|
||||||
|
toast(e.message || "统计加载失败", true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
["plan-stats-date-from", "plan-stats-date-to"].forEach(function (id) {
|
||||||
|
const el = $(id);
|
||||||
|
if (!el) return;
|
||||||
|
el.addEventListener("change", function () {
|
||||||
|
statsDateFrom = ($("plan-stats-date-from") && $("plan-stats-date-from").value) || "";
|
||||||
|
statsDateTo = ($("plan-stats-date-to") && $("plan-stats-date-to").value) || "";
|
||||||
|
if (statsPeriod === "range") {
|
||||||
|
loadStats().catch(function (e) {
|
||||||
|
toast(e.message || "统计加载失败", true);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function init() {
|
||||||
|
if (inited) {
|
||||||
|
await refreshAll();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
inited = true;
|
||||||
|
bindEvents();
|
||||||
|
try {
|
||||||
|
await loadMeta();
|
||||||
|
await refreshAll();
|
||||||
|
} catch (e) {
|
||||||
|
toast(e.message || "加载失败", true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function destroy() {}
|
||||||
|
|
||||||
|
window.hubPlanPage = { init: init, destroy: destroy };
|
||||||
|
})();
|
||||||
@@ -8,6 +8,8 @@
|
|||||||
|
|
||||||
```
|
```
|
||||||
浏览器
|
浏览器
|
||||||
|
├─ /funds 资金概况
|
||||||
|
├─ /plan 开仓计划(计划录入 / 进行中 / 历史胜率)
|
||||||
├─ /monitor 监控区(持仓、关键位、趋势计划、全平)
|
├─ /monitor 监控区(持仓、关键位、趋势计划、全平)
|
||||||
├─ /market 行情区(K 线、技术指标、持仓价格线)
|
├─ /market 行情区(K 线、技术指标、持仓价格线)
|
||||||
├─ /archive 内照明心(复盘语录 + 交易记录 + 永久 5m K 线)
|
├─ /archive 内照明心(复盘语录 + 交易记录 + 永久 5m K 线)
|
||||||
@@ -510,6 +512,7 @@ pm2 save && pm2 startup
|
|||||||
|------|------|
|
|------|------|
|
||||||
| [使用说明.md](./使用说明.md) | 本文 |
|
| [使用说明.md](./使用说明.md) | 本文 |
|
||||||
| [行情区说明.md](./行情区说明.md) | K 线周期、缓存、快捷键、API |
|
| [行情区说明.md](./行情区说明.md) | K 线周期、缓存、快捷键、API |
|
||||||
|
| [开仓计划说明.md](./开仓计划说明.md) | 计划录入、归档、胜率统计 |
|
||||||
| [docs/hub-symbol-archive-kline.md](../docs/hub-symbol-archive-kline.md) | 内照明心、区间统计、永久 5m、建档与同步 |
|
| [docs/hub-symbol-archive-kline.md](../docs/hub-symbol-archive-kline.md) | 内照明心、区间统计、永久 5m、建档与同步 |
|
||||||
| [部署文档.md](./部署文档.md) | Ubuntu / PM2 / 反代 |
|
| [部署文档.md](./部署文档.md) | Ubuntu / PM2 / 反代 |
|
||||||
| [常见问题.md](./常见问题.md) | 故障实录与排障 |
|
| [常见问题.md](./常见问题.md) | 故障实录与排障 |
|
||||||
|
|||||||
@@ -0,0 +1,85 @@
|
|||||||
|
# 开仓计划
|
||||||
|
|
||||||
|
中控顶栏 **开仓计划**(`/plan`)用于记录开仓前的计划、跟踪进行中条目,并在填写结果后归档;支持按币种、趋势周期、入场方案统计胜率。
|
||||||
|
|
||||||
|
## 入口
|
||||||
|
|
||||||
|
- 顶栏:**资金概况** 与 **监控区** 之间 → **开仓计划**
|
||||||
|
- 路由:`/plan`
|
||||||
|
|
||||||
|
## 页面结构
|
||||||
|
|
||||||
|
| 区域 | 功能 |
|
||||||
|
|------|------|
|
||||||
|
| 左侧 · 新建计划 | 填写计划字段,保存后进入「进行中」 |
|
||||||
|
| 左侧 · 进行中 | 修改、删除、填写结果并归档 |
|
||||||
|
| 右侧 · 计划历史 | 一行一条摘要,点击查看详情 |
|
||||||
|
| 右侧 · 数据统计 | 胜率表(可切换维度与时间范围) |
|
||||||
|
|
||||||
|
## 字段说明
|
||||||
|
|
||||||
|
| 字段 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| 日期 | 计划日期(日期选择器,可手输 `YYYY-MM-DD`) |
|
||||||
|
| 交易所 | 四所:binance / okx / gate / gate_bot(来自 hub 已启用账户) |
|
||||||
|
| 币种 | 输入 `BTC` 或 `BTC/USDT`,自动规范为 `XXX/USDT` |
|
||||||
|
| 类型 | 趋势单 / 波段单 / 日内短线 |
|
||||||
|
| 趋势周期 | 5m / 15m / 30m / 1h / 4h / 1d |
|
||||||
|
| 入场周期 | 1m / 5m / 15m / 30m / 1h |
|
||||||
|
| 方向 | 多 / 空 |
|
||||||
|
| 目标位 | 文本 |
|
||||||
|
| 当前区间 | 文本 |
|
||||||
|
| 入场方案 | 突破方案 / 假突破突破方案 / 箱体拐点方案 |
|
||||||
|
| 结果 | **仅进行中**可填:盈 / 亏;**必选其一才归档** |
|
||||||
|
| 盈亏 | **可选**数字(U),不参与是否归档 |
|
||||||
|
| 备注 | 文本 |
|
||||||
|
|
||||||
|
## 业务流程
|
||||||
|
|
||||||
|
1. **新建** → 状态 `active`(进行中)
|
||||||
|
2. **修改** → 可改入场方案、备注、价位等(弹窗)
|
||||||
|
3. **删除** → 仅 **未填结果** 的进行中计划可删
|
||||||
|
4. **归档** → 在进行中选择 **盈/亏** 并点「填写结果并归档」→ 状态 `archived`,移入计划历史
|
||||||
|
|
||||||
|
## 数据统计
|
||||||
|
|
||||||
|
- **默认**:全部历史
|
||||||
|
- **时间**:全部 / 本周 / 本月 / 自选区间
|
||||||
|
- **维度 Tab**:币种 | 趋势周期 | 入场方案
|
||||||
|
- **胜率**:盈利 ÷ (盈利 + 亏损),仅统计已归档且结果=盈/亏 的计划
|
||||||
|
|
||||||
|
## API
|
||||||
|
|
||||||
|
| 方法 | 路径 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| GET | `/api/entry-plans/meta` | 枚举项 + 交易所列表 |
|
||||||
|
| GET | `/api/entry-plans?status=active\|archived` | 列表 |
|
||||||
|
| GET | `/api/entry-plans/{id}` | 详情 |
|
||||||
|
| POST | `/api/entry-plans` | 新建 |
|
||||||
|
| PATCH | `/api/entry-plans/{id}` | 更新;写入 `result` 时自动归档 |
|
||||||
|
| DELETE | `/api/entry-plans/{id}` | 删除(仅 active) |
|
||||||
|
| GET | `/api/entry-plans/stats` | 统计;参数 `dimension`、`period`、`date_from`、`date_to` |
|
||||||
|
|
||||||
|
## 存储
|
||||||
|
|
||||||
|
- SQLite:`manual_trading_hub/data/hub_entry_plans.db`
|
||||||
|
- 环境变量:`HUB_ENTRY_PLAN_DB_PATH`(可选自定义路径)
|
||||||
|
|
||||||
|
## 部署
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git pull
|
||||||
|
pm2 restart manual-trading-hub
|
||||||
|
```
|
||||||
|
|
||||||
|
浏览器访问 `/plan` 并 **Ctrl+F5** 强刷静态资源。
|
||||||
|
|
||||||
|
## 相关代码
|
||||||
|
|
||||||
|
| 文件 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| `hub_entry_plan_lib.py` | 库表、CRUD、统计 |
|
||||||
|
| `manual_trading_hub/hub.py` | REST API |
|
||||||
|
| `manual_trading_hub/static/plan.js` | 前端逻辑 |
|
||||||
|
| `manual_trading_hub/static/index.html` | 页面 DOM |
|
||||||
|
| `tests/test_hub_entry_plan_lib.py` | 单元测试 |
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
"""开仓计划库:CRUD 与胜率统计。"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import tempfile
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from hub_entry_plan_lib import (
|
||||||
|
compute_entry_plan_stats,
|
||||||
|
create_entry_plan,
|
||||||
|
delete_entry_plan,
|
||||||
|
init_db,
|
||||||
|
list_entry_plans,
|
||||||
|
normalize_plan_symbol,
|
||||||
|
resolve_stats_date_bounds,
|
||||||
|
update_entry_plan,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _base_payload(**overrides):
|
||||||
|
data = {
|
||||||
|
"plan_date": "2026-06-14",
|
||||||
|
"exchange_key": "binance",
|
||||||
|
"symbol": "BTC",
|
||||||
|
"plan_type": "trend",
|
||||||
|
"trend_timeframe": "4h",
|
||||||
|
"entry_timeframe": "15m",
|
||||||
|
"direction": "long",
|
||||||
|
"target_level": "70000",
|
||||||
|
"current_range": "68000-69000",
|
||||||
|
"entry_scheme": "breakout",
|
||||||
|
"note": "test",
|
||||||
|
}
|
||||||
|
data.update(overrides)
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
def test_normalize_plan_symbol():
|
||||||
|
assert normalize_plan_symbol("btc") == "BTC/USDT"
|
||||||
|
assert normalize_plan_symbol("ETH/USDT") == "ETH/USDT"
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_list_delete_active_plan():
|
||||||
|
with tempfile.TemporaryDirectory() as td:
|
||||||
|
db = Path(td) / "plans.db"
|
||||||
|
row = create_entry_plan(_base_payload(), db_path=db)
|
||||||
|
assert row["status"] == "active"
|
||||||
|
assert row["symbol"] == "BTC/USDT"
|
||||||
|
active = list_entry_plans(status="active", db_path=db)
|
||||||
|
assert len(active) == 1
|
||||||
|
assert delete_entry_plan(int(row["id"]), db_path=db) is True
|
||||||
|
assert list_entry_plans(status="active", db_path=db) == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_archive_on_result():
|
||||||
|
with tempfile.TemporaryDirectory() as td:
|
||||||
|
db = Path(td) / "plans.db"
|
||||||
|
row = create_entry_plan(_base_payload(symbol="SOL"), db_path=db)
|
||||||
|
updated = update_entry_plan(
|
||||||
|
int(row["id"]),
|
||||||
|
{"result": "win", "pnl_amount": 12.5},
|
||||||
|
db_path=db,
|
||||||
|
)
|
||||||
|
assert updated["status"] == "archived"
|
||||||
|
assert updated["result"] == "win"
|
||||||
|
assert updated["pnl_amount"] == 12.5
|
||||||
|
assert list_entry_plans(status="active", db_path=db) == []
|
||||||
|
archived = list_entry_plans(status="archived", db_path=db)
|
||||||
|
assert len(archived) == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_archive_without_pnl_amount():
|
||||||
|
with tempfile.TemporaryDirectory() as td:
|
||||||
|
db = Path(td) / "plans.db"
|
||||||
|
row = create_entry_plan(_base_payload(symbol="DOGE"), db_path=db)
|
||||||
|
updated = update_entry_plan(int(row["id"]), {"result": "loss"}, db_path=db)
|
||||||
|
assert updated["status"] == "archived"
|
||||||
|
assert updated["pnl_amount"] is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_cannot_delete_archived():
|
||||||
|
with tempfile.TemporaryDirectory() as td:
|
||||||
|
db = Path(td) / "plans.db"
|
||||||
|
row = create_entry_plan(_base_payload(), db_path=db)
|
||||||
|
update_entry_plan(int(row["id"]), {"result": "win"}, db_path=db)
|
||||||
|
try:
|
||||||
|
delete_entry_plan(int(row["id"]), db_path=db)
|
||||||
|
assert False, "expected ValueError"
|
||||||
|
except ValueError as e:
|
||||||
|
assert "仅进行中" in str(e)
|
||||||
|
|
||||||
|
|
||||||
|
def test_compute_stats_by_symbol():
|
||||||
|
with tempfile.TemporaryDirectory() as td:
|
||||||
|
db = Path(td) / "plans.db"
|
||||||
|
for sym, res in (("BTC", "win"), ("BTC", "loss"), ("ETH", "win")):
|
||||||
|
row = create_entry_plan(_base_payload(symbol=sym), db_path=db)
|
||||||
|
update_entry_plan(int(row["id"]), {"result": res}, db_path=db)
|
||||||
|
stats = compute_entry_plan_stats(dimension="symbol", period="all", db_path=db)
|
||||||
|
by_sym = {it["key"]: it for it in stats["items"]}
|
||||||
|
assert by_sym["BTC/USDT"]["win_count"] == 1
|
||||||
|
assert by_sym["BTC/USDT"]["loss_count"] == 1
|
||||||
|
assert by_sym["BTC/USDT"]["win_rate"] == 50.0
|
||||||
|
assert by_sym["ETH/USDT"]["win_count"] == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_stats_period_range_filter():
|
||||||
|
with tempfile.TemporaryDirectory() as td:
|
||||||
|
db = Path(td) / "plans.db"
|
||||||
|
row1 = create_entry_plan(_base_payload(plan_date="2026-06-01"), db_path=db)
|
||||||
|
row2 = create_entry_plan(_base_payload(plan_date="2026-06-20", symbol="ETH"), db_path=db)
|
||||||
|
update_entry_plan(int(row1["id"]), {"result": "win"}, db_path=db)
|
||||||
|
update_entry_plan(int(row2["id"]), {"result": "loss"}, db_path=db)
|
||||||
|
stats = compute_entry_plan_stats(
|
||||||
|
dimension="symbol",
|
||||||
|
period="range",
|
||||||
|
date_from="2026-06-01",
|
||||||
|
date_to="2026-06-10",
|
||||||
|
db_path=db,
|
||||||
|
)
|
||||||
|
assert len(stats["items"]) == 1
|
||||||
|
assert stats["items"][0]["key"] == "BTC/USDT"
|
||||||
|
|
||||||
|
|
||||||
|
def test_resolve_stats_date_bounds():
|
||||||
|
df, dt, label = resolve_stats_date_bounds(period="all")
|
||||||
|
assert df is None and dt is None
|
||||||
|
assert "全部" in label
|
||||||
Reference in New Issue
Block a user