From 26bc19f047cca807b233b328c0b43580257e4e8c Mon Sep 17 00:00:00 2001 From: dekun Date: Tue, 28 Jul 2026 15:05:38 +0800 Subject: [PATCH] Add two-day amplitude window to amp-stats. For each settlement day, also compute H-L over start minus one day through 16:00 (e.g. 25 16:00 to 27 16:00). Co-authored-by: Cursor --- docs/振幅统计说明.md | 13 ++- lib/hub/amp_stats_lib.py | 134 ++++++++++++++++++++++--- manual_trading_hub/static/amp_stats.js | 11 +- manual_trading_hub/static/index.html | 6 +- tests/test_amp_stats_lib.py | 8 ++ 5 files changed, 147 insertions(+), 25 deletions(-) diff --git a/docs/振幅统计说明.md b/docs/振幅统计说明.md index 88de03e..cc97b66 100644 --- a/docs/振幅统计说明.md +++ b/docs/振幅统计说明.md @@ -46,12 +46,13 @@ |------|------| | 开→高 | `H − O`(一边波动) | | 开→低 | `O − L`(另一边波动) | -| **振幅** | `H − L`(= 开→高 + 开→低) | -| 涨跌值 | `C − O` | +| **振幅** | `H − L`(= 开→高 + 开→低),窗为起点整点 → 当日 16:00 | +| **两日振幅** | 同上口径,但起点再往前推 1 天;例起点 16:00、结算 27 日 → **25日16:00 → 27日16:00** | +| 涨跌值 | `C − O`(单日窗) | 例:O=2000,H=2500,L=1800 → 开→高 500,开→低 200,振幅 **700**。 -汇总必含:最大振幅(及日期)、开→高/开→低的最大与均值等。 +汇总必含:最大振幅(及日期)、两日振幅最大/均值/中位、开→高/开→低的最大与均值等。 K 线粒度:**1H**(与整点对齐);价源优先 OKX 指数(ETH-USD / BTC-USD),失败再降级永续标记。 近期 K 线接口约仅 **1440** 根(1H≈60 天);更长周期自动续拉 `history-index-candles` / `history-candles`。 @@ -65,12 +66,13 @@ K 线粒度:**1H**(与整点对齐);价源优先 OKX 指数(ETH-USD / | 汇总项 | 口径 | |--------|------| -| 振幅≥点数 | `H−L ≥ 点数` 的天数与**占比**(主指标) | +| 振幅≥点数 | 单日窗 `H−L ≥ 点数` 的天数与**占比**(主指标) | +| 两日振幅≥点数 | 两日窗振幅 ≥ 点数 的天数与占比 | | 开→高≥点数 | `H−O ≥ 点数` 天数与占比 | | 开→低≥点数 | `O−L ≥ 点数` 天数与占比 | | \|涨跌\|≥点数 | `\|C−O\| ≥ 点数` 天数与占比 | -日表保留 **开→高 / 开→低**(两边波动点数),并标 **振幅达标**;达标行振幅会高亮。 +日表保留 **开→高 / 开→低**、**振幅**、**两日振幅**(悬停可见两日窗起止),并标 **振幅达标**。 改点数 / 周末筛选会在已有日表上**本地重算**(不重拉 K 线)。 ### 周末 @@ -108,3 +110,4 @@ K 线粒度:**1H**(与整点对齐);价源优先 OKX 指数(ETH-USD / | 2026-07-23 | 买跨对照、周末筛选、止盈点 | | 2026-07-28 | 永期对冲对照(后已移除) | | 2026-07-28 | 去掉买跨/永期;改为波动点数→振幅占比 | +| 2026-07-28 | 增加两日振幅(例 25日16:00→27日16:00) | diff --git a/lib/hub/amp_stats_lib.py b/lib/hub/amp_stats_lib.py index 6cdcc6d..d4de313 100644 --- a/lib/hub/amp_stats_lib.py +++ b/lib/hub/amp_stats_lib.py @@ -68,10 +68,20 @@ def resolve_sample_days(period: str, custom_days: Any = None) -> int: return PERIOD_DAYS[p] -def window_bounds_for_settlement(settlement: date, start_hour: int) -> tuple[datetime, datetime]: - """返回 [start, end) 的本地时刻;end 为结算日 16:00.""" +def window_bounds_for_settlement( + settlement: date, + start_hour: int, + *, + span_days: int = 1, +) -> tuple[datetime, datetime]: + """返回 [start, end) 的本地时刻;end 为结算日 16:00. + + span_days=1: 与现口径相同(如 26日16:00→27日16:00) + span_days=2: 再往前推 1 天(如 25日16:00→27日16:00) + """ if not (0 <= int(start_hour) <= 23): raise ValueError("起点须为 0-23 整点") + span = max(1, int(span_days or 1)) end = datetime(settlement.year, settlement.month, settlement.day, END_HOUR, 0, 0, tzinfo=APP_TZ) sh = int(start_hour) if sh >= END_HOUR: @@ -79,6 +89,8 @@ def window_bounds_for_settlement(settlement: date, start_hour: int) -> tuple[dat 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) + if span > 1: + start = start - timedelta(days=span - 1) return start, end @@ -127,12 +139,12 @@ def bars_to_map(bars: list[dict[str, Any]]) -> dict[int, dict[str, float]]: return m -def compute_day_row( - settlement: date, - start_hour: int, +def _ohlc_window_metrics( + start: datetime, + end: datetime, bar_map: dict[int, dict[str, float]], ) -> Optional[dict[str, Any]]: - start, end = window_bounds_for_settlement(settlement, start_hour) + """在 [start, end) 上算开高低收与开→高/开→低/振幅/涨跌.""" 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) @@ -153,15 +165,9 @@ def compute_day_row( down = opens - lo amp = hi - lo change = close - opens - wd = settlement.weekday() # Mon=0 … Sun=6 - is_we = wd >= 5 return { - "settlement_day": settlement.isoformat(), "window_start": start.strftime("%Y-%m-%d %H:%M"), "window_end": end.strftime("%Y-%m-%d %H:%M"), - "weekday": wd, - "weekday_label": "六" if wd == 5 else ("日" if wd == 6 else ""), - "is_weekend": is_we, "open": round(opens, 4), "high": round(hi, 4), "low": round(lo, 4), @@ -173,6 +179,59 @@ def compute_day_row( } +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, span_days=1) + m1 = _ohlc_window_metrics(start, end, bar_map) + if m1 is None: + return None + start2, end2 = window_bounds_for_settlement(settlement, start_hour, span_days=2) + m2 = _ohlc_window_metrics(start2, end2, bar_map) + wd = settlement.weekday() # Mon=0 … Sun=6 + is_we = wd >= 5 + row: dict[str, Any] = { + "settlement_day": settlement.isoformat(), + "weekday": wd, + "weekday_label": "六" if wd == 5 else ("日" if wd == 6 else ""), + "is_weekend": is_we, + **m1, + } + if m2 is None: + row.update( + { + "window2_start": start2.strftime("%Y-%m-%d %H:%M"), + "window2_end": end2.strftime("%Y-%m-%d %H:%M"), + "open_2d": None, + "high_2d": None, + "low_2d": None, + "close_2d": None, + "up_points_2d": None, + "down_points_2d": None, + "amplitude_2d": None, + "change_2d": None, + } + ) + else: + row.update( + { + "window2_start": m2["window_start"], + "window2_end": m2["window_end"], + "open_2d": m2["open"], + "high_2d": m2["high"], + "low_2d": m2["low"], + "close_2d": m2["close"], + "up_points_2d": m2["up_points"], + "down_points_2d": m2["down_points"], + "amplitude_2d": m2["amplitude"], + "change_2d": m2["change"], + } + ) + return row + + def normalize_move_points(raw: Any) -> Optional[float]: """对照波动点数.空/≤0 表示不做点数达标对照.""" if raw is None or raw == "": @@ -252,12 +311,16 @@ def enrich_rows( hit_up = bool(mp is not None and up >= mp) hit_down = bool(mp is not None and down >= mp) amp_hit = bool(mp is not None and amp >= mp) + amp2 = item.get("amplitude_2d") + amp2_v = float(amp2) if amp2 is not None and amp2 != "" else None + amp_hit_2d = bool(mp is not None and amp2_v is not None and amp2_v >= mp) item["move_points"] = mp item["hit_up"] = hit_up item["hit_down"] = hit_down item["hit_either"] = hit_up or hit_down item["hit_both"] = hit_up and hit_down item["amp_hit"] = amp_hit + item["amp_hit_2d"] = amp_hit_2d out.append(item) return out @@ -287,6 +350,8 @@ def move_points_stats(rows: list[dict[str, Any]], move_points: float) -> dict[st "both_hit_ratio": None, "amp_hit_days": 0, "amp_hit_ratio": None, + "amp_2d_hit_days": 0, + "amp_2d_hit_ratio": None, "abs_change_hit_days": 0, "abs_change_hit_ratio": None, } @@ -296,7 +361,10 @@ def move_points_stats(rows: list[dict[str, Any]], move_points: float) -> dict[st down_hit = sum(1 for r in work if r.get("hit_down")) either = sum(1 for r in work if r.get("hit_either")) both = sum(1 for r in work if r.get("hit_both")) - amp_hit = sum(1 for r in work if float(r.get("amplitude") or 0) >= mp) + amp_hit = sum(1 for r in work if r.get("amp_hit")) + amp2_rows = [r for r in work if r.get("amplitude_2d") is not None] + amp2_hit = sum(1 for r in work if r.get("amp_hit_2d")) + n2 = len(amp2_rows) abs_hit = sum(1 for r in work if abs(float(r.get("change") or 0)) >= mp) empty.update( { @@ -310,6 +378,8 @@ def move_points_stats(rows: list[dict[str, Any]], move_points: float) -> dict[st "both_hit_ratio": round(both / n, 4), "amp_hit_days": amp_hit, "amp_hit_ratio": round(amp_hit / n, 4), + "amp_2d_hit_days": amp2_hit, + "amp_2d_hit_ratio": round(amp2_hit / n2, 4) if n2 else None, "abs_change_hit_days": abs_hit, "abs_change_hit_ratio": round(abs_hit / n, 4), } @@ -323,6 +393,12 @@ def summarize_rows( move_points: Any = None, ) -> dict[str, Any]: mp = normalize_move_points(move_points) + empty_2d = { + "max_amplitude_2d": None, + "max_amplitude_2d_day": None, + "avg_amplitude_2d": None, + "median_amplitude_2d": None, + } if not rows: out = { "sample_count": 0, @@ -336,6 +412,7 @@ def summarize_rows( "avg_down_points": None, "up_day_ratio": None, "down_day_ratio": None, + **empty_2d, "move_points_stats": None, } if mp is not None: @@ -349,6 +426,7 @@ def summarize_rows( 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) + amps2 = [float(r["amplitude_2d"]) for r in rows if r.get("amplitude_2d") is not None] out: dict[str, Any] = { "sample_count": n, "max_amplitude": round(max_amp, 4), @@ -361,8 +439,17 @@ def summarize_rows( "avg_down_points": round(statistics.fmean(downs), 4), "up_day_ratio": round(up_days / n, 4), "down_day_ratio": round(down_days / n, 4), + **empty_2d, "move_points_stats": None, } + if amps2: + max_a2 = max(amps2) + out["max_amplitude_2d"] = round(max_a2, 4) + out["max_amplitude_2d_day"] = next( + r["settlement_day"] for r in rows if r.get("amplitude_2d") is not None and float(r["amplitude_2d"]) == max_a2 + ) + out["avg_amplitude_2d"] = round(statistics.fmean(amps2), 4) + out["median_amplitude_2d"] = round(statistics.median(amps2), 4) if mp is not None: out["move_points_stats"] = move_points_stats(rows, mp) return out @@ -583,11 +670,11 @@ def compute_amp_stats( settlements = list_settlement_dates(sample_days=sample_days, now=now) if not settlements: raise RuntimeError("无可用结算日") - # 最远窗起点 + # 最远窗起点(含两日振幅,多拉 1 天) oldest = settlements[-1] newest = settlements[0] - start0, _ = window_bounds_for_settlement(oldest, sh) - _, end1 = window_bounds_for_settlement(newest, sh) + start0, _ = window_bounds_for_settlement(oldest, sh, span_days=2) + _, end1 = window_bounds_for_settlement(newest, sh, span_days=1) since_ms = int(start0.timestamp() * 1000) until_ms = int(end1.timestamp() * 1000) bars, price_source, inst_id = fetch_symbol_bars( @@ -723,6 +810,8 @@ def build_export_csv(payload: dict[str, Any]) -> str: 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_amplitude_2d"), "日期", s.get("max_amplitude_2d_day")]) + w.writerow(["两日振幅均值", s.get("avg_amplitude_2d"), "中位数", s.get("median_amplitude_2d")]) 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")]) @@ -731,6 +820,7 @@ def build_export_csv(payload: dict[str, Any]) -> str: w.writerow([]) w.writerow(["【波动点数·振幅占比】", mp.get("move_points")]) w.writerow(["振幅≥点数天数", mp.get("amp_hit_days"), "占比", mp.get("amp_hit_ratio")]) + w.writerow(["两日振幅≥点数天数", mp.get("amp_2d_hit_days"), "占比", mp.get("amp_2d_hit_ratio")]) w.writerow(["开→高≥点数天数", mp.get("up_hit_days"), "占比", mp.get("up_hit_ratio")]) w.writerow(["开→低≥点数天数", mp.get("down_hit_days"), "占比", mp.get("down_hit_ratio")]) w.writerow(["|涨跌|≥点数天数", mp.get("abs_change_hit_days"), "占比", mp.get("abs_change_hit_ratio")]) @@ -751,8 +841,14 @@ def build_export_csv(payload: dict[str, Any]) -> str: "开→低", "振幅", "涨跌值", + "两日窗起点", + "两日窗终点", + "两日振幅", + "两日开→高", + "两日开→低", "对照点数", "振幅达标", + "两日振幅达标", ] ) for r in payload.get("rows") or []: @@ -771,8 +867,14 @@ def build_export_csv(payload: dict[str, Any]) -> str: r.get("down_points"), r.get("amplitude"), r.get("change"), + r.get("window2_start"), + r.get("window2_end"), + r.get("amplitude_2d"), + r.get("up_points_2d"), + r.get("down_points_2d"), r.get("move_points") if r.get("move_points") is not None else "", "是" if r.get("amp_hit") else ("否" if r.get("move_points") is not None else ""), + "是" if r.get("amp_hit_2d") else ("否" if r.get("move_points") is not None and r.get("amplitude_2d") is not None else ""), ] ) return buf.getvalue() diff --git a/manual_trading_hub/static/amp_stats.js b/manual_trading_hub/static/amp_stats.js index 4a670fa..6032eff 100644 --- a/manual_trading_hub/static/amp_stats.js +++ b/manual_trading_hub/static/amp_stats.js @@ -102,6 +102,8 @@ `
最大振幅${esc(s.max_amplitude)} (${esc(s.max_amplitude_day)})
` + `
振幅均值${esc(s.avg_amplitude)}
` + `
振幅中位${esc(s.median_amplitude)}
` + + `
两日最大振幅${esc(s.max_amplitude_2d)} (${esc(s.max_amplitude_2d_day)})
` + + `
两日振幅均值/中位${esc(s.avg_amplitude_2d)} / ${esc(s.median_amplitude_2d)}
` + `
开→高最大/均${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)}
` + @@ -121,6 +123,7 @@ `
` + `
对照点数${esc(ms.move_points)}
` + `
振幅≥点数${esc(ms.amp_hit_days)} 天 · ${esc(pct(ms.amp_hit_ratio))}
` + + `
两日振幅≥点数${esc(ms.amp_2d_hit_days)} 天 · ${esc(pct(ms.amp_2d_hit_ratio))}
` + `
开→高≥点数${esc(ms.up_hit_days)} 天 · ${esc(pct(ms.up_hit_ratio))}
` + `
开→低≥点数${esc(ms.down_hit_days)} 天 · ${esc(pct(ms.down_hit_ratio))}
` + `
|涨跌|≥点数${esc(ms.abs_change_hit_days)} 天 · ${esc(pct(ms.abs_change_hit_ratio))}
` + @@ -147,7 +150,7 @@ if (!body) return; const rows = (pagePayload && pagePayload.rows) || []; if (!rows.length) { - body.innerHTML = '暂无数据'; + body.innerHTML = '暂无数据'; } else { body.innerHTML = rows .map((r) => { @@ -155,6 +158,11 @@ const upCls = r.hit_up ? ' class="amp-pnl is-pos"' : ""; const downCls = r.hit_down ? ' class="amp-pnl is-pos"' : ""; const ampCls = r.amp_hit ? ' class="amp-pnl is-pos"' : ""; + const amp2Cls = r.amp_hit_2d ? ' class="amp-pnl is-pos"' : ""; + const amp2 = + r.amplitude_2d == null || r.amplitude_2d === "" + ? "—" + : `${esc(r.amplitude_2d)}`; return ( `` + `${dayLabel(r)}` + @@ -166,6 +174,7 @@ `${esc(r.up_points)}` + `${esc(r.down_points)}` + `${esc(r.amplitude)}` + + `${amp2}` + `${esc(r.change)}` + `${hitCell(r)}` + `` diff --git a/manual_trading_hub/static/index.html b/manual_trading_hub/static/index.html index 8f2b283..c307142 100644 --- a/manual_trading_hub/static/index.html +++ b/manual_trading_hub/static/index.html @@ -1275,7 +1275,7 @@

-

口径:开→高=最高−开盘;开→低=开盘−最低;振幅=最高−最低.填写波动点数后看振幅≥该点数的天数占比;日表显示两边波动(开→高/开→低)与振幅是否达标.周末按结算日标注/筛选.

+

口径:开→高=最高−开盘;开→低=开盘−最低;振幅=最高−最低.两日振幅=起点再往前推1天到当日16:00(例:25日16:00→27日16:00).填写波动点数后看振幅/两日振幅≥该点数的天数占比.周末按结算日标注/筛选.

汇总

振幅占比

@@ -1286,11 +1286,11 @@ 结算日窗起点开高低收 - 开→高开→低振幅涨跌振幅达标 + 开→高开→低振幅两日振幅涨跌振幅达标 - 点击「计算」加载 + 点击「计算」加载 diff --git a/tests/test_amp_stats_lib.py b/tests/test_amp_stats_lib.py index f0b22a1..37c1721 100644 --- a/tests/test_amp_stats_lib.py +++ b/tests/test_amp_stats_lib.py @@ -34,6 +34,14 @@ class AmpStatsLibTests(unittest.TestCase): 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_window_two_day_16_to_16(self): + # 结算 27 日 → 两日窗 25日16:00 → 27日16:00 + start, end = window_bounds_for_settlement(date(2026, 7, 27), 16, span_days=2) + self.assertEqual(start.strftime("%Y-%m-%d %H:%M"), "2026-07-25 16:00") + self.assertEqual(end.strftime("%Y-%m-%d %H:%M"), "2026-07-27 16:00") + one_start, _ = window_bounds_for_settlement(date(2026, 7, 27), 16, span_days=1) + self.assertEqual(one_start.strftime("%Y-%m-%d %H:%M"), "2026-07-26 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)