From 61e8da1e8b4222f2370532142631d448ee30e130 Mon Sep 17 00:00:00 2001 From: dekun Date: Thu, 23 Jul 2026 02:06:21 +0800 Subject: [PATCH] Add hub-only OKX amp stats for ETH/BTC session windows. Read-only 1H index candles, point amplitude metrics, history save and CSV export; no order-path changes. Co-authored-by: Cursor --- .gitignore | 1 + docs/ETH时段振幅统计-开发方案.md | 2 +- docs/快照备份.md | 2 +- docs/振幅统计说明.md | 82 ++++ lib/hub/amp_stats_lib.py | 442 +++++++++++++++++++++ manual_trading_hub/amp_stats_routes.py | 137 +++++++ manual_trading_hub/amp_stats_store.py | 122 ++++++ manual_trading_hub/docs/help/02-hub-nav.md | 1 + manual_trading_hub/hub.py | 4 + manual_trading_hub/settings_store.py | 1 + manual_trading_hub/static/amp_stats.js | 296 ++++++++++++++ manual_trading_hub/static/app.css | 52 +++ manual_trading_hub/static/app.js | 16 + manual_trading_hub/static/index.html | 89 ++++- tests/test_amp_stats_lib.py | 124 ++++++ 15 files changed, 1367 insertions(+), 4 deletions(-) create mode 100644 docs/振幅统计说明.md create mode 100644 lib/hub/amp_stats_lib.py create mode 100644 manual_trading_hub/amp_stats_routes.py create mode 100644 manual_trading_hub/amp_stats_store.py create mode 100644 manual_trading_hub/static/amp_stats.js create mode 100644 tests/test_amp_stats_lib.py diff --git a/.gitignore b/.gitignore index 6b82791..13a8638 100644 --- a/.gitignore +++ b/.gitignore @@ -21,6 +21,7 @@ manual_trading_hub/hub_fund_history.json manual_trading_hub/hub_supervisor_state.json manual_trading_hub/hub_ai_summaries.json manual_trading_hub/hub_ai_chat.json +manual_trading_hub/amp_stats_history.json manual_trading_hub/hub_ai_fund_history.json manual_trading_hub/data/ backups/ diff --git a/docs/ETH时段振幅统计-开发方案.md b/docs/ETH时段振幅统计-开发方案.md index da73438..77f92c9 100644 --- a/docs/ETH时段振幅统计-开发方案.md +++ b/docs/ETH时段振幅统计-开发方案.md @@ -3,7 +3,7 @@ > 状态:**方案冻结**(按本文实现;改需求先改本文). > 范围:**中控**新增只读统计工具;不改开平仓、不接 AI 教练(首版). > 数据源:**仅 OKX**. -> 相关:[交易执行手册-期权与Gate.md](./交易执行手册-期权与Gate.md)(16:00 会话窗纪律) · [振幅统计说明.md](./振幅统计说明.md)(实现后用户说明) +> 相关:[交易执行手册-期权与Gate.md](./交易执行手册-期权与Gate.md)(16:00 会话窗纪律) · [振幅统计说明.md](./振幅统计说明.md) --- diff --git a/docs/快照备份.md b/docs/快照备份.md index e087be7..836bd7a 100644 --- a/docs/快照备份.md +++ b/docs/快照备份.md @@ -6,7 +6,7 @@ | 标签 | 指向提交 | 说明 | |------|----------|------| -| `snapshot/20260723-pre-amp-stats` | 4ccfb83 | 2026-07-23:振幅统计开发前;含执行手册进教练、日亏损冻结、手机监控 UI、振幅统计开发方案等 | +| `snapshot/20260723-pre-amp-stats` | `40be3a5` | 2026-07-23:振幅统计开发前;含执行手册进教练、日亏损冻结、手机监控 UI、振幅统计开发方案等 | | `snapshot/20260721-2` | `a721642` | 2026-07-21 晚:日亏损次数冻结、交易执行手册入中控策略说明、期权/Gate 执行手册文档等 | | `snapshot/20260721` | `1a163c0` | 2026-07-21:仓库代码统计文档、期权复盘亮色主题、对冲腿盈亏时区修复、本快照说明等 | diff --git a/docs/振幅统计说明.md b/docs/振幅统计说明.md new file mode 100644 index 0000000..20620cf --- /dev/null +++ b/docs/振幅统计说明.md @@ -0,0 +1,82 @@ +# 振幅统计(中控) + +中控只读工具:按自定义整点起点、**固定北京时间 16:00 收窗**,统计 OKX 上 ETH/BTC 的历史「点数振幅」档案,辅助一天期期权判断空间。 + +> 开发方案见 [ETH时段振幅统计-开发方案.md](./ETH时段振幅统计-开发方案.md)。 +> **不改下单链路**;不算 IV / 权利金。 + +--- + +## 入口 + +- 顶栏 **振幅统计**(`/amp-stats`) +- 手机端:**更多 → 振幅统计** +- 可在系统设置里隐藏该导航 + +--- + +## 怎么用 + +1. 打开 **统计** Tab +2. 选择 **标的** ETH / BTC;数据源固定 **OKX** +3. **起点整点**(00–23);终点固定 **16:00** +4. **周期**:1 月 / 2 月 / 3 月 / 半年 / 1 年 / 自定义天数(默认 2 个月) +5. 点 **计算** → 下方看汇总 + 分页日表 +6. 需要留存时点 **保存到历史**;**下载 CSV** 含摘要 + 全日明细 + +**跨天例子** + +| 起点 | 含义(结算日 D) | +|------|------------------| +| 22:00 | 昨天 22:00 → 今天 16:00 | +| 16:00 | 昨天 16:00 → 今天 16:00 | +| 08:00 | 今天 08:00 → 今天 16:00 | + +未到当日 16:00 的「今天」不入样本。 + +--- + +## 指标(点数) + +设开盘 O、最高 H、最低 L、收盘 C: + +| 字段 | 算法 | +|------|------| +| 开→高 | `H − O` | +| 开→低 | `O − L` | +| **振幅** | `H − L`(= 开→高 + 开→低) | +| 涨跌值 | `C − O` | + +例:O=2000,H=2500,L=1800 → 开→高 500,开→低 200,振幅 **700**。 + +汇总必含:最大振幅(及日期)、开→高/开→低的最大与均值等。 + +K 线粒度:**1H**(与整点对齐);价源优先 OKX 指数(ETH-USD / BTC-USD),失败再降级永续标记。 + +--- + +## 历史 Tab + +- 仅 **保存到历史** 后出现(不会一算就自动入库) +- 可查看、再下载、删除 +- 数据文件:`manual_trading_hub/amp_stats_history.json`(勿当密钥提交) + +--- + +## 相关代码 + +| 路径 | 说明 | +|------|------| +| `lib/hub/amp_stats_lib.py` | 切窗、汇总、OKX 拉取、CSV | +| `manual_trading_hub/amp_stats_routes.py` | API | +| `manual_trading_hub/amp_stats_store.py` | 历史 JSON | +| `manual_trading_hub/static/amp_stats.js` | 前端 | +| `tests/test_amp_stats_lib.py` | 单元测试 | + +--- + +## 修订 + +| 日期 | 说明 | +|------|------| +| 2026-07-23 | 首版上线说明 | diff --git a/lib/hub/amp_stats_lib.py b/lib/hub/amp_stats_lib.py new file mode 100644 index 0000000..4591700 --- /dev/null +++ b/lib/hub/amp_stats_lib.py @@ -0,0 +1,442 @@ +"""中控振幅统计:OKX 指数(可降级永续)按时段切窗,点数口径. + +仅只读行情;不触及下单链路. +""" +from __future__ import annotations + +import csv +import io +import statistics +from datetime import date, datetime, timedelta +from typing import Any, Callable, Optional +from zoneinfo import ZoneInfo + +import httpx + +APP_TZ = ZoneInfo("Asia/Shanghai") +END_HOUR = 16 +EXCHANGE = "okx" +TIMEFRAME = "1H" + +SYMBOLS: dict[str, dict[str, str]] = { + "eth": { + "label": "ETH", + "index_inst": "ETH-USD", + "swap_inst": "ETH-USDT-SWAP", + }, + "btc": { + "label": "BTC", + "index_inst": "BTC-USD", + "swap_inst": "BTC-USDT-SWAP", + }, +} + +PERIOD_DAYS: dict[str, int] = { + "1m": 30, + "2m": 60, + "3m": 90, + "6m": 180, + "1y": 365, +} + +OKX_INDEX_CANDLES = "https://www.okx.com/api/v5/market/index-candles" +OKX_SWAP_CANDLES = "https://www.okx.com/api/v5/market/candles" + + +def normalize_symbol(raw: str) -> str: + s = (raw or "").strip().lower() + if s in ("eth", "ethereum"): + return "eth" + if s in ("btc", "bitcoin"): + return "btc" + raise ValueError("symbol 仅支持 eth / btc") + + +def resolve_sample_days(period: str, custom_days: Any = None) -> int: + p = (period or "2m").strip().lower() + if p == "custom": + try: + n = int(custom_days) + except (TypeError, ValueError): + raise ValueError("自定义天数无效") from None + return max(7, min(400, n)) + if p not in PERIOD_DAYS: + raise ValueError("周期无效") + return PERIOD_DAYS[p] + + +def window_bounds_for_settlement(settlement: date, start_hour: int) -> tuple[datetime, datetime]: + """返回 [start, end) 的本地时刻;end 为结算日 16:00.""" + if not (0 <= int(start_hour) <= 23): + raise ValueError("起点须为 0-23 整点") + end = datetime(settlement.year, settlement.month, settlement.day, END_HOUR, 0, 0, tzinfo=APP_TZ) + sh = int(start_hour) + if sh >= END_HOUR: + prev = settlement - timedelta(days=1) + start = datetime(prev.year, prev.month, prev.day, sh, 0, 0, tzinfo=APP_TZ) + else: + start = datetime(settlement.year, settlement.month, settlement.day, sh, 0, 0, tzinfo=APP_TZ) + return start, end + + +def list_settlement_dates(*, sample_days: int, now: Optional[datetime] = None) -> list[date]: + """最近 sample_days 个已收窗结算日(不含进行中的今天未到 16:00).""" + now = now or datetime.now(APP_TZ) + if now.tzinfo is None: + now = now.replace(tzinfo=APP_TZ) + else: + now = now.astimezone(APP_TZ) + today = now.date() + today_end = datetime(today.year, today.month, today.day, END_HOUR, 0, 0, tzinfo=APP_TZ) + latest = today if now >= today_end else today - timedelta(days=1) + return [latest - timedelta(days=i) for i in range(int(sample_days))] + + +def _safe_float(v: Any) -> Optional[float]: + try: + if v is None or v == "": + return None + return float(v) + except (TypeError, ValueError): + return None + + +def bars_to_map(bars: list[dict[str, Any]]) -> dict[int, dict[str, float]]: + """open_time_ms -> {o,h,l,c}.""" + m: dict[int, dict[str, float]] = {} + for b in bars or []: + if not isinstance(b, dict): + continue + ts = b.get("ts") + if ts is None: + ts = b.get("open_time_ms") + try: + ts_i = int(ts) + except (TypeError, ValueError): + continue + o = _safe_float(b.get("o") if "o" in b else b.get("open")) + h = _safe_float(b.get("h") if "h" in b else b.get("high")) + l = _safe_float(b.get("l") if "l" in b else b.get("low")) + c = _safe_float(b.get("c") if "c" in b else b.get("close")) + if None in (o, h, l, c): + continue + m[ts_i] = {"o": float(o), "h": float(h), "l": float(l), "c": float(c)} + return m + + +def compute_day_row( + settlement: date, + start_hour: int, + bar_map: dict[int, dict[str, float]], +) -> Optional[dict[str, Any]]: + start, end = window_bounds_for_settlement(settlement, start_hour) + start_ms = int(start.timestamp() * 1000) + # 1H 棒覆盖 [T, T+1h);窗终点 16:00 用 15:00 棒的 close + last_bar_ms = int((end - timedelta(hours=1)).timestamp() * 1000) + if start_ms not in bar_map or last_bar_ms not in bar_map: + return None + opens = bar_map[start_ms]["o"] + close = bar_map[last_bar_ms]["c"] + hi = bar_map[start_ms]["h"] + lo = bar_map[start_ms]["l"] + t = start_ms + while t <= last_bar_ms: + b = bar_map.get(t) + if b: + hi = max(hi, b["h"]) + lo = min(lo, b["l"]) + t += 3600 * 1000 + up = hi - opens + down = opens - lo + amp = hi - lo + change = close - opens + return { + "settlement_day": settlement.isoformat(), + "window_start": start.strftime("%Y-%m-%d %H:%M"), + "window_end": end.strftime("%Y-%m-%d %H:%M"), + "open": round(opens, 4), + "high": round(hi, 4), + "low": round(lo, 4), + "close": round(close, 4), + "up_points": round(up, 4), + "down_points": round(down, 4), + "amplitude": round(amp, 4), + "change": round(change, 4), + } + + +def summarize_rows(rows: list[dict[str, Any]]) -> dict[str, Any]: + if not rows: + return { + "sample_count": 0, + "max_amplitude": None, + "max_amplitude_day": None, + "avg_amplitude": None, + "median_amplitude": None, + "max_up_points": None, + "avg_up_points": None, + "max_down_points": None, + "avg_down_points": None, + "up_day_ratio": None, + "down_day_ratio": None, + } + amps = [float(r["amplitude"]) for r in rows] + ups = [float(r["up_points"]) for r in rows] + downs = [float(r["down_points"]) for r in rows] + max_amp = max(amps) + max_amp_day = next(r["settlement_day"] for r in rows if float(r["amplitude"]) == max_amp) + up_days = sum(1 for r in rows if float(r["change"]) > 0) + down_days = sum(1 for r in rows if float(r["change"]) < 0) + n = len(rows) + return { + "sample_count": n, + "max_amplitude": round(max_amp, 4), + "max_amplitude_day": max_amp_day, + "avg_amplitude": round(statistics.fmean(amps), 4), + "median_amplitude": round(statistics.median(amps), 4), + "max_up_points": round(max(ups), 4), + "avg_up_points": round(statistics.fmean(ups), 4), + "max_down_points": round(max(downs), 4), + "avg_down_points": round(statistics.fmean(downs), 4), + "up_day_ratio": round(up_days / n, 4), + "down_day_ratio": round(down_days / n, 4), + } + + +def _parse_okx_candle_row(row: list) -> Optional[dict[str, Any]]: + if not row or len(row) < 5: + return None + try: + ts = int(row[0]) + o, h, l, c = float(row[1]), float(row[2]), float(row[3]), float(row[4]) + except (TypeError, ValueError, IndexError): + return None + return {"ts": ts, "o": o, "h": h, "l": l, "c": c} + + +def fetch_okx_candles( + *, + url: str, + inst_id: str, + since_ms: int, + until_ms: int, + bar: str = "1H", + client: Optional[httpx.Client] = None, + timeout: float = 30.0, +) -> list[dict[str, Any]]: + """拉取 [since_ms, until_ms] 覆盖的 K 线(含边界).OKX 返回新→旧.""" + own = client is None + client = client or httpx.Client( + timeout=timeout, + trust_env=False, + headers={"User-Agent": "crypto_monitor-amp-stats/1.0"}, + ) + try: + out: dict[int, dict[str, Any]] = {} + after: Optional[str] = None + for _ in range(80): + params: dict[str, str] = {"instId": inst_id, "bar": bar, "limit": "100"} + if after: + params["after"] = after + r = client.get(url, params=params) + r.raise_for_status() + body = r.json() + if str(body.get("code") or "") not in ("0", "0.0", ""): + raise RuntimeError(body.get("msg") or f"OKX error {body.get('code')}") + data = body.get("data") or [] + if not data: + break + oldest_ts = None + for row in data: + parsed = _parse_okx_candle_row(row) + if not parsed: + continue + ts = int(parsed["ts"]) + oldest_ts = ts if oldest_ts is None else min(oldest_ts, ts) + if ts < since_ms - 3600 * 1000: + continue + if ts > until_ms + 3600 * 1000: + continue + out[ts] = parsed + if oldest_ts is None: + break + if oldest_ts <= since_ms: + break + after = str(oldest_ts) + return [out[k] for k in sorted(out.keys())] + finally: + if own: + client.close() + + +def fetch_symbol_bars( + symbol: str, + *, + since_ms: int, + until_ms: int, + fetch_fn: Optional[Callable[..., list[dict[str, Any]]]] = None, +) -> tuple[list[dict[str, Any]], str, str]: + """返回 (bars, price_source_label, inst_id).""" + key = normalize_symbol(symbol) + meta = SYMBOLS[key] + if fetch_fn: + bars = fetch_fn(inst_id=meta["index_inst"], since_ms=since_ms, until_ms=until_ms) + return bars, f"okx_index:{meta['index_inst']}", meta["index_inst"] + + try: + bars = fetch_okx_candles( + url=OKX_INDEX_CANDLES, + inst_id=meta["index_inst"], + since_ms=since_ms, + until_ms=until_ms, + ) + if bars: + return bars, f"okx_index:{meta['index_inst']}", meta["index_inst"] + except Exception: + bars = [] + + bars = fetch_okx_candles( + url=OKX_SWAP_CANDLES, + inst_id=meta["swap_inst"], + since_ms=since_ms, + until_ms=until_ms, + ) + if not bars: + raise RuntimeError("OKX 指数与永续 K 线均无数据") + return bars, f"okx_swap:{meta['swap_inst']}", meta["swap_inst"] + + +def compute_amp_stats( + *, + symbol: str = "eth", + start_hour: int = 16, + period: str = "2m", + custom_days: Any = None, + now: Optional[datetime] = None, + fetch_fn: Optional[Callable[..., list[dict[str, Any]]]] = None, +) -> dict[str, Any]: + key = normalize_symbol(symbol) + sh = int(start_hour) + if sh < 0 or sh > 23: + raise ValueError("起点须为 0-23 整点") + sample_days = resolve_sample_days(period, custom_days) + settlements = list_settlement_dates(sample_days=sample_days, now=now) + if not settlements: + raise RuntimeError("无可用结算日") + # 最远窗起点 + oldest = settlements[-1] + newest = settlements[0] + start0, _ = window_bounds_for_settlement(oldest, sh) + _, end1 = window_bounds_for_settlement(newest, sh) + since_ms = int(start0.timestamp() * 1000) + until_ms = int(end1.timestamp() * 1000) + bars, price_source, inst_id = fetch_symbol_bars( + key, since_ms=since_ms, until_ms=until_ms, fetch_fn=fetch_fn + ) + bar_map = bars_to_map(bars) + rows: list[dict[str, Any]] = [] + missing: list[str] = [] + for d in settlements: + row = compute_day_row(d, sh, bar_map) + if row is None: + missing.append(d.isoformat()) + continue + rows.append(row) + summary = summarize_rows(rows) + period_label = period if period != "custom" else f"custom:{sample_days}" + return { + "ok": True, + "exchange": EXCHANGE, + "symbol": key, + "symbol_label": SYMBOLS[key]["label"], + "start_hour": sh, + "end_hour": END_HOUR, + "period": period_label, + "sample_days_requested": sample_days, + "timeframe": TIMEFRAME, + "price_source": price_source, + "inst_id": inst_id, + "timezone": "Asia/Shanghai", + "rows": rows, + "summary": summary, + "missing_days": missing[:30], + "missing_count": len(missing), + } + + +def rows_page(rows: list[dict[str, Any]], *, page: int = 1, page_size: int = 20) -> dict[str, Any]: + page = max(1, int(page or 1)) + page_size = max(5, min(100, int(page_size or 20))) + total = len(rows) + start = (page - 1) * page_size + chunk = rows[start : start + page_size] + return { + "page": page, + "page_size": page_size, + "total": total, + "total_pages": max(1, (total + page_size - 1) // page_size) if total else 1, + "rows": chunk, + } + + +def build_export_csv(payload: dict[str, Any]) -> str: + buf = io.StringIO() + # Excel 友好 BOM + buf.write("\ufeff") + w = csv.writer(buf) + s = payload.get("summary") or {} + w.writerow(["【统计摘要】"]) + w.writerow(["交易所", payload.get("exchange")]) + w.writerow(["标的", payload.get("symbol_label")]) + w.writerow(["价源", payload.get("price_source")]) + w.writerow(["起点整点", f"{payload.get('start_hour')}:00"]) + w.writerow(["终点", f"{payload.get('end_hour')}:00"]) + w.writerow(["周期", payload.get("period")]) + w.writerow(["样本数", s.get("sample_count")]) + w.writerow(["最大振幅", s.get("max_amplitude"), "日期", s.get("max_amplitude_day")]) + w.writerow(["振幅均值", s.get("avg_amplitude"), "中位数", s.get("median_amplitude")]) + w.writerow(["开→高最大", s.get("max_up_points"), "均值", s.get("avg_up_points")]) + w.writerow(["开→低最大", s.get("max_down_points"), "均值", s.get("avg_down_points")]) + w.writerow(["上涨窗占比", s.get("up_day_ratio"), "下跌窗占比", s.get("down_day_ratio")]) + w.writerow([]) + w.writerow(["【日表明细】"]) + w.writerow( + [ + "结算日", + "窗起点", + "窗终点", + "开盘", + "最高", + "最低", + "收盘", + "开→高", + "开→低", + "振幅", + "涨跌值", + ] + ) + for r in payload.get("rows") or []: + w.writerow( + [ + r.get("settlement_day"), + r.get("window_start"), + r.get("window_end"), + r.get("open"), + r.get("high"), + r.get("low"), + r.get("close"), + r.get("up_points"), + r.get("down_points"), + r.get("amplitude"), + r.get("change"), + ] + ) + return buf.getvalue() + + +def export_filename(payload: dict[str, Any]) -> str: + sym = (payload.get("symbol") or "eth").lower() + sh = int(payload.get("start_hour") or 16) + period = str(payload.get("period") or "2m").replace(":", "") + day = datetime.now(APP_TZ).strftime("%Y%m%d") + return f"okx_{sym}_amp_{sh}to16_{period}_{day}.csv" diff --git a/manual_trading_hub/amp_stats_routes.py b/manual_trading_hub/amp_stats_routes.py new file mode 100644 index 0000000..43f5d2f --- /dev/null +++ b/manual_trading_hub/amp_stats_routes.py @@ -0,0 +1,137 @@ +"""中控振幅统计 API.""" +from __future__ import annotations + +from typing import Any, Optional + +from fastapi import APIRouter, HTTPException, Query +from fastapi.responses import Response +from pydantic import BaseModel, Field + +from amp_stats_store import delete_history, get_history, list_history, save_history +from lib.hub.amp_stats_lib import ( + build_export_csv, + compute_amp_stats, + export_filename, + rows_page, +) + + +class ComputeBody(BaseModel): + symbol: str = "eth" + start_hour: int = 16 + period: str = "2m" + custom_days: Optional[int] = None + page: int = 1 + page_size: int = 20 + + +class SaveBody(BaseModel): + result: dict[str, Any] = Field(default_factory=dict) + + +def create_amp_stats_router() -> APIRouter: + router = APIRouter(prefix="/api/amp-stats", tags=["amp-stats"]) + + @router.get("/meta") + def api_meta(): + return { + "ok": True, + "exchange": "okx", + "symbols": [ + {"key": "eth", "label": "ETH"}, + {"key": "btc", "label": "BTC"}, + ], + "end_hour": 16, + "start_hours": list(range(24)), + "periods": [ + {"key": "1m", "label": "1个月"}, + {"key": "2m", "label": "2个月"}, + {"key": "3m", "label": "3个月"}, + {"key": "6m", "label": "半年"}, + {"key": "1y", "label": "1年"}, + {"key": "custom", "label": "自定义"}, + ], + "default_period": "2m", + "timeframe": "1H", + "metric_note": "振幅与距离均为点数:振幅=最高-最低=(开→高)+(开→低)", + } + + @router.post("/compute") + def api_compute(body: ComputeBody): + try: + result = compute_amp_stats( + symbol=body.symbol, + start_hour=body.start_hour, + period=body.period, + custom_days=body.custom_days, + ) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + except Exception as exc: + raise HTTPException(status_code=502, detail=str(exc)) from exc + page = rows_page(result.get("rows") or [], page=body.page, page_size=body.page_size) + return { + "ok": True, + "result": result, + "page": page, + } + + @router.get("/history") + def api_history(symbol: str = "", limit: int = 50): + return {"ok": True, "items": list_history(symbol=symbol, limit=limit)} + + @router.post("/history") + def api_history_save(body: SaveBody): + payload = body.result if isinstance(body.result, dict) else {} + if not payload.get("rows") and not payload.get("summary"): + raise HTTPException(status_code=400, detail="无可保存的结果") + item = save_history(payload) + return {"ok": True, "item": item} + + @router.get("/history/{item_id}") + def api_history_detail(item_id: str): + item = get_history(item_id) + if not item: + raise HTTPException(status_code=404, detail="历史不存在") + return {"ok": True, "item": item} + + @router.delete("/history/{item_id}") + def api_history_delete(item_id: str): + if not delete_history(item_id): + raise HTTPException(status_code=404, detail="历史不存在") + return {"ok": True} + + @router.get("/export") + def api_export( + history_id: str = Query(default=""), + symbol: str = Query(default="eth"), + start_hour: int = Query(default=16), + period: str = Query(default="2m"), + custom_days: Optional[int] = Query(default=None), + ): + if (history_id or "").strip(): + item = get_history(history_id.strip()) + if not item: + raise HTTPException(status_code=404, detail="历史不存在") + payload = item + else: + try: + payload = compute_amp_stats( + symbol=symbol, + start_hour=start_hour, + period=period, + custom_days=custom_days, + ) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + except Exception as exc: + raise HTTPException(status_code=502, detail=str(exc)) from exc + csv_text = build_export_csv(payload) + name = export_filename(payload) + return Response( + content=csv_text.encode("utf-8"), + media_type="text/csv; charset=utf-8", + headers={"Content-Disposition": f'attachment; filename="{name}"'}, + ) + + return router diff --git a/manual_trading_hub/amp_stats_store.py b/manual_trading_hub/amp_stats_store.py new file mode 100644 index 0000000..c2911e5 --- /dev/null +++ b/manual_trading_hub/amp_stats_store.py @@ -0,0 +1,122 @@ +"""振幅统计历史作业存储(中控 JSON).""" +from __future__ import annotations + +import json +import threading +import uuid +from datetime import datetime, timezone +from pathlib import Path +from typing import Any, Optional + +_LOCK = threading.Lock() +_STORE_NAME = "amp_stats_history.json" +_MAX_ITEMS = 80 + + +def _store_path() -> Path: + return Path(__file__).resolve().parent / _STORE_NAME + + +def _now_iso() -> str: + return datetime.now(timezone.utc).astimezone().strftime("%Y-%m-%d %H:%M:%S") + + +def _load() -> dict[str, Any]: + path = _store_path() + if not path.is_file(): + return {"items": []} + try: + data = json.loads(path.read_text(encoding="utf-8")) + except Exception: + return {"items": []} + if not isinstance(data, dict): + return {"items": []} + items = data.get("items") + if not isinstance(items, list): + items = [] + return {"items": items} + + +def _save(data: dict[str, Any]) -> None: + path = _store_path() + path.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8") + + +def list_history(*, symbol: str = "", limit: int = 50) -> list[dict[str, Any]]: + with _LOCK: + items = list(_load().get("items") or []) + sym = (symbol or "").strip().lower() + if sym: + items = [x for x in items if str(x.get("symbol") or "").lower() == sym] + limit = max(1, min(200, int(limit or 50))) + out = [] + for it in items[:limit]: + out.append( + { + "id": it.get("id"), + "created_at": it.get("created_at"), + "symbol": it.get("symbol"), + "symbol_label": it.get("symbol_label"), + "start_hour": it.get("start_hour"), + "end_hour": it.get("end_hour"), + "period": it.get("period"), + "price_source": it.get("price_source"), + "sample_count": (it.get("summary") or {}).get("sample_count"), + "max_amplitude": (it.get("summary") or {}).get("max_amplitude"), + "max_amplitude_day": (it.get("summary") or {}).get("max_amplitude_day"), + } + ) + return out + + +def get_history(item_id: str) -> Optional[dict[str, Any]]: + iid = (item_id or "").strip() + if not iid: + return None + with _LOCK: + for it in _load().get("items") or []: + if str(it.get("id")) == iid: + return dict(it) + return None + + +def save_history(payload: dict[str, Any]) -> dict[str, Any]: + item = { + "id": uuid.uuid4().hex[:12], + "created_at": _now_iso(), + "exchange": payload.get("exchange"), + "symbol": payload.get("symbol"), + "symbol_label": payload.get("symbol_label"), + "start_hour": payload.get("start_hour"), + "end_hour": payload.get("end_hour"), + "period": payload.get("period"), + "timeframe": payload.get("timeframe"), + "price_source": payload.get("price_source"), + "inst_id": payload.get("inst_id"), + "timezone": payload.get("timezone"), + "summary": payload.get("summary") or {}, + "rows": payload.get("rows") or [], + "missing_count": payload.get("missing_count") or 0, + } + with _LOCK: + data = _load() + items = list(data.get("items") or []) + items.insert(0, item) + data["items"] = items[:_MAX_ITEMS] + _save(data) + return item + + +def delete_history(item_id: str) -> bool: + iid = (item_id or "").strip() + if not iid: + return False + with _LOCK: + data = _load() + items = list(data.get("items") or []) + new_items = [x for x in items if str(x.get("id")) != iid] + if len(new_items) == len(items): + return False + data["items"] = new_items + _save(data) + return True diff --git a/manual_trading_hub/docs/help/02-hub-nav.md b/manual_trading_hub/docs/help/02-hub-nav.md index 83a0eca..da505fc 100644 --- a/manual_trading_hub/docs/help/02-hub-nav.md +++ b/manual_trading_hub/docs/help/02-hub-nav.md @@ -8,6 +8,7 @@ | **开仓计划** | 事前写下计划、跟踪进行中、统计历史胜率 | | **监控区** | **核心操作台**:三所持仓卡片、全平/撤单、关键位与趋势计划摘要 | | **策略说明** | 执行手册 + 三所策略 playbook + 开仓检查清单(非系统操作手册) | +| **振幅统计** | OKX ETH/BTC 时段点数振幅档案(只读,固定 16:00 收窗) | | **使用说明** | 本页:中控与实例怎么用 | | **行情区** | K 线、指标、画线;可从持仓跳转带币种 | | **计算器** | 趋势回调 / 滚仓张数与盈亏测算(手动填价) | diff --git a/manual_trading_hub/hub.py b/manual_trading_hub/hub.py index 6dd325b..f6b404e 100644 --- a/manual_trading_hub/hub.py +++ b/manual_trading_hub/hub.py @@ -998,6 +998,7 @@ def root_redirect(): @app.get("/funds") @app.get("/ai") @app.get("/strategy") +@app.get("/amp-stats") @app.get("/help") @app.get("/logs") @app.get("/settings") @@ -1012,8 +1013,10 @@ def _all_exchanges_for_ai() -> list: from hub_ai.routes import create_hub_ai_router from hub_dashboard import build_dashboard_payload, default_trading_day +from amp_stats_routes import create_amp_stats_router app.include_router(create_hub_ai_router(load_all_exchanges=_all_exchanges_for_ai)) +app.include_router(create_amp_stats_router()) async def _run_dashboard_aggregate() -> dict: @@ -1111,6 +1114,7 @@ class SettingsDisplayBody(BaseModel): show_nav_ai: bool = True show_nav_calculator: bool = True show_nav_strategy: bool = True + show_nav_amp_stats: bool = True show_nav_help: bool = True show_nav_logs: bool = True diff --git a/manual_trading_hub/settings_store.py b/manual_trading_hub/settings_store.py index df57760..1759eae 100644 --- a/manual_trading_hub/settings_store.py +++ b/manual_trading_hub/settings_store.py @@ -30,6 +30,7 @@ DEFAULT_DISPLAY = { "show_nav_ai": True, "show_nav_calculator": True, "show_nav_strategy": True, + "show_nav_amp_stats": True, "show_nav_help": True, "show_nav_logs": True, } diff --git a/manual_trading_hub/static/amp_stats.js b/manual_trading_hub/static/amp_stats.js new file mode 100644 index 0000000..2c5a171 --- /dev/null +++ b/manual_trading_hub/static/amp_stats.js @@ -0,0 +1,296 @@ +/** + * 中控振幅统计:OKX ETH/BTC 时段点数振幅. + */ +(function () { + const page = document.getElementById("page-amp-stats"); + if (!page) return; + + let meta = null; + let lastResult = null; + let pageNo = 1; + let bound = false; + + const el = (id) => document.getElementById(id); + + async function apiFetch(url, opts) { + const r = await fetch(url, { credentials: "same-origin", ...(opts || {}) }); + const ct = (r.headers.get("content-type") || "").toLowerCase(); + if (ct.includes("application/json")) { + const data = await r.json(); + if (!r.ok) throw new Error((data && (data.detail || data.msg)) || r.statusText || "请求失败"); + return data; + } + if (!r.ok) throw new Error(r.statusText || "请求失败"); + return r; + } + + function esc(s) { + return String(s ?? "") + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """); + } + + function setStatus(msg) { + const s = el("amp-status"); + if (s) s.textContent = msg || ""; + } + + function setView(view) { + const isHist = view === "history"; + el("amp-panel-stats")?.classList.toggle("hidden", isHist); + el("amp-panel-history")?.classList.toggle("hidden", !isHist); + page.querySelectorAll(".amp-view-tab").forEach((btn) => { + const on = btn.getAttribute("data-view") === view; + btn.classList.toggle("is-active", on); + btn.setAttribute("aria-selected", on ? "true" : "false"); + }); + if (isHist) void loadHistory(); + } + + function syncCustomDays() { + const period = el("amp-period")?.value || "2m"; + const wrap = el("amp-custom-wrap"); + if (wrap) wrap.classList.toggle("hidden", period !== "custom"); + } + + function fillMetaControls() { + const hourSel = el("amp-start-hour"); + if (hourSel && !hourSel.options.length) { + for (let h = 0; h < 24; h++) { + const opt = document.createElement("option"); + opt.value = String(h); + opt.textContent = String(h).padStart(2, "0") + ":00"; + if (h === 16) opt.selected = true; + hourSel.appendChild(opt); + } + } + } + + function renderSummary(summary, result) { + const box = el("amp-summary"); + if (!box) return; + const s = summary || {}; + if (!s.sample_count) { + box.innerHTML = '

