diff --git a/docs/振幅统计说明.md b/docs/振幅统计说明.md index 355283f..4ed6d23 100644 --- a/docs/振幅统计说明.md +++ b/docs/振幅统计说明.md @@ -57,19 +57,25 @@ K 线粒度:**1H**(与整点对齐);价源优先 OKX 指数(ETH-USD / ## 买跨对照(赌波动) -表单可填 **双边权利金(点)**,例如 `30`: +表单可填 **双边权利金(点)**,例如 `30`;旁边可填 **止盈点**(可空): | 汇总项 | 口径 | |--------|------| -| 开→高超过 | `H−O > 权利金` 的天数与占比 | -| 开→低超过 | `O−L > 权利金` 的天数与占比 | -| \|涨跌\|超过 | `\|C−O\| > 权利金` 的天数与占比 | -| 买跨盈亏 | 单日 `\|C−O\| − 权利金`,再看合计 / 日均 / 胜率 | +| 开→高超过权利金 | `H−O > 权利金` 的天数与占比 | +| 开→低超过权利金 | `O−L > 权利金` 的天数与占比 | +| \|涨跌\|超过权利金 | `\|C−O\| > 权利金` 的天数与占比 | +| 有效波动 | 若设止盈且 `开→高≥止盈` 或 `开→低≥止盈` → 用止盈点;否则用 `\|C−O\|` | +| 买跨收益 | `有效波动 − 权利金`(日表「收益」列同口径) | -- 方向:**买跨**(不是卖跨) -- 比较:严格 **`>`**(刚好等于权利金不算越过) -- 已算出日表后,改权利金会**本地重算对照**(不重拉 K 线) -- 空着或 ≤0:不显示买跨块 +- 方向:**买跨** +- 权利金越过:严格 **`>`**;止盈触达:**`≥`** +- 止盈留空 / ≤0:有效波动一律按 `|涨跌|` +- 已算出日表后,改权利金 / 止盈 / 周末筛选会**本地重算**(不重拉 K 线) + +### 周末 + +- 下拉:**全部**(默认)/ **排除周末** / **仅周末** +- 按 **结算日** 北京时间星期判断;表中六、日带标注并高亮 --- @@ -99,3 +105,4 @@ K 线粒度:**1H**(与整点对齐);价源优先 OKX 指数(ETH-USD / |------|------| | 2026-07-23 | 首版上线说明 | | 2026-07-23 | 买跨对照:可设双边权利金、越过占比与收盘盈亏 | +| 2026-07-23 | 周末筛选/标注、止盈点(≥)、日表收益列 | diff --git a/lib/hub/amp_stats_lib.py b/lib/hub/amp_stats_lib.py index ef9e61c..7e13c5c 100644 --- a/lib/hub/amp_stats_lib.py +++ b/lib/hub/amp_stats_lib.py @@ -150,10 +150,15 @@ 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), @@ -178,15 +183,113 @@ def normalize_straddle_premium(raw: Any) -> Optional[float]: return v -def straddle_long_stats(rows: list[dict[str, Any]], premium: float) -> dict[str, Any]: - """买跨(赌波动):盈亏按收盘 |C−O| − 双边权利金;越过阈值用严格 >.""" +def normalize_take_profit(raw: Any) -> Optional[float]: + """止盈点.空/≤0 表示不止盈,有效波动用 |涨跌|.""" + if raw is None or raw == "": + return None + try: + v = float(raw) + except (TypeError, ValueError): + raise ValueError("止盈点须为数字") from None + if v <= 0: + return None + return v + + +def normalize_weekend_filter(raw: Any) -> str: + """all | exclude | only;默认全部.""" + s = (str(raw) if raw is not None else "all").strip().lower() + if s in ("", "all", "全部"): + return "all" + if s in ("exclude", "exclude_weekend", "no_weekend", "排除周末"): + return "exclude" + if s in ("only", "weekend_only", "only_weekend", "仅周末"): + return "only" + raise ValueError("周末筛选须为 all / exclude / only") + + +def filter_weekend_rows(rows: list[dict[str, Any]], weekend_filter: Any = "all") -> list[dict[str, Any]]: + mode = normalize_weekend_filter(weekend_filter) + if mode == "all": + return list(rows or []) + out: list[dict[str, Any]] = [] + for r in rows or []: + is_we = bool(r.get("is_weekend")) + if "is_weekend" not in r and r.get("settlement_day"): + try: + is_we = date.fromisoformat(str(r["settlement_day"])).weekday() >= 5 + except ValueError: + is_we = False + if mode == "exclude" and is_we: + continue + if mode == "only" and not is_we: + continue + out.append(r) + return out + + +def effective_move_points(row: dict[str, Any], take_profit: Optional[float]) -> float: + """触达止盈(≥)用止盈点,否则用 |涨跌|.""" + abs_chg = abs(float(row.get("change") or 0)) + if take_profit is None: + return abs_chg + tp = float(take_profit) + up = float(row.get("up_points") or 0) + down = float(row.get("down_points") or 0) + if up >= tp or down >= tp: + return tp + return abs_chg + + +def enrich_rows_pnl( + rows: list[dict[str, Any]], + *, + straddle_premium: Optional[float] = None, + take_profit: Optional[float] = None, +) -> list[dict[str, Any]]: + """为日表附加有效波动 / 是否触达止盈 / 收益(有权利金时).""" + prem = normalize_straddle_premium(straddle_premium) + tp = normalize_take_profit(take_profit) + out: list[dict[str, Any]] = [] + for r in rows or []: + item = dict(r) + if "is_weekend" not in item and item.get("settlement_day"): + try: + wd = date.fromisoformat(str(item["settlement_day"])).weekday() + item["weekday"] = wd + item["weekday_label"] = "六" if wd == 5 else ("日" if wd == 6 else "") + item["is_weekend"] = wd >= 5 + except ValueError: + item.setdefault("weekday_label", "") + item.setdefault("is_weekend", False) + move = effective_move_points(item, tp) + hit = False + if tp is not None: + hit = float(item.get("up_points") or 0) >= tp or float(item.get("down_points") or 0) >= tp + item["effective_move"] = round(move, 4) + item["take_profit_hit"] = hit + item["profit"] = round(move - prem, 4) if prem is not None else None + out.append(item) + return out + + +def straddle_long_stats( + rows: list[dict[str, Any]], + premium: float, + *, + take_profit: Any = None, +) -> dict[str, Any]: + """买跨:越过权利金用严格 >;收益=有效波动−权利金(止盈≥触达用止盈点,否则|涨跌|).""" prem = float(premium) if prem <= 0: raise ValueError("双边权利金须 > 0") - if not rows: + tp = normalize_take_profit(take_profit) + enriched = enrich_rows_pnl(rows, straddle_premium=prem, take_profit=tp) + if not enriched: return { "side": "long_straddle", "premium": prem, + "take_profit": tp, "sample_count": 0, "up_exceed_days": 0, "up_exceed_ratio": None, @@ -194,6 +297,8 @@ def straddle_long_stats(rows: list[dict[str, Any]], premium: float) -> dict[str, "down_exceed_ratio": None, "abs_change_exceed_days": 0, "abs_change_exceed_ratio": None, + "tp_hit_days": 0, + "tp_hit_ratio": None, "pnl_total": None, "pnl_avg": None, "win_days": 0, @@ -201,15 +306,17 @@ def straddle_long_stats(rows: list[dict[str, Any]], premium: float) -> dict[str, "pnl_max": None, "pnl_min": None, } - n = len(rows) - up_ex = sum(1 for r in rows if float(r["up_points"]) > prem) - down_ex = sum(1 for r in rows if float(r["down_points"]) > prem) - abs_ex = sum(1 for r in rows if abs(float(r["change"])) > prem) - pnls = [abs(float(r["change"])) - prem for r in rows] + n = len(enriched) + up_ex = sum(1 for r in enriched if float(r["up_points"]) > prem) + down_ex = sum(1 for r in enriched if float(r["down_points"]) > prem) + abs_ex = sum(1 for r in enriched if abs(float(r["change"])) > prem) + tp_hits = sum(1 for r in enriched if r.get("take_profit_hit")) + pnls = [float(r["profit"]) for r in enriched if r.get("profit") is not None] win = sum(1 for p in pnls if p > 0) return { "side": "long_straddle", "premium": round(prem, 4), + "take_profit": round(tp, 4) if tp is not None else None, "sample_count": n, "up_exceed_days": up_ex, "up_exceed_ratio": round(up_ex / n, 4), @@ -217,6 +324,8 @@ def straddle_long_stats(rows: list[dict[str, Any]], premium: float) -> dict[str, "down_exceed_ratio": round(down_ex / n, 4), "abs_change_exceed_days": abs_ex, "abs_change_exceed_ratio": round(abs_ex / n, 4), + "tp_hit_days": tp_hits, + "tp_hit_ratio": round(tp_hits / n, 4) if tp is not None else None, "pnl_total": round(sum(pnls), 4), "pnl_avg": round(statistics.fmean(pnls), 4), "win_days": win, @@ -230,6 +339,7 @@ def summarize_rows( rows: list[dict[str, Any]], *, straddle_premium: Any = None, + take_profit: Any = None, ) -> dict[str, Any]: if not rows: out = { @@ -248,7 +358,7 @@ def summarize_rows( } prem = normalize_straddle_premium(straddle_premium) if prem is not None: - out["straddle"] = straddle_long_stats([], prem) + out["straddle"] = straddle_long_stats([], prem, take_profit=take_profit) return out amps = [float(r["amplitude"]) for r in rows] ups = [float(r["up_points"]) for r in rows] @@ -274,7 +384,7 @@ def summarize_rows( } prem = normalize_straddle_premium(straddle_premium) if prem is not None: - out["straddle"] = straddle_long_stats(rows, prem) + out["straddle"] = straddle_long_stats(rows, prem, take_profit=take_profit) return out @@ -388,6 +498,8 @@ def compute_amp_stats( period: str = "2m", custom_days: Any = None, straddle_premium: Any = None, + take_profit: Any = None, + weekend_filter: Any = "all", now: Optional[datetime] = None, fetch_fn: Optional[Callable[..., list[dict[str, Any]]]] = None, ) -> dict[str, Any]: @@ -396,6 +508,8 @@ def compute_amp_stats( if sh < 0 or sh > 23: raise ValueError("起点须为 0-23 整点") prem = normalize_straddle_premium(straddle_premium) + tp = normalize_take_profit(take_profit) + we_mode = normalize_weekend_filter(weekend_filter) sample_days = resolve_sample_days(period, custom_days) settlements = list_settlement_dates(sample_days=sample_days, now=now) if not settlements: @@ -411,37 +525,109 @@ def compute_amp_stats( key, since_ms=since_ms, until_ms=until_ms, fetch_fn=fetch_fn ) bar_map = bars_to_map(bars) - rows: list[dict[str, Any]] = [] + rows_all: 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, straddle_premium=prem) - period_label = period if period != "custom" else f"custom:{sample_days}" + rows_all.append(row) + return build_amp_result( + rows_all=rows_all, + symbol_key=key, + start_hour=sh, + period=period, + sample_days=sample_days, + straddle_premium=prem, + take_profit=tp, + weekend_filter=we_mode, + price_source=price_source, + inst_id=inst_id, + missing=missing, + ) + + +def build_amp_result( + *, + rows_all: list[dict[str, Any]], + symbol_key: str, + start_hour: int, + period: str, + sample_days: int, + straddle_premium: Any = None, + take_profit: Any = None, + weekend_filter: Any = "all", + price_source: str = "", + inst_id: str = "", + missing: Optional[list[str]] = None, +) -> dict[str, Any]: + prem = normalize_straddle_premium(straddle_premium) + tp = normalize_take_profit(take_profit) + we_mode = normalize_weekend_filter(weekend_filter) + filtered = filter_weekend_rows(rows_all, we_mode) + rows = enrich_rows_pnl(filtered, straddle_premium=prem, take_profit=tp) + summary = summarize_rows(rows, straddle_premium=prem, take_profit=tp) + if period == "custom" or str(period).startswith("custom:"): + period_label = period if str(period).startswith("custom:") else f"custom:{sample_days}" + else: + period_label = str(period) + miss = missing or [] return { "ok": True, "exchange": EXCHANGE, - "symbol": key, - "symbol_label": SYMBOLS[key]["label"], - "start_hour": sh, + "symbol": symbol_key, + "symbol_label": SYMBOLS[symbol_key]["label"], + "start_hour": start_hour, "end_hour": END_HOUR, "period": period_label, "sample_days_requested": sample_days, "straddle_premium": prem, + "take_profit": tp, + "weekend_filter": we_mode, "timeframe": TIMEFRAME, "price_source": price_source, "inst_id": inst_id, "timezone": "Asia/Shanghai", + "rows_all": rows_all, "rows": rows, "summary": summary, - "missing_days": missing[:30], - "missing_count": len(missing), + "missing_days": miss[:30], + "missing_count": len(miss), } +def reframe_amp_stats( + *, + rows_all: list[dict[str, Any]], + symbol: str = "eth", + start_hour: int = 16, + period: str = "2m", + sample_days: int = 60, + straddle_premium: Any = None, + take_profit: Any = None, + weekend_filter: Any = "all", + price_source: str = "", + inst_id: str = "", + missing: Optional[list[str]] = None, +) -> dict[str, Any]: + """已有日表上改周末/权利金/止盈,不拉 K 线.""" + key = normalize_symbol(symbol) + return build_amp_result( + rows_all=list(rows_all or []), + symbol_key=key, + start_hour=int(start_hour), + period=period, + sample_days=int(sample_days or 60), + straddle_premium=straddle_premium, + take_profit=take_profit, + weekend_filter=weekend_filter, + price_source=price_source, + inst_id=inst_id, + missing=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))) @@ -470,6 +656,7 @@ def build_export_csv(payload: dict[str, Any]) -> str: w.writerow(["起点整点", f"{payload.get('start_hour')}:00"]) w.writerow(["终点", f"{payload.get('end_hour')}:00"]) w.writerow(["周期", payload.get("period")]) + w.writerow(["周末筛选", payload.get("weekend_filter")]) 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")]) @@ -479,10 +666,12 @@ def build_export_csv(payload: dict[str, Any]) -> str: st = s.get("straddle") or {} if st: w.writerow([]) - w.writerow(["【买跨对照·双边权利金】", st.get("premium")]) - w.writerow(["开→高超过", st.get("up_exceed_days"), "占比", st.get("up_exceed_ratio")]) - w.writerow(["开→低超过", st.get("down_exceed_days"), "占比", st.get("down_exceed_ratio")]) - w.writerow(["|涨跌|超过", st.get("abs_change_exceed_days"), "占比", st.get("abs_change_exceed_ratio")]) + w.writerow(["【买跨对照·双边权利金】", st.get("premium"), "止盈点", st.get("take_profit")]) + w.writerow(["开→高超过权利金", st.get("up_exceed_days"), "占比", st.get("up_exceed_ratio")]) + w.writerow(["开→低超过权利金", st.get("down_exceed_days"), "占比", st.get("down_exceed_ratio")]) + w.writerow(["|涨跌|超过权利金", st.get("abs_change_exceed_days"), "占比", st.get("abs_change_exceed_ratio")]) + if st.get("take_profit") is not None: + w.writerow(["触达止盈天数", st.get("tp_hit_days"), "占比", st.get("tp_hit_ratio")]) w.writerow( [ "买跨点数盈亏合计", @@ -501,6 +690,8 @@ def build_export_csv(payload: dict[str, Any]) -> str: w.writerow( [ "结算日", + "星期", + "周末", "窗起点", "窗终点", "开盘", @@ -511,12 +702,17 @@ def build_export_csv(payload: dict[str, Any]) -> str: "开→低", "振幅", "涨跌值", + "有效波动", + "触达止盈", + "收益", ] ) for r in payload.get("rows") or []: w.writerow( [ r.get("settlement_day"), + r.get("weekday_label") or "", + "是" if r.get("is_weekend") else "否", r.get("window_start"), r.get("window_end"), r.get("open"), @@ -527,6 +723,9 @@ def build_export_csv(payload: dict[str, Any]) -> str: r.get("down_points"), r.get("amplitude"), r.get("change"), + r.get("effective_move"), + "是" if r.get("take_profit_hit") else "否", + r.get("profit"), ] ) return buf.getvalue() diff --git a/manual_trading_hub/amp_stats_routes.py b/manual_trading_hub/amp_stats_routes.py index 25ab4aa..c20f4f2 100644 --- a/manual_trading_hub/amp_stats_routes.py +++ b/manual_trading_hub/amp_stats_routes.py @@ -13,9 +13,10 @@ from lib.hub.amp_stats_lib import ( compute_amp_stats, export_filename, normalize_straddle_premium, + normalize_take_profit, + normalize_weekend_filter, + reframe_amp_stats, rows_page, - straddle_long_stats, - summarize_rows, ) @@ -25,6 +26,8 @@ class ComputeBody(BaseModel): period: str = "2m" custom_days: Optional[int] = None straddle_premium: Optional[float] = None + take_profit: Optional[float] = None + weekend_filter: str = "all" page: int = 1 page_size: int = 20 @@ -33,11 +36,21 @@ class SaveBody(BaseModel): result: dict[str, Any] = Field(default_factory=dict) -class StraddleBody(BaseModel): - """已有日表上按权利金重算买跨对照(不拉 K 线).""" +class ReframeBody(BaseModel): + """已有日表上改周末/权利金/止盈(不拉 K 线).""" - rows: list[dict[str, Any]] = Field(default_factory=list) + rows_all: list[dict[str, Any]] = Field(default_factory=list) + symbol: str = "eth" + start_hour: int = 16 + period: str = "2m" + sample_days: int = 60 straddle_premium: Optional[float] = None + take_profit: Optional[float] = None + weekend_filter: str = "all" + price_source: str = "" + inst_id: str = "" + page: int = 1 + page_size: int = 20 def create_amp_stats_router() -> APIRouter: @@ -62,10 +75,16 @@ def create_amp_stats_router() -> APIRouter: {"key": "1y", "label": "1年"}, {"key": "custom", "label": "自定义"}, ], + "weekend_filters": [ + {"key": "all", "label": "全部"}, + {"key": "exclude", "label": "排除周末"}, + {"key": "only", "label": "仅周末"}, + ], "default_period": "2m", + "default_weekend_filter": "all", "timeframe": "1H", "metric_note": "振幅与距离均为点数:振幅=最高-最低=(开→高)+(开→低)", - "straddle_note": "买跨对照:双边权利金可设;越过用严格>;盈亏=|收-开|-权利金", + "straddle_note": "买跨:越过权利金用>;止盈≥触达用止盈点否则|涨跌|;收益=有效波动-权利金", } @router.post("/compute") @@ -77,6 +96,8 @@ def create_amp_stats_router() -> APIRouter: period=body.period, custom_days=body.custom_days, straddle_premium=body.straddle_premium, + take_profit=body.take_profit, + weekend_filter=body.weekend_filter, ) except ValueError as exc: raise HTTPException(status_code=400, detail=str(exc)) from exc @@ -89,19 +110,28 @@ def create_amp_stats_router() -> APIRouter: "page": page, } - @router.post("/straddle") - def api_straddle(body: StraddleBody): + @router.post("/reframe") + def api_reframe(body: ReframeBody): + rows_all = body.rows_all or [] + if not rows_all: + raise HTTPException(status_code=400, detail="无日表可重算") try: - prem = normalize_straddle_premium(body.straddle_premium) + result = reframe_amp_stats( + rows_all=rows_all, + symbol=body.symbol, + start_hour=body.start_hour, + period=body.period, + sample_days=body.sample_days, + straddle_premium=body.straddle_premium, + take_profit=body.take_profit, + weekend_filter=body.weekend_filter, + price_source=body.price_source, + inst_id=body.inst_id, + ) except ValueError as exc: raise HTTPException(status_code=400, detail=str(exc)) from exc - if prem is None: - return {"ok": True, "straddle": None} - try: - st = straddle_long_stats(body.rows or [], prem) - except ValueError as exc: - raise HTTPException(status_code=400, detail=str(exc)) from exc - return {"ok": True, "straddle": st} + 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): @@ -110,7 +140,7 @@ def create_amp_stats_router() -> APIRouter: @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"): + if not payload.get("rows") and not payload.get("rows_all") and not payload.get("summary"): raise HTTPException(status_code=400, detail="无可保存的结果") item = save_history(payload) return {"ok": True, "item": item} @@ -136,28 +166,46 @@ def create_amp_stats_router() -> APIRouter: period: str = Query(default="2m"), custom_days: Optional[int] = Query(default=None), straddle_premium: Optional[float] = Query(default=None), + take_profit: Optional[float] = Query(default=None), + weekend_filter: str = Query(default="all"), ): if (history_id or "").strip(): item = get_history(history_id.strip()) if not item: raise HTTPException(status_code=404, detail="历史不存在") - payload = dict(item) + rows_all = item.get("rows_all") or item.get("rows") or [] try: - prem = normalize_straddle_premium(straddle_premium) + payload = reframe_amp_stats( + rows_all=rows_all, + symbol=item.get("symbol") or symbol, + start_hour=int(item.get("start_hour") if item.get("start_hour") is not None else start_hour), + period=str(item.get("period") or period), + sample_days=int(item.get("sample_days_requested") or 60), + straddle_premium=straddle_premium + if straddle_premium is not None + else item.get("straddle_premium"), + take_profit=take_profit if take_profit is not None else item.get("take_profit"), + weekend_filter=weekend_filter or item.get("weekend_filter") or "all", + price_source=str(item.get("price_source") or ""), + inst_id=str(item.get("inst_id") or ""), + missing=item.get("missing_days") or [], + ) except ValueError as exc: raise HTTPException(status_code=400, detail=str(exc)) from exc - if prem is not None: - summary = summarize_rows(payload.get("rows") or [], straddle_premium=prem) - payload["summary"] = summary - payload["straddle_premium"] = prem else: try: + # validate enums early + normalize_weekend_filter(weekend_filter) + normalize_straddle_premium(straddle_premium) + normalize_take_profit(take_profit) payload = compute_amp_stats( symbol=symbol, start_hour=start_hour, period=period, custom_days=custom_days, straddle_premium=straddle_premium, + take_profit=take_profit, + weekend_filter=weekend_filter, ) except ValueError as exc: raise HTTPException(status_code=400, detail=str(exc)) from exc diff --git a/manual_trading_hub/static/amp_stats.js b/manual_trading_hub/static/amp_stats.js index d579617..6707f38 100644 --- a/manual_trading_hub/static/amp_stats.js +++ b/manual_trading_hub/static/amp_stats.js @@ -1,5 +1,5 @@ /** - * 中控振幅统计:OKX ETH/BTC 时段点数振幅 + 买跨对照. + * 中控振幅统计:OKX ETH/BTC + 买跨/止盈/周末筛选. */ (function () { const page = document.getElementById("page-amp-stats"); @@ -8,7 +8,7 @@ let lastResult = null; let pageNo = 1; let bound = false; - let straddleTimer = null; + let reframeTimer = null; const el = (id) => document.getElementById(id); @@ -47,6 +47,18 @@ return n; } + function readTakeProfit() { + const raw = (el("amp-take-profit")?.value || "").trim(); + if (!raw) return null; + const n = Number(raw); + if (!Number.isFinite(n) || n <= 0) return null; + return n; + } + + function readWeekend() { + return el("amp-weekend-filter")?.value || "all"; + } + function setStatus(msg) { const s = el("amp-status"); if (s) s.textContent = msg || ""; @@ -127,12 +139,17 @@ : Number(st.pnl_total) < 0 ? "样本合计亏损" : "样本合计持平"; + const tpLine = + st.take_profit != null + ? `
止盈点 / 触达${esc(st.take_profit)} · ${esc(st.tp_hit_days)} 天 · ${esc(pct(st.tp_hit_ratio))}
` + : `
止盈点未设(按|涨跌|)
`; box.innerHTML = `
` + `
双边权利金${esc(st.premium)}
` + - `
开→高超过${esc(st.up_exceed_days)} 天 · ${esc(pct(st.up_exceed_ratio))}
` + - `
开→低超过${esc(st.down_exceed_days)} 天 · ${esc(pct(st.down_exceed_ratio))}
` + - `
|涨跌|超过${esc(st.abs_change_exceed_days)} 天 · ${esc(pct(st.abs_change_exceed_ratio))}
` + + tpLine + + `
开→高超过权利金${esc(st.up_exceed_days)} 天 · ${esc(pct(st.up_exceed_ratio))}
` + + `
开→低超过权利金${esc(st.down_exceed_days)} 天 · ${esc(pct(st.down_exceed_ratio))}
` + + `
|涨跌|超过权利金${esc(st.abs_change_exceed_days)} 天 · ${esc(pct(st.abs_change_exceed_ratio))}
` + `
买跨盈亏合计${esc(st.pnl_total)} (${esc(verdict)})
` + `
日均盈亏${esc(st.pnl_avg)}
` + `
赚钱天数/胜率${esc(st.win_days)} · ${esc(pct(st.win_ratio))}
` + @@ -140,6 +157,14 @@ `
`; } + function dayLabel(r) { + const day = esc(r.settlement_day); + if (r.is_weekend && r.weekday_label) { + return `${day}${esc(r.weekday_label)}`; + } + return day; + } + function renderTable(pagePayload) { const body = el("amp-table-body"); const pager = el("amp-pager"); @@ -149,10 +174,15 @@ body.innerHTML = '暂无数据'; } else { body.innerHTML = rows - .map( - (r) => - `` + - `${esc(r.settlement_day)}` + + .map((r) => { + const profit = + r.profit == null || r.profit === "" + ? "—" + : `${esc(r.profit)}`; + const trClass = r.is_weekend ? ' class="amp-row-weekend"' : ""; + return ( + `` + + `${dayLabel(r)}` + `${esc(r.window_start)}` + `${esc(r.open)}` + `${esc(r.high)}` + @@ -162,8 +192,10 @@ `${esc(r.down_points)}` + `${esc(r.amplitude)}` + `${esc(r.change)}` + + `${profit}` + `` - ) + ); + }) .join(""); } if (pager && pagePayload) { @@ -174,49 +206,66 @@ el("amp-page-prev")?.addEventListener("click", () => { if (pageNo > 1) { pageNo -= 1; - void compute(false); + void reframe(false); } }); el("amp-page-next")?.addEventListener("click", () => { if (pagePayload.page < pagePayload.total_pages) { pageNo += 1; - void compute(false); + void reframe(false); } }); } } - async function applyStraddleOnly() { - if (!lastResult || !Array.isArray(lastResult.rows)) { - renderStraddle(null); - return; - } - const prem = readPremium(); - if (prem == null) { - if (lastResult.summary) lastResult.summary.straddle = null; - lastResult.straddle_premium = null; + function rowsAllFromLast() { + if (!lastResult) return []; + if (Array.isArray(lastResult.rows_all) && lastResult.rows_all.length) return lastResult.rows_all; + return lastResult.rows || []; + } + + async function reframe(resetPage) { + if (!lastResult) { renderStraddle(null); return; } + if (resetPage) pageNo = 1; + const rowsAll = rowsAllFromLast(); + if (!rowsAll.length) return; try { - const data = await apiFetch("/api/amp-stats/straddle", { + const data = await apiFetch("/api/amp-stats/reframe", { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ rows: lastResult.rows, straddle_premium: prem }), + body: JSON.stringify({ + rows_all: rowsAll, + symbol: lastResult.symbol || el("amp-symbol")?.value || "eth", + start_hour: lastResult.start_hour ?? Number(el("amp-start-hour")?.value || 16), + period: lastResult.period || el("amp-period")?.value || "2m", + sample_days: lastResult.sample_days_requested || 60, + straddle_premium: readPremium(), + take_profit: readTakeProfit(), + weekend_filter: readWeekend(), + price_source: lastResult.price_source || "", + inst_id: lastResult.inst_id || "", + page: pageNo, + page_size: 20, + }), }); - const st = data.straddle || null; - if (!lastResult.summary) lastResult.summary = {}; - lastResult.summary.straddle = st; - lastResult.straddle_premium = prem; - renderStraddle(st); + const prevAll = rowsAll; + lastResult = data.result || lastResult; + if (!lastResult.rows_all || !lastResult.rows_all.length) lastResult.rows_all = prevAll; + renderSummary(lastResult.summary, lastResult); + renderTable(data.page); + setStatus(`完成 · 样本 ${(lastResult.summary || {}).sample_count || 0}`); } catch (e) { setStatus(String(e && e.message ? e.message : e)); } } - function scheduleStraddleRefresh() { - if (straddleTimer) clearTimeout(straddleTimer); - straddleTimer = setTimeout(() => void applyStraddleOnly(), 280); + function scheduleReframe() { + if (!lastResult) return; + if (reframeTimer) clearTimeout(reframeTimer); + reframeTimer = setTimeout(() => void reframe(true), 280); } async function compute(resetPage) { @@ -225,7 +274,6 @@ 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 straddlePremium = readPremium(); setStatus("计算中…(首次拉取 OKX K 线可能需数十秒)"); try { const data = await apiFetch("/api/amp-stats/compute", { @@ -236,7 +284,9 @@ start_hour: startHour, period, custom_days: period === "custom" ? customDays : null, - straddle_premium: straddlePremium, + straddle_premium: readPremium(), + take_profit: readTakeProfit(), + weekend_filter: readWeekend(), page: pageNo, page_size: 20, }), @@ -282,13 +332,16 @@ const period = el("amp-period")?.value || "2m"; const customDays = Number(el("amp-custom-days")?.value || 60); const prem = readPremium(); + const tp = readTakeProfit(); const q = new URLSearchParams({ symbol, start_hour: String(startHour), period, + weekend_filter: readWeekend(), }); if (period === "custom") q.set("custom_days", String(customDays)); if (prem != null) q.set("straddle_premium", String(prem)); + if (tp != null) q.set("take_profit", String(tp)); window.location.href = "/api/amp-stats/export?" + q.toString(); } @@ -323,8 +376,14 @@ card.querySelector(".amp-hist-view")?.addEventListener("click", () => void openHistory(id)); card.querySelector(".amp-hist-dl")?.addEventListener("click", () => { const prem = readPremium(); - let url = "/api/amp-stats/export?history_id=" + encodeURIComponent(id); + const tp = readTakeProfit(); + let url = + "/api/amp-stats/export?history_id=" + + encodeURIComponent(id) + + "&weekend_filter=" + + encodeURIComponent(readWeekend()); if (prem != null) url += "&straddle_premium=" + encodeURIComponent(String(prem)); + if (tp != null) url += "&take_profit=" + encodeURIComponent(String(tp)); window.location.href = url; }); card.querySelector(".amp-hist-del")?.addEventListener("click", async () => { @@ -349,18 +408,15 @@ if (lastResult.straddle_premium != null && el("amp-straddle-premium")) { el("amp-straddle-premium").value = String(lastResult.straddle_premium); } - 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), - }; + if (lastResult.take_profit != null && el("amp-take-profit")) { + el("amp-take-profit").value = String(lastResult.take_profit); + } + if (lastResult.weekend_filter && el("amp-weekend-filter")) { + el("amp-weekend-filter").value = lastResult.weekend_filter; + } pageNo = 1; - renderTable(pagePayload); setStatus("已载入历史 " + id); - void applyStraddleOnly(); + await reframe(true); } } catch (e) { setStatus(String(e && e.message ? e.message : e)); @@ -378,7 +434,9 @@ el("amp-btn-compute")?.addEventListener("click", () => void compute(true)); el("amp-btn-save")?.addEventListener("click", () => void saveHistory()); el("amp-btn-download")?.addEventListener("click", downloadCurrent); - el("amp-straddle-premium")?.addEventListener("input", scheduleStraddleRefresh); + el("amp-straddle-premium")?.addEventListener("input", scheduleReframe); + el("amp-take-profit")?.addEventListener("input", scheduleReframe); + el("amp-weekend-filter")?.addEventListener("change", () => void reframe(true)); syncCustomDays(); } diff --git a/manual_trading_hub/static/app.css b/manual_trading_hub/static/app.css index ebdcd10..c89d53e 100644 --- a/manual_trading_hub/static/app.css +++ b/manual_trading_hub/static/app.css @@ -10973,6 +10973,12 @@ html[data-theme="light"] .hub-logs-card-hint { .amp-sum-v.is-pos { color: var(--green); } .amp-sum-v.is-neg { color: var(--red); } .amp-straddle { margin-bottom: 4px; } +.amp-table tr.amp-row-weekend td { background: rgba(255, 180, 60, 0.08); } +.amp-wd-tag { + display: inline-block; margin-left: 6px; padding: 1px 6px; border-radius: 4px; + font-size: 11px; font-weight: 600; color: #f0c14b; + border: 1px solid rgba(240, 193, 75, 0.45); +} .amp-table-wrap { overflow-x: auto; } .amp-table { width: 100%; border-collapse: collapse; font-size: 12px; } .amp-table th, .amp-table td { diff --git a/manual_trading_hub/static/index.html b/manual_trading_hub/static/index.html index 7ed831d..217e1dd 100644 --- a/manual_trading_hub/static/index.html +++ b/manual_trading_hub/static/index.html @@ -16,7 +16,7 @@ - + @@ -1051,10 +1051,22 @@ 自定义天数 + +
@@ -1062,7 +1074,7 @@

-

口径:开→高=最高−开盘;开→低=开盘−最低;振幅=最高−最低(均为点数).未到 16:00 的当日不入样.买跨对照:越过用严格 >;盈亏=|收−开|−双边权利金.

+

口径:开→高=最高−开盘;开→低=开盘−最低;振幅=最高−最低.买跨收益=有效波动−权利金;止盈≥触达则有效波动=止盈点,否则用|涨跌|.周末按结算日标注/筛选.

汇总

买跨对照

@@ -1073,11 +1085,11 @@ 结算日窗起点开高低收 - 开→高开→低振幅涨跌 + 开→高开→低振幅涨跌收益 - 点击「计算」加载 + 点击「计算」加载 @@ -1496,7 +1508,7 @@ - + diff --git a/tests/test_amp_stats_lib.py b/tests/test_amp_stats_lib.py index a800078..a56bfe1 100644 --- a/tests/test_amp_stats_lib.py +++ b/tests/test_amp_stats_lib.py @@ -114,6 +114,83 @@ class AmpStatsLibTests(unittest.TestCase): self.assertIn("买跨对照", csv_text) self.assertIn("买跨点数盈亏合计", csv_text) + def test_take_profit_and_weekend(self): + from lib.hub.amp_stats_lib import ( + enrich_rows_pnl, + filter_weekend_rows, + reframe_amp_stats, + ) + + # Sat 2026-07-18, Sun 2026-07-19, Mon 2026-07-20 + rows = [ + { + "settlement_day": "2026-07-18", + "is_weekend": True, + "weekday_label": "六", + "up_points": 100, + "down_points": 10, + "change": -5, + "amplitude": 110, + }, + { + "settlement_day": "2026-07-19", + "is_weekend": True, + "weekday_label": "日", + "up_points": 20, + "down_points": 15, + "change": 12, + "amplitude": 35, + }, + { + "settlement_day": "2026-07-20", + "is_weekend": False, + "weekday_label": "", + "up_points": 50, + "down_points": 40, + "change": 8, + "amplitude": 90, + }, + ] + excl = filter_weekend_rows(rows, "exclude") + self.assertEqual(len(excl), 1) + self.assertEqual(excl[0]["settlement_day"], "2026-07-20") + only = filter_weekend_rows(rows, "only") + self.assertEqual(len(only), 2) + + # TP=80: day1 hit → move 80; day2 no → |12|; day3 no → 8 + enriched = enrich_rows_pnl(rows, straddle_premium=10, take_profit=80) + self.assertTrue(enriched[0]["take_profit_hit"]) + self.assertEqual(enriched[0]["effective_move"], 80) + self.assertEqual(enriched[0]["profit"], 70) + self.assertFalse(enriched[1]["take_profit_hit"]) + self.assertEqual(enriched[1]["effective_move"], 12) + self.assertEqual(enriched[1]["profit"], 2) + # TP empty → use |change| + no_tp = enrich_rows_pnl(rows[:1], straddle_premium=10, take_profit=None) + self.assertEqual(no_tp[0]["effective_move"], 5) + self.assertEqual(no_tp[0]["profit"], -5) + + # TP boundary >= : up=80 counts as hit + edge = enrich_rows_pnl( + [{"up_points": 80, "down_points": 1, "change": 2, "settlement_day": "2026-07-20", "is_weekend": False}], + straddle_premium=10, + take_profit=80, + ) + self.assertTrue(edge[0]["take_profit_hit"]) + self.assertEqual(edge[0]["profit"], 70) + + reframed = reframe_amp_stats( + rows_all=rows, + symbol="eth", + weekend_filter="exclude", + straddle_premium=10, + take_profit=80, + ) + self.assertEqual(reframed["summary"]["sample_count"], 1) + # Mon: 未触达止盈 → |8|-10 + self.assertEqual(reframed["rows"][0]["profit"], -2) + self.assertIn("收益", build_export_csv(reframed)) + def test_compute_with_mock_fetch(self): now = datetime(2026, 7, 22, 18, 0, tzinfo=TZ)