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 <cursoragent@cursor.com>
This commit is contained in:
dekun
2026-07-28 15:05:38 +08:00
parent c81ba147cc
commit 26bc19f047
5 changed files with 147 additions and 25 deletions
+118 -16
View File
@@ -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()