暂无汇总

'; + return; + } + box.innerHTML = + `
` + + `
样本${esc(s.sample_count)}
` + + `
最大振幅${esc(s.max_amplitude)} (${esc(s.max_amplitude_day)})
` + + `
振幅均值${esc(s.avg_amplitude)}
` + + `
振幅中位${esc(s.median_amplitude)}
` + + `
开→高最大/均${esc(s.max_up_points)} / ${esc(s.avg_up_points)}
` + + `
开→低最大/均${esc(s.max_down_points)} / ${esc(s.avg_down_points)}
` + + `
涨/跌窗占比${esc(s.up_day_ratio)} / ${esc(s.down_day_ratio)}
` + + `
价源${esc(result && result.price_source)}
` + + `
`; + } + + function renderTable(pagePayload) { + const body = el("amp-table-body"); + const pager = el("amp-pager"); + if (!body) return; + const rows = (pagePayload && pagePayload.rows) || []; + if (!rows.length) { + body.innerHTML = '暂无数据'; + } else { + body.innerHTML = rows + .map( + (r) => + `` + + `${esc(r.settlement_day)}` + + `${esc(r.window_start)}` + + `${esc(r.open)}` + + `${esc(r.high)}` + + `${esc(r.low)}` + + `${esc(r.close)}` + + `${esc(r.up_points)}` + + `${esc(r.down_points)}` + + `${esc(r.amplitude)}` + + `${esc(r.change)}` + + `` + ) + .join(""); + } + if (pager && pagePayload) { + pager.innerHTML = + `` + + `第 ${esc(pagePayload.page)} / ${esc(pagePayload.total_pages)} 页 · 共 ${esc(pagePayload.total)} 天` + + ``; + el("amp-page-prev")?.addEventListener("click", () => { + if (pageNo > 1) { + pageNo -= 1; + void compute(false); + } + }); + el("amp-page-next")?.addEventListener("click", () => { + if (pagePayload.page < pagePayload.total_pages) { + pageNo += 1; + void compute(false); + } + }); + } + } + + async function compute(resetPage) { + if (resetPage) pageNo = 1; + const symbol = el("amp-symbol")?.value || "eth"; + const startHour = Number(el("amp-start-hour")?.value || 16); + const period = el("amp-period")?.value || "2m"; + const customDays = Number(el("amp-custom-days")?.value || 60); + setStatus("计算中…(首次拉取 OKX K 线可能需数十秒)"); + try { + const data = await apiFetch("/api/amp-stats/compute", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + symbol, + start_hour: startHour, + period, + custom_days: period === "custom" ? customDays : null, + page: pageNo, + page_size: 20, + }), + }); + lastResult = data.result || null; + renderSummary(lastResult && lastResult.summary, lastResult); + renderTable(data.page); + const miss = (lastResult && lastResult.missing_count) || 0; + setStatus( + miss + ? `完成 · 样本 ${(lastResult.summary || {}).sample_count || 0} · 缺 ${miss} 天` + : `完成 · 样本 ${(lastResult.summary || {}).sample_count || 0}` + ); + } catch (e) { + setStatus(String(e && e.message ? e.message : e)); + } + } + + async function saveHistory() { + if (!lastResult) { + setStatus("请先计算"); + return; + } + try { + await apiFetch("/api/amp-stats/history", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ result: lastResult }), + }); + setStatus("已保存到历史"); + } catch (e) { + setStatus(String(e && e.message ? e.message : e)); + } + } + + function downloadCurrent() { + if (!lastResult) { + setStatus("请先计算"); + return; + } + const symbol = el("amp-symbol")?.value || "eth"; + const startHour = Number(el("amp-start-hour")?.value || 16); + const period = el("amp-period")?.value || "2m"; + const customDays = Number(el("amp-custom-days")?.value || 60); + const q = new URLSearchParams({ + symbol, + start_hour: String(startHour), + period, + }); + if (period === "custom") q.set("custom_days", String(customDays)); + window.location.href = "/api/amp-stats/export?" + q.toString(); + } + + async function loadHistory() { + const box = el("amp-history-list"); + if (!box) return; + box.innerHTML = '

加载中…

'; + try { + const data = await apiFetch("/api/amp-stats/history?limit=50"); + const items = data.items || []; + if (!items.length) { + box.innerHTML = '

暂无历史

'; + return; + } + box.innerHTML = items + .map( + (it) => + `
` + + `
` + + `${esc(it.symbol_label || it.symbol)} · ${esc(String(it.start_hour).padStart(2, "0"))}:00→16:00 · ${esc(it.period)}` + + `
${esc(it.created_at)} · 样本 ${esc(it.sample_count)} · 最大振幅 ${esc(it.max_amplitude)} (${esc(it.max_amplitude_day)})
` + + `
` + + `
` + + `` + + `` + + `` + + `
` + ) + .join(""); + box.querySelectorAll(".amp-hist-card").forEach((card) => { + const id = card.getAttribute("data-id"); + card.querySelector(".amp-hist-view")?.addEventListener("click", () => void openHistory(id)); + card.querySelector(".amp-hist-dl")?.addEventListener("click", () => { + window.location.href = "/api/amp-stats/export?history_id=" + encodeURIComponent(id); + }); + card.querySelector(".amp-hist-del")?.addEventListener("click", async () => { + if (!confirm("删除该历史记录?")) return; + await apiFetch("/api/amp-stats/history/" + encodeURIComponent(id), { method: "DELETE" }); + void loadHistory(); + }); + }); + } catch (e) { + box.innerHTML = `

