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 <cursoragent@cursor.com>
This commit is contained in:
@@ -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/
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
> 状态:**方案冻结**(按本文实现;改需求先改本文).
|
||||
> 范围:**中控**新增只读统计工具;不改开平仓、不接 AI 教练(首版).
|
||||
> 数据源:**仅 OKX**.
|
||||
> 相关:[交易执行手册-期权与Gate.md](./交易执行手册-期权与Gate.md)(16:00 会话窗纪律) · [振幅统计说明.md](./振幅统计说明.md)(实现后用户说明)
|
||||
> 相关:[交易执行手册-期权与Gate.md](./交易执行手册-期权与Gate.md)(16:00 会话窗纪律) · [振幅统计说明.md](./振幅统计说明.md)
|
||||
|
||||
---
|
||||
|
||||
|
||||
+1
-1
@@ -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:仓库代码统计文档、期权复盘亮色主题、对冲腿盈亏时区修复、本快照说明等 |
|
||||
|
||||
|
||||
@@ -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 | 首版上线说明 |
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -8,6 +8,7 @@
|
||||
| **开仓计划** | 事前写下计划、跟踪进行中、统计历史胜率 |
|
||||
| **监控区** | **核心操作台**:三所持仓卡片、全平/撤单、关键位与趋势计划摘要 |
|
||||
| **策略说明** | 执行手册 + 三所策略 playbook + 开仓检查清单(非系统操作手册) |
|
||||
| **振幅统计** | OKX ETH/BTC 时段点数振幅档案(只读,固定 16:00 收窗) |
|
||||
| **使用说明** | 本页:中控与实例怎么用 |
|
||||
| **行情区** | K 线、指标、画线;可从持仓跳转带币种 |
|
||||
| **计算器** | 趋势回调 / 滚仓张数与盈亏测算(手动填价) |
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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, ">")
|
||||
.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 = '<p class="amp-empty">暂无汇总</p>';
|
||||
return;
|
||||
}
|
||||
box.innerHTML =
|
||||
`<div class="amp-sum-grid">` +
|
||||
`<div><span class="amp-sum-k">样本</span><span class="amp-sum-v">${esc(s.sample_count)}</span></div>` +
|
||||
`<div><span class="amp-sum-k">最大振幅</span><span class="amp-sum-v">${esc(s.max_amplitude)} <small>(${esc(s.max_amplitude_day)})</small></span></div>` +
|
||||
`<div><span class="amp-sum-k">振幅均值</span><span class="amp-sum-v">${esc(s.avg_amplitude)}</span></div>` +
|
||||
`<div><span class="amp-sum-k">振幅中位</span><span class="amp-sum-v">${esc(s.median_amplitude)}</span></div>` +
|
||||
`<div><span class="amp-sum-k">开→高最大/均</span><span class="amp-sum-v">${esc(s.max_up_points)} / ${esc(s.avg_up_points)}</span></div>` +
|
||||
`<div><span class="amp-sum-k">开→低最大/均</span><span class="amp-sum-v">${esc(s.max_down_points)} / ${esc(s.avg_down_points)}</span></div>` +
|
||||
`<div><span class="amp-sum-k">涨/跌窗占比</span><span class="amp-sum-v">${esc(s.up_day_ratio)} / ${esc(s.down_day_ratio)}</span></div>` +
|
||||
`<div><span class="amp-sum-k">价源</span><span class="amp-sum-v">${esc(result && result.price_source)}</span></div>` +
|
||||
`</div>`;
|
||||
}
|
||||
|
||||
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 = '<tr><td colspan="11" class="amp-empty">暂无数据</td></tr>';
|
||||
} else {
|
||||
body.innerHTML = rows
|
||||
.map(
|
||||
(r) =>
|
||||
`<tr>` +
|
||||
`<td>${esc(r.settlement_day)}</td>` +
|
||||
`<td>${esc(r.window_start)}</td>` +
|
||||
`<td>${esc(r.open)}</td>` +
|
||||
`<td>${esc(r.high)}</td>` +
|
||||
`<td>${esc(r.low)}</td>` +
|
||||
`<td>${esc(r.close)}</td>` +
|
||||
`<td>${esc(r.up_points)}</td>` +
|
||||
`<td>${esc(r.down_points)}</td>` +
|
||||
`<td><strong>${esc(r.amplitude)}</strong></td>` +
|
||||
`<td>${esc(r.change)}</td>` +
|
||||
`</tr>`
|
||||
)
|
||||
.join("");
|
||||
}
|
||||
if (pager && pagePayload) {
|
||||
pager.innerHTML =
|
||||
`<button type="button" class="ghost" id="amp-page-prev" ${pagePayload.page <= 1 ? "disabled" : ""}>上一页</button>` +
|
||||
`<span class="amp-pager-meta">第 ${esc(pagePayload.page)} / ${esc(pagePayload.total_pages)} 页 · 共 ${esc(pagePayload.total)} 天</span>` +
|
||||
`<button type="button" class="ghost" id="amp-page-next" ${pagePayload.page >= pagePayload.total_pages ? "disabled" : ""}>下一页</button>`;
|
||||
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 = '<p class="amp-empty">加载中…</p>';
|
||||
try {
|
||||
const data = await apiFetch("/api/amp-stats/history?limit=50");
|
||||
const items = data.items || [];
|
||||
if (!items.length) {
|
||||
box.innerHTML = '<p class="amp-empty">暂无历史</p>';
|
||||
return;
|
||||
}
|
||||
box.innerHTML = items
|
||||
.map(
|
||||
(it) =>
|
||||
`<div class="amp-hist-card" data-id="${esc(it.id)}">` +
|
||||
`<div class="amp-hist-main">` +
|
||||
`<strong>${esc(it.symbol_label || it.symbol)}</strong> · ${esc(String(it.start_hour).padStart(2, "0"))}:00→16:00 · ${esc(it.period)}` +
|
||||
`<div class="amp-hist-sub">${esc(it.created_at)} · 样本 ${esc(it.sample_count)} · 最大振幅 ${esc(it.max_amplitude)} (${esc(it.max_amplitude_day)})</div>` +
|
||||
`</div>` +
|
||||
`<div class="amp-hist-actions">` +
|
||||
`<button type="button" class="ghost amp-hist-view">查看</button>` +
|
||||
`<button type="button" class="ghost amp-hist-dl">下载</button>` +
|
||||
`<button type="button" class="danger amp-hist-del">删除</button>` +
|
||||
`</div></div>`
|
||||
)
|
||||
.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 = `<p class="amp-empty">${esc(String(e && e.message ? e.message : e))}</p>`;
|
||||
}
|
||||
}
|
||||
|
||||
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("");
|
||||
},
|
||||
};
|
||||
})();
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600&family=Orbitron:wght@500;600;700&display=swap" rel="stylesheet" media="print" onload="this.media='all'" />
|
||||
<noscript><link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600&family=Orbitron:wght@500;600;700&display=swap" rel="stylesheet" /></noscript>
|
||||
<link rel="stylesheet" href="/assets/app.css?v=20260722-monitor-m-stats" />
|
||||
<link rel="stylesheet" href="/assets/app.css?v=20260723-amp-stats" />
|
||||
<link rel="stylesheet" href="/assets/trade_stats_calendar.css?v=4" />
|
||||
<link rel="stylesheet" href="/assets/account_risk_badge.css?v=4" />
|
||||
<script src="/assets/account_risk_badge.js?v=4"></script>
|
||||
@@ -53,6 +53,7 @@
|
||||
<a href="/plan" id="nav-plan">开仓计划</a>
|
||||
<a href="/monitor" id="nav-monitor">监控区</a>
|
||||
<a href="/strategy" id="nav-strategy">策略说明</a>
|
||||
<a href="/amp-stats" id="nav-amp-stats">振幅统计</a>
|
||||
<a href="/help" id="nav-help">使用说明</a>
|
||||
<a href="/market" id="nav-market">行情区</a>
|
||||
<a href="/calculator" id="nav-calculator">计算器</a>
|
||||
@@ -1003,6 +1004,84 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="page-amp-stats" class="page hidden">
|
||||
<div class="page-head">
|
||||
<div>
|
||||
<h1><span class="head-tag">AMP</span> 振幅统计</h1>
|
||||
<p class="page-desc">OKX 指数 · 整点起点 → 固定 16:00 · 点数振幅档案(只读)</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="amp-view-tabs" role="tablist" aria-label="振幅视图">
|
||||
<button type="button" class="amp-view-tab is-active" data-view="stats" role="tab" aria-selected="true">统计</button>
|
||||
<button type="button" class="amp-view-tab" data-view="history" role="tab" aria-selected="false">历史</button>
|
||||
</div>
|
||||
<section id="amp-panel-stats" class="card amp-panel">
|
||||
<div class="amp-form">
|
||||
<label class="amp-field">
|
||||
<span>标的</span>
|
||||
<select id="amp-symbol">
|
||||
<option value="eth" selected>ETH</option>
|
||||
<option value="btc">BTC</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="amp-field">
|
||||
<span>数据源</span>
|
||||
<input type="text" value="OKX" disabled />
|
||||
</label>
|
||||
<label class="amp-field">
|
||||
<span>起点整点</span>
|
||||
<select id="amp-start-hour"></select>
|
||||
</label>
|
||||
<label class="amp-field">
|
||||
<span>终点</span>
|
||||
<input type="text" value="16:00" disabled />
|
||||
</label>
|
||||
<label class="amp-field">
|
||||
<span>周期</span>
|
||||
<select id="amp-period">
|
||||
<option value="1m">1个月</option>
|
||||
<option value="2m" selected>2个月</option>
|
||||
<option value="3m">3个月</option>
|
||||
<option value="6m">半年</option>
|
||||
<option value="1y">1年</option>
|
||||
<option value="custom">自定义</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="amp-field hidden" id="amp-custom-wrap">
|
||||
<span>自定义天数</span>
|
||||
<input id="amp-custom-days" type="number" min="7" max="400" value="60" />
|
||||
</label>
|
||||
<div class="amp-actions">
|
||||
<button type="button" id="amp-btn-compute" class="primary">计算</button>
|
||||
<button type="button" id="amp-btn-save" class="ghost">保存到历史</button>
|
||||
<button type="button" id="amp-btn-download" class="ghost">下载 CSV</button>
|
||||
</div>
|
||||
</div>
|
||||
<p id="amp-status" class="toolbar-meta amp-status"></p>
|
||||
<p class="amp-hint">口径:开→高=最高−开盘;开→低=开盘−最低;振幅=最高−最低(均为点数).未到 16:00 的当日不入样.</p>
|
||||
<h3 class="amp-block-title">汇总</h3>
|
||||
<div id="amp-summary" class="amp-summary"></div>
|
||||
<h3 class="amp-block-title">日表明细</h3>
|
||||
<div class="amp-table-wrap">
|
||||
<table class="amp-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>结算日</th><th>窗起点</th><th>开</th><th>高</th><th>低</th><th>收</th>
|
||||
<th>开→高</th><th>开→低</th><th>振幅</th><th>涨跌</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="amp-table-body">
|
||||
<tr><td colspan="10" class="amp-empty">点击「计算」加载</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div id="amp-pager" class="amp-pager"></div>
|
||||
</section>
|
||||
<section id="amp-panel-history" class="card amp-panel hidden">
|
||||
<div id="amp-history-list" class="amp-history-list"></div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div id="page-help" class="page hidden">
|
||||
<div class="page-head">
|
||||
<div>
|
||||
@@ -1149,6 +1228,10 @@
|
||||
<input type="checkbox" id="pref-show-nav-strategy" checked />
|
||||
顶栏显示「策略说明」
|
||||
</label>
|
||||
<label class="chk-label settings-display-chk">
|
||||
<input type="checkbox" id="pref-show-nav-amp-stats" checked />
|
||||
顶栏显示「振幅统计」
|
||||
</label>
|
||||
<label class="chk-label settings-display-chk">
|
||||
<input type="checkbox" id="pref-show-nav-help" checked />
|
||||
顶栏显示「使用说明」
|
||||
@@ -1343,6 +1426,7 @@
|
||||
<a href="/quotes" id="m-nav-quotes">语录</a>
|
||||
<a href="/dashboard" id="m-nav-dashboard">数据看板</a>
|
||||
<a href="/strategy" id="m-nav-strategy">策略说明</a>
|
||||
<a href="/amp-stats" id="m-nav-amp-stats">振幅统计</a>
|
||||
<a href="/help" id="m-nav-help">使用说明</a>
|
||||
<a href="/logs" id="m-nav-logs">系统日志</a>
|
||||
<a href="/settings" id="m-nav-settings">系统设置</a>
|
||||
@@ -1406,6 +1490,7 @@
|
||||
<script src="/assets/funds.js?v=20260717-funds-scroll-fix"></script>
|
||||
<script src="/assets/dashboard.js?v=20260720-dash-sl-tp"></script>
|
||||
<script src="/assets/strategy.js?v=9"></script>
|
||||
<script src="/assets/amp_stats.js?v=1"></script>
|
||||
<script src="/assets/help.js?v=1"></script>
|
||||
<script src="/assets/logs.js?v=1"></script>
|
||||
<script src="/assets/ai_review_render.js?v=3"></script>
|
||||
@@ -1413,6 +1498,6 @@
|
||||
<script src="/assets/options_expiry_countdown.js?v=1"></script>
|
||||
<script src="/assets/options_position_cards.js?v=2"></script>
|
||||
<script src="/assets/backup.js?v=1"></script>
|
||||
<script src="/assets/app.js?v=20260720-dash-back"></script>
|
||||
<script src="/assets/app.js?v=20260723-amp-stats"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
"""振幅统计核心逻辑单元测试(不打交易所)."""
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
from datetime import date, datetime
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from lib.hub.amp_stats_lib import (
|
||||
build_export_csv,
|
||||
compute_amp_stats,
|
||||
compute_day_row,
|
||||
list_settlement_dates,
|
||||
summarize_rows,
|
||||
window_bounds_for_settlement,
|
||||
)
|
||||
|
||||
TZ = ZoneInfo("Asia/Shanghai")
|
||||
|
||||
|
||||
def _bar(ts_ms: int, o: float, h: float, l: float, c: float) -> dict:
|
||||
return {"ts": ts_ms, "o": o, "h": h, "l": l, "c": c}
|
||||
|
||||
|
||||
class AmpStatsLibTests(unittest.TestCase):
|
||||
def test_window_cross_day_22_to_16(self):
|
||||
start, end = window_bounds_for_settlement(date(2026, 7, 22), 22)
|
||||
self.assertEqual(start.strftime("%Y-%m-%d %H:%M"), "2026-07-21 22:00")
|
||||
self.assertEqual(end.strftime("%Y-%m-%d %H:%M"), "2026-07-22 16:00")
|
||||
|
||||
def test_window_same_day_8_to_16(self):
|
||||
start, end = window_bounds_for_settlement(date(2026, 7, 22), 8)
|
||||
self.assertEqual(start.strftime("%Y-%m-%d %H:%M"), "2026-07-22 08:00")
|
||||
self.assertEqual(end.strftime("%Y-%m-%d %H:%M"), "2026-07-22 16:00")
|
||||
|
||||
def test_settlement_excludes_incomplete_today(self):
|
||||
now = datetime(2026, 7, 22, 10, 0, tzinfo=TZ)
|
||||
days = list_settlement_dates(sample_days=3, now=now)
|
||||
self.assertEqual(days[0].isoformat(), "2026-07-21")
|
||||
self.assertEqual(len(days), 3)
|
||||
|
||||
def test_settlement_includes_today_after_1600(self):
|
||||
now = datetime(2026, 7, 22, 16, 0, tzinfo=TZ)
|
||||
days = list_settlement_dates(sample_days=1, now=now)
|
||||
self.assertEqual(days[0].isoformat(), "2026-07-22")
|
||||
|
||||
def test_day_row_points(self):
|
||||
# 22:00 D-1 → 16:00 D; O=2000 H=2500 L=1800 C=2100 → up500 down200 amp700
|
||||
settlement = date(2026, 7, 22)
|
||||
start, end = window_bounds_for_settlement(settlement, 22)
|
||||
bar_map = {}
|
||||
t = int(start.timestamp() * 1000)
|
||||
last = int((end.replace(hour=15)).timestamp() * 1000)
|
||||
# first bar
|
||||
bar_map[t] = {"o": 2000.0, "h": 2100.0, "l": 1950.0, "c": 2050.0}
|
||||
cur = t + 3600 * 1000
|
||||
while cur < last:
|
||||
bar_map[cur] = {"o": 2050.0, "h": 2200.0, "l": 1900.0, "c": 2100.0}
|
||||
cur += 3600 * 1000
|
||||
# peak and trough somewhere
|
||||
mid = t + 5 * 3600 * 1000
|
||||
bar_map[mid] = {"o": 2100.0, "h": 2500.0, "l": 1800.0, "c": 2000.0}
|
||||
bar_map[last] = {"o": 2000.0, "h": 2150.0, "l": 1990.0, "c": 2100.0}
|
||||
# fill any missing hours with flat
|
||||
cur = t
|
||||
while cur <= last:
|
||||
if cur not in bar_map:
|
||||
bar_map[cur] = {"o": 2000.0, "h": 2000.0, "l": 2000.0, "c": 2000.0}
|
||||
cur += 3600 * 1000
|
||||
row = compute_day_row(settlement, 22, bar_map)
|
||||
self.assertIsNotNone(row)
|
||||
self.assertEqual(row["open"], 2000.0)
|
||||
self.assertEqual(row["high"], 2500.0)
|
||||
self.assertEqual(row["low"], 1800.0)
|
||||
self.assertEqual(row["up_points"], 500.0)
|
||||
self.assertEqual(row["down_points"], 200.0)
|
||||
self.assertEqual(row["amplitude"], 700.0)
|
||||
self.assertEqual(row["change"], 100.0)
|
||||
|
||||
def test_summary_max_amplitude(self):
|
||||
rows = [
|
||||
{"amplitude": 100, "up_points": 40, "down_points": 60, "change": 10, "settlement_day": "2026-07-01"},
|
||||
{"amplitude": 700, "up_points": 500, "down_points": 200, "change": -5, "settlement_day": "2026-07-02"},
|
||||
{"amplitude": 200, "up_points": 50, "down_points": 150, "change": 20, "settlement_day": "2026-07-03"},
|
||||
]
|
||||
s = summarize_rows(rows)
|
||||
self.assertEqual(s["max_amplitude"], 700)
|
||||
self.assertEqual(s["max_amplitude_day"], "2026-07-02")
|
||||
self.assertEqual(s["max_up_points"], 500)
|
||||
self.assertEqual(s["max_down_points"], 200)
|
||||
|
||||
def test_compute_with_mock_fetch(self):
|
||||
now = datetime(2026, 7, 22, 18, 0, tzinfo=TZ)
|
||||
|
||||
def fetch_fn(*, inst_id, since_ms, until_ms):
|
||||
bars = []
|
||||
t = since_ms - (since_ms % (3600 * 1000))
|
||||
while t <= until_ms:
|
||||
# synthetic: open 2000, one spike day
|
||||
o = 2000.0
|
||||
h = 2500.0 if t == since_ms + 5 * 3600 * 1000 else 2050.0
|
||||
l = 1800.0 if t == since_ms + 5 * 3600 * 1000 else 1950.0
|
||||
c = 2020.0
|
||||
bars.append(_bar(t, o, h, l, c))
|
||||
t += 3600 * 1000
|
||||
return bars
|
||||
|
||||
result = compute_amp_stats(
|
||||
symbol="eth",
|
||||
start_hour=16,
|
||||
period="custom",
|
||||
custom_days=7,
|
||||
now=now,
|
||||
fetch_fn=fetch_fn,
|
||||
)
|
||||
self.assertTrue(result["ok"])
|
||||
self.assertEqual(result["exchange"], "okx")
|
||||
self.assertGreaterEqual(result["summary"]["sample_count"], 1)
|
||||
csv_text = build_export_csv(result)
|
||||
self.assertIn("最大振幅", csv_text)
|
||||
self.assertIn("日表明细", csv_text)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user