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,
|
||||
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_TYPES,
|
||||
create_event as create_macro_event,
|
||||
@@ -691,6 +700,7 @@ def root_redirect():
|
||||
|
||||
|
||||
@app.get("/monitor")
|
||||
@app.get("/plan")
|
||||
@app.get("/market")
|
||||
@app.get("/archive")
|
||||
@app.get("/dashboard")
|
||||
@@ -2375,6 +2385,113 @@ async def api_archive_sync():
|
||||
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")
|
||||
def api_hub_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("dashboard")) return "dashboard";
|
||||
if (p.includes("funds")) return "funds";
|
||||
if (p.includes("plan")) return "plan";
|
||||
if (p.includes("market")) return "market";
|
||||
if (p.includes("/ai")) return "ai";
|
||||
return "monitor";
|
||||
@@ -1017,6 +1018,7 @@
|
||||
if (page === "archive") return "page-archive";
|
||||
if (page === "dashboard") return "page-dashboard";
|
||||
if (page === "funds") return "page-funds";
|
||||
if (page === "plan") return "page-plan";
|
||||
if (page === "market") return "page-market";
|
||||
if (page === "ai") return "page-ai";
|
||||
return "page-monitor";
|
||||
@@ -1057,6 +1059,11 @@
|
||||
} else if (window.hubArchivePage && 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) {
|
||||
window.hubFundsPage.init();
|
||||
} else if (window.hubFundsPage && window.hubFundsPage.destroy) {
|
||||
|
||||
@@ -48,6 +48,7 @@
|
||||
<span id="sys-status" class="sys-pill" title="系统状态">SYNC</span>
|
||||
<nav class="top-nav">
|
||||
<a href="/funds" id="nav-funds">资金概况</a>
|
||||
<a href="/plan" id="nav-plan">开仓计划</a>
|
||||
<a href="/monitor" id="nav-monitor">监控区</a>
|
||||
<a href="/market" id="nav-market">行情区</a>
|
||||
<a href="/archive" id="nav-archive">内照明心</a>
|
||||
@@ -59,6 +60,112 @@
|
||||
</div>
|
||||
</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 class="page-head">
|
||||
<h1><span class="head-tag">MON</span> 监控区</h1>
|
||||
@@ -681,10 +788,33 @@
|
||||
</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>
|
||||
<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.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/funds.js?v=20260609-hub-funds-fold"></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 监控区(持仓、关键位、趋势计划、全平)
|
||||
├─ /market 行情区(K 线、技术指标、持仓价格线)
|
||||
├─ /archive 内照明心(复盘语录 + 交易记录 + 永久 5m K 线)
|
||||
@@ -510,6 +512,7 @@ pm2 save && pm2 startup
|
||||
|------|------|
|
||||
| [使用说明.md](./使用说明.md) | 本文 |
|
||||
| [行情区说明.md](./行情区说明.md) | K 线周期、缓存、快捷键、API |
|
||||
| [开仓计划说明.md](./开仓计划说明.md) | 计划录入、归档、胜率统计 |
|
||||
| [docs/hub-symbol-archive-kline.md](../docs/hub-symbol-archive-kline.md) | 内照明心、区间统计、永久 5m、建档与同步 |
|
||||
| [部署文档.md](./部署文档.md) | Ubuntu / PM2 / 反代 |
|
||||
| [常见问题.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