${esc(String(e && e.message ? e.message : e))}

`; + } + } + + async function openHistory(id) { + try { + const data = await apiFetch("/api/amp-stats/history/" + encodeURIComponent(id)); + lastResult = data.item || null; + setView("stats"); + if (lastResult) { + if (el("amp-symbol")) el("amp-symbol").value = lastResult.symbol || "eth"; + if (el("amp-start-hour")) el("amp-start-hour").value = String(lastResult.start_hour ?? 16); + renderSummary(lastResult.summary, lastResult); + const pagePayload = { + page: 1, + page_size: 20, + total: (lastResult.rows || []).length, + total_pages: Math.max(1, Math.ceil((lastResult.rows || []).length / 20)), + rows: (lastResult.rows || []).slice(0, 20), + }; + pageNo = 1; + renderTable(pagePayload); + setStatus("已载入历史 " + id); + } + } catch (e) { + setStatus(String(e && e.message ? e.message : e)); + } + } + + function bind() { + if (bound) return; + bound = true; + fillMetaControls(); + page.querySelectorAll(".amp-view-tab").forEach((btn) => { + btn.addEventListener("click", () => setView(btn.getAttribute("data-view"))); + }); + el("amp-period")?.addEventListener("change", syncCustomDays); + el("amp-btn-compute")?.addEventListener("click", () => void compute(true)); + el("amp-btn-save")?.addEventListener("click", () => void saveHistory()); + el("amp-btn-download")?.addEventListener("click", downloadCurrent); + syncCustomDays(); + } + + window.hubAmpStatsPage = { + init() { + bind(); + setView("stats"); + setStatus(""); + }, + }; +})(); diff --git a/manual_trading_hub/static/app.css b/manual_trading_hub/static/app.css index 76aa056..08b0048 100644 --- a/manual_trading_hub/static/app.css +++ b/manual_trading_hub/static/app.css @@ -10939,3 +10939,55 @@ html[data-theme="light"] .hub-logs-card-hint { min-height: 360px; } } + +/* —— 振幅统计 —— */ +.amp-view-tabs { display: flex; gap: 8px; margin: 0 0 12px; } +.amp-view-tab { + min-height: 34px; padding: 6px 14px; border: 1px solid var(--border-soft); + border-radius: 8px; background: transparent; color: var(--muted); cursor: pointer; +} +.amp-view-tab.is-active { color: var(--text); border-color: var(--accent); background: rgba(0, 212, 255, 0.08); } +.amp-panel { padding: 14px 16px 18px; } +.amp-form { + display: grid; grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); + gap: 10px 12px; align-items: end; margin-bottom: 8px; +} +.amp-field { display: flex; flex-direction: column; gap: 4px; font-size: 12px; color: var(--muted); } +.amp-field select, .amp-field input { + min-height: 34px; padding: 6px 8px; border-radius: 8px; + border: 1px solid var(--border-soft); background: var(--panel-solid); color: var(--text); +} +.amp-actions { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; } +.amp-hint { font-size: 12px; color: var(--muted); margin: 4px 0 12px; } +.amp-status { margin: 0 0 8px; } +.amp-block-title { font-size: 14px; margin: 14px 0 8px; } +.amp-sum-grid { + display: grid; grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); gap: 8px; +} +.amp-sum-grid > div { + border: 1px solid var(--border-soft); border-radius: 8px; padding: 8px 10px; + display: flex; flex-direction: column; gap: 2px; +} +.amp-sum-k { font-size: 11px; color: var(--muted); } +.amp-sum-v { font-size: 14px; font-weight: 600; color: var(--text); } +.amp-table-wrap { overflow-x: auto; } +.amp-table { width: 100%; border-collapse: collapse; font-size: 12px; } +.amp-table th, .amp-table td { + border-bottom: 1px solid var(--border-soft); padding: 7px 8px; text-align: right; white-space: nowrap; +} +.amp-table th:first-child, .amp-table td:first-child, +.amp-table th:nth-child(2), .amp-table td:nth-child(2) { text-align: left; } +.amp-pager { display: flex; align-items: center; gap: 10px; margin-top: 10px; } +.amp-pager-meta { font-size: 12px; color: var(--muted); } +.amp-empty { color: var(--muted); text-align: center; padding: 16px; } +.amp-history-list { display: flex; flex-direction: column; gap: 10px; } +.amp-hist-card { + display: flex; justify-content: space-between; gap: 12px; flex-wrap: wrap; + border: 1px solid var(--border-soft); border-radius: 10px; padding: 10px 12px; +} +.amp-hist-sub { font-size: 12px; color: var(--muted); margin-top: 4px; } +.amp-hist-actions { display: flex; gap: 6px; align-items: center; } +@media (max-width: 720px) { + .amp-form { grid-template-columns: 1fr 1fr; } + .amp-actions { grid-column: 1 / -1; } +} diff --git a/manual_trading_hub/static/app.js b/manual_trading_hub/static/app.js index 50a8f62..2fc1f5e 100644 --- a/manual_trading_hub/static/app.js +++ b/manual_trading_hub/static/app.js @@ -45,6 +45,10 @@ return displayPref("show_nav_strategy", true); } + function showNavAmpStatsPref() { + return displayPref("show_nav_amp_stats", true); + } + function showNavHelpPref() { return displayPref("show_nav_help", true); } @@ -64,6 +68,7 @@ ["nav-ai", "m-tab-ai", d.show_nav_ai === false], ["nav-calculator", "m-tab-calculator", d.show_nav_calculator === false], ["nav-strategy", "m-nav-strategy", d.show_nav_strategy === false], + ["nav-amp-stats", "m-nav-amp-stats", d.show_nav_amp_stats === false], ["nav-help", "m-nav-help", d.show_nav_help === false], ["nav-logs", "m-nav-logs", d.show_nav_logs === false], ]; @@ -136,6 +141,7 @@ if (page === "ai") return showNavAiPref(); if (page === "calculator") return showNavCalculatorPref(); if (page === "strategy") return showNavStrategyPref(); + if (page === "amp-stats") return showNavAmpStatsPref(); if (page === "help") return showNavHelpPref(); if (page === "logs") return showNavLogsPref(); return true; @@ -152,6 +158,7 @@ const aiCb = document.getElementById("pref-show-nav-ai"); const calcCb = document.getElementById("pref-show-nav-calculator"); const strategyCb = document.getElementById("pref-show-nav-strategy"); + const ampCb = document.getElementById("pref-show-nav-amp-stats"); const helpCb = document.getElementById("pref-show-nav-help"); const logsCb = document.getElementById("pref-show-nav-logs"); if (pnlCb) pnlCb.checked = d.show_account_pnl !== false; @@ -163,6 +170,7 @@ if (aiCb) aiCb.checked = d.show_nav_ai !== false; if (calcCb) calcCb.checked = d.show_nav_calculator !== false; if (strategyCb) strategyCb.checked = d.show_nav_strategy !== false; + if (ampCb) ampCb.checked = d.show_nav_amp_stats !== false; if (helpCb) helpCb.checked = d.show_nav_help !== false; if (logsCb) logsCb.checked = d.show_nav_logs !== false; syncNavVisibility(data); @@ -1278,6 +1286,7 @@ if (p.includes("plan")) return "plan"; if (p.includes("calculator")) return "calculator"; if (p.includes("help")) return "help"; + if (p.includes("amp-stats")) return "amp-stats"; if (p.includes("strategy")) return "strategy"; if (p.includes("logs")) return "logs"; if (p.includes("market")) return "market"; @@ -1295,6 +1304,7 @@ if (page === "calculator") return "page-calculator"; if (page === "help") return "page-help"; if (page === "strategy") return "page-strategy"; + if (page === "amp-stats") return "page-amp-stats"; if (page === "logs") return "page-logs"; if (page === "market") return "page-market"; if (page === "ai") return "page-ai"; @@ -1329,6 +1339,7 @@ document.body.classList.toggle("hub-page-quotes", page === "quotes"); document.body.classList.toggle("hub-page-plan", page === "plan"); document.body.classList.toggle("hub-page-strategy", page === "strategy"); + document.body.classList.toggle("hub-page-amp-stats", page === "amp-stats"); document.body.classList.toggle("hub-page-logs", page === "logs"); document.body.classList.toggle("hub-page-help", page === "help"); syncHubPhoneShellClass(); @@ -1373,6 +1384,9 @@ } else if (window.hubStrategyPage && window.hubStrategyPage.destroy) { window.hubStrategyPage.destroy(); } + if (page === "amp-stats" && window.hubAmpStatsPage) { + window.hubAmpStatsPage.init(); + } if (page === "help" && window.hubHelpPage) { window.hubHelpPage.init(); } else if (window.hubHelpPage && window.hubHelpPage.destroy) { @@ -5052,6 +5066,7 @@ const aiCb = document.getElementById("pref-show-nav-ai"); const calcCb = document.getElementById("pref-show-nav-calculator"); const strategyCb = document.getElementById("pref-show-nav-strategy"); + const ampCb = document.getElementById("pref-show-nav-amp-stats"); const helpCb = document.getElementById("pref-show-nav-help"); const logsCb = document.getElementById("pref-show-nav-logs"); const supEnabled = document.getElementById("supervisor-enabled"); @@ -5075,6 +5090,7 @@ show_nav_ai: aiCb ? !!aiCb.checked : true, show_nav_calculator: calcCb ? !!calcCb.checked : true, show_nav_strategy: strategyCb ? !!strategyCb.checked : true, + show_nav_amp_stats: ampCb ? !!ampCb.checked : true, show_nav_help: helpCb ? !!helpCb.checked : true, show_nav_logs: logsCb ? !!logsCb.checked : true, }, diff --git a/manual_trading_hub/static/index.html b/manual_trading_hub/static/index.html index ab52e94..b2c9001 100644 --- a/manual_trading_hub/static/index.html +++ b/manual_trading_hub/static/index.html @@ -16,7 +16,7 @@ - + @@ -53,6 +53,7 @@ 开仓计划 监控区 策略说明 + 振幅统计 使用说明 行情区 计算器 @@ -1003,6 +1004,84 @@ + +