fix(web): 自动刷新保留 K 线视窗;威科夫与图表增量更新
自动刷新改用 tail update 与 scrollToPosition 恢复视窗,避免 setData 后跳到最右;拆分 chart_tv 模块并扩展 analyze/recent API。同步威科夫分析、pipeline 增量构建及相关策略与配置。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,566 @@
|
||||
"""
|
||||
Maker Edge 事件记录器(Dry-run / Live)— Execution Reality Layer
|
||||
|
||||
事件:
|
||||
- quote_created / quote_canceled / quote_filled (报价生命周期)
|
||||
- book_tick (可选心跳,用于成交前5s盘口)
|
||||
- fill (成交瞬间 + 盘口状态)
|
||||
- fill_path (10s/30s/1m/5m + Price/Time MAE/MFE)
|
||||
|
||||
输出:user_data/logs/maker_edge/YYYYMMDD.jsonl
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
import uuid
|
||||
from collections import deque
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any, Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _utc_now() -> datetime:
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
|
||||
def _iso(ts: datetime | float | None = None) -> str:
|
||||
if ts is None:
|
||||
t = _utc_now()
|
||||
elif isinstance(ts, (int, float)):
|
||||
t = datetime.fromtimestamp(ts, tz=timezone.utc)
|
||||
else:
|
||||
t = ts if ts.tzinfo else ts.replace(tzinfo=timezone.utc)
|
||||
return t.isoformat()
|
||||
|
||||
|
||||
@dataclass
|
||||
class MicroSnapshot:
|
||||
best_bid: float = 0.0
|
||||
best_ask: float = 0.0
|
||||
mid: float = 0.0
|
||||
spread: float = 0.0
|
||||
bid_depth_1: float = 0.0
|
||||
ask_depth_1: float = 0.0
|
||||
bid_depth_5: float = 0.0
|
||||
ask_depth_5: float = 0.0
|
||||
bid_depth: float = 0.0 # top-N
|
||||
ask_depth: float = 0.0
|
||||
obi: float = 0.0
|
||||
delta: float = 0.0
|
||||
trade_imbalance: float = 0.0 # (buy-sell)/(buy+sell) on recent trades
|
||||
delta_efficiency: float = 0.0
|
||||
liquidation_distance: float = 0.0
|
||||
|
||||
def to_book_fields(self) -> dict[str, float]:
|
||||
return {
|
||||
"bid_price": self.best_bid,
|
||||
"ask_price": self.best_ask,
|
||||
"mid": self.mid,
|
||||
"spread": self.spread,
|
||||
"bid_depth_1": self.bid_depth_1,
|
||||
"ask_depth_1": self.ask_depth_1,
|
||||
"bid_depth_5": self.bid_depth_5,
|
||||
"ask_depth_5": self.ask_depth_5,
|
||||
"bid_depth": self.bid_depth,
|
||||
"ask_depth": self.ask_depth,
|
||||
"obi": self.obi,
|
||||
"delta": self.delta,
|
||||
"trade_imbalance": self.trade_imbalance,
|
||||
"delta_efficiency": self.delta_efficiency,
|
||||
"liquidation_distance": self.liquidation_distance,
|
||||
# 兼容旧字段
|
||||
"buy1_depth": self.bid_depth_1,
|
||||
"sell1_depth": self.ask_depth_1,
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class ActiveQuote:
|
||||
quote_id: str
|
||||
pair: str
|
||||
side: str # bid / ask
|
||||
quote_price: float
|
||||
created_ts: float
|
||||
reason: str = ""
|
||||
trade_id: Optional[int] = None
|
||||
status: str = "open" # open / filled / canceled
|
||||
|
||||
|
||||
@dataclass
|
||||
class PendingFillPath:
|
||||
fill_id: str
|
||||
pair: str
|
||||
side: str
|
||||
fill_price: float
|
||||
fill_ts: float
|
||||
quote_id: Optional[str] = None
|
||||
exit_reason: Optional[str] = None
|
||||
# horizon prices
|
||||
after_10s_price: Optional[float] = None
|
||||
after_30s_price: Optional[float] = None
|
||||
after_1m_price: Optional[float] = None
|
||||
after_5m_price: Optional[float] = None
|
||||
# running extrema
|
||||
min_price: float = 0.0
|
||||
max_price: float = 0.0
|
||||
# time-MAE: worst adverse excursion seen by each horizon (signed, adverse negative for long)
|
||||
mae_10s: Optional[float] = None
|
||||
mae_30s: Optional[float] = None
|
||||
mae_1m: Optional[float] = None
|
||||
mae_5m: Optional[float] = None
|
||||
mfe_10s: Optional[float] = None
|
||||
mfe_30s: Optional[float] = None
|
||||
mfe_1m: Optional[float] = None
|
||||
mfe_5m: Optional[float] = None
|
||||
done: bool = False
|
||||
|
||||
def __post_init__(self):
|
||||
self.min_price = self.fill_price
|
||||
self.max_price = self.fill_price
|
||||
|
||||
def signed_excursions(self) -> tuple[float, float]:
|
||||
"""Return (mae, mfe) at current min/max. mae<=0 adverse, mfe>=0 favorable."""
|
||||
if self.side == "long":
|
||||
mae = (self.min_price - self.fill_price) / self.fill_price
|
||||
mfe = (self.max_price - self.fill_price) / self.fill_price
|
||||
else:
|
||||
mae = (self.fill_price - self.max_price) / self.fill_price
|
||||
mfe = (self.fill_price - self.min_price) / self.fill_price
|
||||
return mae, mfe
|
||||
|
||||
|
||||
class MakerEdgeLogger:
|
||||
def __init__(
|
||||
self,
|
||||
log_dir: str | Path | None = None,
|
||||
levels: int = 10,
|
||||
book_history_sec: float = 30.0,
|
||||
):
|
||||
root = Path(__file__).resolve().parents[2]
|
||||
self.log_dir = Path(log_dir) if log_dir else root / "logs" / "maker_edge"
|
||||
self.log_dir.mkdir(parents=True, exist_ok=True)
|
||||
self.levels = levels
|
||||
self.book_history_sec = book_history_sec
|
||||
self._pending: dict[str, PendingFillPath] = {}
|
||||
self._quotes: dict[str, ActiveQuote] = {} # quote_id -> ActiveQuote
|
||||
self._quotes_by_trade: dict[int, str] = {} # trade_id -> quote_id
|
||||
self._book_hist: deque[tuple[float, MicroSnapshot]] = deque(maxlen=2000)
|
||||
|
||||
def _file(self) -> Path:
|
||||
return self.log_dir / f"{_utc_now().strftime('%Y%m%d')}.jsonl"
|
||||
|
||||
def write(self, event: dict[str, Any]) -> None:
|
||||
event.setdefault("ts", _iso())
|
||||
event.setdefault("ts_epoch", time.time())
|
||||
with self._file().open("a", encoding="utf-8") as f:
|
||||
f.write(json.dumps(event, ensure_ascii=False, default=str) + "\n")
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Snapshot
|
||||
# ------------------------------------------------------------------ #
|
||||
@staticmethod
|
||||
def snapshot_from_orderbook(
|
||||
ob: dict,
|
||||
levels: int = 10,
|
||||
recent_trades: list | None = None,
|
||||
last_mid: float | None = None,
|
||||
liq_proxy_low: float | None = None,
|
||||
liq_proxy_high: float | None = None,
|
||||
) -> MicroSnapshot:
|
||||
bids = (ob.get("bids") or [])[:levels]
|
||||
asks = (ob.get("asks") or [])[:levels]
|
||||
if not bids or not asks:
|
||||
return MicroSnapshot()
|
||||
|
||||
best_bid = float(bids[0][0])
|
||||
best_ask = float(asks[0][0])
|
||||
mid = (best_bid + best_ask) / 2.0
|
||||
spread = best_ask - best_bid
|
||||
|
||||
def depth(levels_side, n):
|
||||
return sum(float(x[1]) for x in levels_side[:n])
|
||||
|
||||
bid_depth_1 = depth(bids, 1)
|
||||
ask_depth_1 = depth(asks, 1)
|
||||
bid_depth_5 = depth(bids, 5)
|
||||
ask_depth_5 = depth(asks, 5)
|
||||
bid_depth = depth(bids, levels)
|
||||
ask_depth = depth(asks, levels)
|
||||
tot = bid_depth + ask_depth
|
||||
obi = ((bid_depth - ask_depth) / tot) if tot > 0 else 0.0
|
||||
|
||||
buy_v = sell_v = 0.0
|
||||
if recent_trades:
|
||||
for t in recent_trades:
|
||||
amt = float(t.get("amount") or t.get("qty") or 0.0)
|
||||
side = (t.get("side") or "").lower()
|
||||
if side in ("buy", "b"):
|
||||
buy_v += amt
|
||||
elif side in ("sell", "s"):
|
||||
sell_v += amt
|
||||
delta = buy_v - sell_v
|
||||
timb_den = buy_v + sell_v
|
||||
trade_imbalance = ((buy_v - sell_v) / timb_den) if timb_den > 0 else 0.0
|
||||
|
||||
de = 0.0
|
||||
if last_mid and mid and abs(delta) > 1e-12:
|
||||
de = ((mid - last_mid) / last_mid) / delta
|
||||
|
||||
liq_dist = 0.0
|
||||
if liq_proxy_low and liq_proxy_high and mid:
|
||||
rng = liq_proxy_high - liq_proxy_low
|
||||
if rng > 0:
|
||||
liq_dist = ((mid - liq_proxy_low) / rng) * 2 - 1
|
||||
|
||||
return MicroSnapshot(
|
||||
best_bid=best_bid,
|
||||
best_ask=best_ask,
|
||||
mid=mid,
|
||||
spread=spread,
|
||||
bid_depth_1=bid_depth_1,
|
||||
ask_depth_1=ask_depth_1,
|
||||
bid_depth_5=bid_depth_5,
|
||||
ask_depth_5=ask_depth_5,
|
||||
bid_depth=bid_depth,
|
||||
ask_depth=ask_depth,
|
||||
obi=obi,
|
||||
delta=delta,
|
||||
trade_imbalance=trade_imbalance,
|
||||
delta_efficiency=de,
|
||||
liquidation_distance=liq_dist,
|
||||
)
|
||||
|
||||
def record_book(self, snap: MicroSnapshot, now: float | None = None) -> None:
|
||||
now = now or time.time()
|
||||
self._book_hist.append((now, snap))
|
||||
# trim old
|
||||
cutoff = now - self.book_history_sec
|
||||
while self._book_hist and self._book_hist[0][0] < cutoff:
|
||||
self._book_hist.popleft()
|
||||
|
||||
def book_at(self, target_ts: float) -> Optional[MicroSnapshot]:
|
||||
"""取最接近 target_ts 的历史盘口(用于成交前5s)。"""
|
||||
if not self._book_hist:
|
||||
return None
|
||||
best = min(self._book_hist, key=lambda x: abs(x[0] - target_ts))
|
||||
return best[1]
|
||||
|
||||
def book_deterioration(self, side: str, now: float | None = None, lookback: float = 5.0) -> dict:
|
||||
"""
|
||||
成交前 lookback 秒盘口是否恶化。
|
||||
long: bid_depth 下降 / ask_depth 上升 / mid 下跌 → 恶化
|
||||
"""
|
||||
now = now or time.time()
|
||||
cur = self.book_at(now)
|
||||
past = self.book_at(now - lookback)
|
||||
if not cur or not past or past.mid <= 0:
|
||||
return {"book_ok": False}
|
||||
mid_chg = (cur.mid - past.mid) / past.mid
|
||||
bid5_chg = (cur.bid_depth_5 - past.bid_depth_5) / past.bid_depth_5 if past.bid_depth_5 else 0.0
|
||||
ask5_chg = (cur.ask_depth_5 - past.ask_depth_5) / past.ask_depth_5 if past.ask_depth_5 else 0.0
|
||||
obi_chg = cur.obi - past.obi
|
||||
if side == "long":
|
||||
deteriorated = (mid_chg < -0.00005) or (bid5_chg < -0.15) or (obi_chg < -0.1)
|
||||
else:
|
||||
deteriorated = (mid_chg > 0.00005) or (ask5_chg < -0.15) or (obi_chg > 0.1)
|
||||
return {
|
||||
"book_ok": True,
|
||||
"pre_5s_mid_chg": mid_chg,
|
||||
"pre_5s_bid_depth_5_chg": bid5_chg,
|
||||
"pre_5s_ask_depth_5_chg": ask5_chg,
|
||||
"pre_5s_obi_chg": obi_chg,
|
||||
"pre_5s_deteriorated": bool(deteriorated),
|
||||
"pre_5s_bid_depth_1": past.bid_depth_1,
|
||||
"pre_5s_ask_depth_1": past.ask_depth_1,
|
||||
"pre_5s_bid_depth_5": past.bid_depth_5,
|
||||
"pre_5s_ask_depth_5": past.ask_depth_5,
|
||||
"pre_5s_obi": past.obi,
|
||||
"pre_5s_spread": past.spread,
|
||||
"pre_5s_trade_imbalance": past.trade_imbalance,
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Quote lifecycle
|
||||
# ------------------------------------------------------------------ #
|
||||
def create_quote(
|
||||
self,
|
||||
pair: str,
|
||||
side: str,
|
||||
quote_price: float,
|
||||
inventory: float,
|
||||
snap: MicroSnapshot,
|
||||
reason: str = "",
|
||||
trade_id: Optional[int] = None,
|
||||
state: dict | None = None,
|
||||
) -> str:
|
||||
qid = uuid.uuid4().hex[:16]
|
||||
now = time.time()
|
||||
q = ActiveQuote(
|
||||
quote_id=qid,
|
||||
pair=pair,
|
||||
side=side,
|
||||
quote_price=quote_price,
|
||||
created_ts=now,
|
||||
reason=reason,
|
||||
trade_id=trade_id,
|
||||
status="open",
|
||||
)
|
||||
self._quotes[qid] = q
|
||||
if trade_id is not None:
|
||||
self._quotes_by_trade[trade_id] = qid
|
||||
|
||||
ev = {
|
||||
"event": "quote_created",
|
||||
"quote_id": qid,
|
||||
"pair": pair,
|
||||
"side": side,
|
||||
"quote_price": quote_price,
|
||||
"quote_created_time": _iso(now),
|
||||
"quote_created_epoch": now,
|
||||
"inventory": inventory,
|
||||
"reason": reason,
|
||||
"trade_id": trade_id,
|
||||
"status": "open",
|
||||
"filled": False,
|
||||
}
|
||||
ev.update(snap.to_book_fields())
|
||||
if state:
|
||||
ev.update(state)
|
||||
self.write(ev)
|
||||
return qid
|
||||
|
||||
def cancel_quote(
|
||||
self,
|
||||
quote_id: str | None = None,
|
||||
trade_id: Optional[int] = None,
|
||||
reason: str = "timeout",
|
||||
snap: MicroSnapshot | None = None,
|
||||
) -> None:
|
||||
q = None
|
||||
if quote_id and quote_id in self._quotes:
|
||||
q = self._quotes[quote_id]
|
||||
elif trade_id is not None and trade_id in self._quotes_by_trade:
|
||||
q = self._quotes.get(self._quotes_by_trade[trade_id])
|
||||
if q is None or q.status != "open":
|
||||
return
|
||||
|
||||
now = time.time()
|
||||
q.status = "canceled"
|
||||
ev = {
|
||||
"event": "quote_canceled",
|
||||
"quote_id": q.quote_id,
|
||||
"pair": q.pair,
|
||||
"side": q.side,
|
||||
"quote_price": q.quote_price,
|
||||
"quote_created_time": _iso(q.created_ts),
|
||||
"quote_cancel_time": _iso(now),
|
||||
"quote_cancel_epoch": now,
|
||||
"time_alive_sec": now - q.created_ts,
|
||||
"cancel_reason": reason,
|
||||
"filled": False,
|
||||
"status": "canceled",
|
||||
"trade_id": q.trade_id,
|
||||
}
|
||||
if snap:
|
||||
ev.update(snap.to_book_fields())
|
||||
self.write(ev)
|
||||
|
||||
def bind_trade(self, quote_id: str, trade_id: int) -> None:
|
||||
if quote_id in self._quotes:
|
||||
self._quotes[quote_id].trade_id = trade_id
|
||||
self._quotes_by_trade[trade_id] = quote_id
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Fill + path
|
||||
# ------------------------------------------------------------------ #
|
||||
def log_fill(
|
||||
self,
|
||||
pair: str,
|
||||
side: str,
|
||||
fill_price: float,
|
||||
amount: float,
|
||||
inventory: float,
|
||||
snap: MicroSnapshot,
|
||||
order_type: str = "limit",
|
||||
quote_id: str | None = None,
|
||||
trade_id: Optional[int] = None,
|
||||
fill_reason: str = "maker_hit",
|
||||
state: dict | None = None,
|
||||
extra: dict | None = None,
|
||||
) -> str:
|
||||
now = time.time()
|
||||
fill_id = uuid.uuid4().hex[:16]
|
||||
|
||||
# resolve quote lifecycle
|
||||
q: Optional[ActiveQuote] = None
|
||||
if quote_id and quote_id in self._quotes:
|
||||
q = self._quotes[quote_id]
|
||||
elif trade_id is not None and trade_id in self._quotes_by_trade:
|
||||
q = self._quotes.get(self._quotes_by_trade[trade_id])
|
||||
|
||||
time_to_fill = None
|
||||
quote_created_time = None
|
||||
quote_price = fill_price
|
||||
if q is not None:
|
||||
q.status = "filled"
|
||||
time_to_fill = now - q.created_ts
|
||||
quote_created_time = _iso(q.created_ts)
|
||||
quote_price = q.quote_price
|
||||
quote_id = q.quote_id
|
||||
|
||||
det = self.book_deterioration(side, now=now, lookback=5.0)
|
||||
|
||||
ev = {
|
||||
"event": "fill",
|
||||
"fill_id": fill_id,
|
||||
"quote_id": quote_id,
|
||||
"pair": pair,
|
||||
"side": side,
|
||||
"fill_price": fill_price,
|
||||
"quote_price": quote_price,
|
||||
"amount": amount,
|
||||
"inventory": inventory,
|
||||
"order_type": order_type,
|
||||
"fill_reason": fill_reason,
|
||||
"quote_created_time": quote_created_time,
|
||||
"quote_fill_time": _iso(now),
|
||||
"time_to_fill": time_to_fill,
|
||||
"trade_id": trade_id,
|
||||
"filled": True,
|
||||
}
|
||||
ev.update(snap.to_book_fields())
|
||||
ev.update(det)
|
||||
if state:
|
||||
ev.update(state)
|
||||
if extra:
|
||||
ev.update(extra)
|
||||
self.write(ev)
|
||||
|
||||
# also emit quote_filled lifecycle event
|
||||
if q is not None:
|
||||
self.write(
|
||||
{
|
||||
"event": "quote_filled",
|
||||
"quote_id": q.quote_id,
|
||||
"fill_id": fill_id,
|
||||
"pair": pair,
|
||||
"side": q.side,
|
||||
"quote_price": q.quote_price,
|
||||
"quote_created_time": _iso(q.created_ts),
|
||||
"quote_fill_time": _iso(now),
|
||||
"time_to_fill": time_to_fill,
|
||||
"fill_reason": fill_reason,
|
||||
"filled": True,
|
||||
"status": "filled",
|
||||
"trade_id": trade_id,
|
||||
**snap.to_book_fields(),
|
||||
**det,
|
||||
}
|
||||
)
|
||||
|
||||
self._pending[fill_id] = PendingFillPath(
|
||||
fill_id=fill_id,
|
||||
pair=pair,
|
||||
side=side,
|
||||
fill_price=fill_price,
|
||||
fill_ts=now,
|
||||
quote_id=quote_id,
|
||||
)
|
||||
return fill_id
|
||||
|
||||
def attach_exit_reason(self, fill_id: str, exit_reason: str) -> None:
|
||||
if fill_id in self._pending:
|
||||
self._pending[fill_id].exit_reason = exit_reason
|
||||
# also write lightweight annotation
|
||||
self.write(
|
||||
{
|
||||
"event": "fill_exit",
|
||||
"fill_id": fill_id,
|
||||
"exit_reason": exit_reason,
|
||||
}
|
||||
)
|
||||
|
||||
def update_paths(self, pair: str, last_price: float, now: float | None = None) -> None:
|
||||
now = now or time.time()
|
||||
finished = []
|
||||
for fid, p in self._pending.items():
|
||||
if p.pair != pair or p.done:
|
||||
continue
|
||||
p.min_price = min(p.min_price, last_price)
|
||||
p.max_price = max(p.max_price, last_price)
|
||||
mae, mfe = p.signed_excursions()
|
||||
age = now - p.fill_ts
|
||||
|
||||
def mark(horizon_attr_price, horizon_mae, horizon_mfe, sec, price_val):
|
||||
if getattr(p, horizon_attr_price) is None and age >= sec:
|
||||
setattr(p, horizon_attr_price, price_val)
|
||||
setattr(p, horizon_mae, mae)
|
||||
setattr(p, horizon_mfe, mfe)
|
||||
|
||||
mark("after_10s_price", "mae_10s", "mfe_10s", 10, last_price)
|
||||
mark("after_30s_price", "mae_30s", "mfe_30s", 30, last_price)
|
||||
mark("after_1m_price", "mae_1m", "mfe_1m", 60, last_price)
|
||||
|
||||
if p.after_5m_price is None and age >= 300:
|
||||
p.after_5m_price = last_price
|
||||
p.mae_5m = mae
|
||||
p.mfe_5m = mfe
|
||||
p.done = True
|
||||
# Price MAE absolute
|
||||
if p.side == "long":
|
||||
price_mae = p.min_price - p.fill_price
|
||||
price_mfe = p.max_price - p.fill_price
|
||||
else:
|
||||
price_mae = p.fill_price - p.max_price # negative if adverse up
|
||||
price_mfe = p.fill_price - p.min_price
|
||||
|
||||
self.write(
|
||||
{
|
||||
"event": "fill_path",
|
||||
"fill_id": p.fill_id,
|
||||
"quote_id": p.quote_id,
|
||||
"pair": p.pair,
|
||||
"side": p.side,
|
||||
"fill_price": p.fill_price,
|
||||
"exit_reason": p.exit_reason,
|
||||
"after_10s_price": p.after_10s_price,
|
||||
"after_30s_price": p.after_30s_price,
|
||||
"after_1m_price": p.after_1m_price,
|
||||
"after_5m_price": p.after_5m_price,
|
||||
"min_price": p.min_price,
|
||||
"max_price": p.max_price,
|
||||
# percent
|
||||
"mae_10s": p.mae_10s,
|
||||
"mae_30s": p.mae_30s,
|
||||
"mae_1m": p.mae_1m,
|
||||
"mae_5m": p.mae_5m,
|
||||
"mfe_10s": p.mfe_10s,
|
||||
"mfe_30s": p.mfe_30s,
|
||||
"mfe_1m": p.mfe_1m,
|
||||
"mfe_5m": p.mfe_5m,
|
||||
# absolute price
|
||||
"price_mae": price_mae,
|
||||
"price_mfe": price_mfe,
|
||||
"price_mae_pct": mae,
|
||||
"price_mfe_pct": mfe,
|
||||
}
|
||||
)
|
||||
finished.append(fid)
|
||||
|
||||
for fid in finished:
|
||||
self._pending.pop(fid, None)
|
||||
|
||||
@property
|
||||
def pending_count(self) -> int:
|
||||
return len(self._pending)
|
||||
|
||||
# 兼容旧 API
|
||||
def log_quote(self, *args, **kwargs):
|
||||
"""Deprecated wrapper → create_quote for live quotes; heartbeat uses book only."""
|
||||
return self.create_quote(*args, **kwargs)
|
||||
Reference in New Issue
Block a user