From 091317276d4920586993bd0a9fc6cc790685da91 Mon Sep 17 00:00:00 2001 From: dekun Date: Mon, 22 Jun 2026 16:19:56 +0800 Subject: [PATCH] Add entry plan page with CRUD, archive flow, and win-rate stats. Co-authored-by: Cursor --- hub_entry_plan_lib.py | 448 +++++++++++++++++ manual_trading_hub/hub.py | 119 ++++- manual_trading_hub/static/app.css | 264 ++++++++++ manual_trading_hub/static/app.js | 7 + manual_trading_hub/static/index.html | 130 +++++ manual_trading_hub/static/plan.js | 693 +++++++++++++++++++++++++++ manual_trading_hub/使用说明.md | 3 + manual_trading_hub/开仓计划说明.md | 85 ++++ tests/test_hub_entry_plan_lib.py | 128 +++++ 9 files changed, 1876 insertions(+), 1 deletion(-) create mode 100644 hub_entry_plan_lib.py create mode 100644 manual_trading_hub/static/plan.js create mode 100644 manual_trading_hub/开仓计划说明.md create mode 100644 tests/test_hub_entry_plan_lib.py diff --git a/hub_entry_plan_lib.py b/hub_entry_plan_lib.py new file mode 100644 index 0000000..5bd0576 --- /dev/null +++ b/hub_entry_plan_lib.py @@ -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 [], + } diff --git a/manual_trading_hub/hub.py b/manual_trading_hub/hub.py index c354fd0..241166d 100644 --- a/manual_trading_hub/hub.py +++ b/manual_trading_hub/hub.py @@ -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 diff --git a/manual_trading_hub/static/app.css b/manual_trading_hub/static/app.css index e3465fb..e860542 100644 --- a/manual_trading_hub/static/app.css +++ b/manual_trading_hub/static/app.css @@ -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; + } +} + diff --git a/manual_trading_hub/static/app.js b/manual_trading_hub/static/app.js index 19d3ec5..cc992ad 100644 --- a/manual_trading_hub/static/app.js +++ b/manual_trading_hub/static/app.js @@ -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) { diff --git a/manual_trading_hub/static/index.html b/manual_trading_hub/static/index.html index dd349d8..fff879d 100644 --- a/manual_trading_hub/static/index.html +++ b/manual_trading_hub/static/index.html @@ -48,6 +48,7 @@ SYNC