Add entry plan page with CRUD, archive flow, and win-rate stats.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
dekun
2026-06-22 16:19:56 +08:00
parent bd759c42d6
commit 091317276d
9 changed files with 1876 additions and 1 deletions
+448
View File
@@ -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
View File
@@ -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
+264
View File
@@ -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;
}
}
+7
View File
@@ -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) {
+130
View File
@@ -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>
+693
View File
@@ -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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
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 };
})();
+3
View File
@@ -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) | 故障实录与排障 |
+85
View File
@@ -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` | 单元测试 |
+128
View File
@@ -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