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,434 @@
|
||||
"""
|
||||
MakerEdgeProbe — Freqtrade Dry-run 探针(过渡用)。
|
||||
|
||||
正式 Maker / L2 / Edge 采集已迁移到:
|
||||
nautilus_mm/ (NautilusTrader,独立 .venv)
|
||||
|
||||
本策略仍可用于 Freqtrade 侧对照;新开发请走 nautilus_mm。
|
||||
|
||||
运行 Nautilus:
|
||||
cd nautilus_mm && ./scripts/run_probe.sh
|
||||
|
||||
分析:
|
||||
cd nautilus_mm && ./scripts/analyze.sh
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import time
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Optional
|
||||
|
||||
import numpy as np
|
||||
import talib.abstract as ta
|
||||
from pandas import DataFrame
|
||||
|
||||
from freqtrade.persistence import Trade, Order
|
||||
from freqtrade.strategy import IStrategy
|
||||
|
||||
from maker_edge_logger import MakerEdgeLogger
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class MakerEdgeProbe(IStrategy):
|
||||
INTERFACE_VERSION = 3
|
||||
timeframe = "1m"
|
||||
can_short = True
|
||||
process_only_new_candles = False
|
||||
startup_candle_count = 60
|
||||
|
||||
minimal_roi = {"0": 0.01}
|
||||
stoploss = -0.002
|
||||
trailing_stop = False
|
||||
use_exit_signal = False
|
||||
|
||||
order_types = {
|
||||
"entry": "limit",
|
||||
"exit": "limit",
|
||||
"stoploss": "market",
|
||||
"stoploss_on_exchange": False,
|
||||
}
|
||||
order_time_in_force = {"entry": "GTC", "exit": "GTC"}
|
||||
|
||||
tick_size = 0.1
|
||||
quote_depth_ticks = 1
|
||||
max_leverage = 1.0
|
||||
stake_pct = 0.003
|
||||
max_hold_minutes = 5
|
||||
edge_exit_pct = 0.0002
|
||||
adverse_exit_pct = 0.0008
|
||||
cooldown_minutes = 5
|
||||
ob_levels = 10
|
||||
trade_lookback = 100
|
||||
ema_slope_thr = 0.0002
|
||||
book_sample_every_sec = 2.0
|
||||
|
||||
_logger: MakerEdgeLogger | None = None
|
||||
_last_mid: float | None = None
|
||||
_last_book_sample: float = 0.0
|
||||
_last_entry_time: Optional[datetime] = None
|
||||
_recent_high: float = 0.0
|
||||
_recent_low: float = 0.0
|
||||
_pending_quote_id: Optional[str] = None
|
||||
_fill_by_trade: dict[int, str] = {}
|
||||
|
||||
def bot_start(self, **kwargs) -> None:
|
||||
self._logger = MakerEdgeLogger(levels=self.ob_levels)
|
||||
self._fill_by_trade = {}
|
||||
logger.info("MakerEdgeProbe started. log_dir=%s", self._logger.log_dir)
|
||||
|
||||
def _get_logger(self) -> MakerEdgeLogger:
|
||||
if self._logger is None:
|
||||
self._logger = MakerEdgeLogger(levels=self.ob_levels)
|
||||
return self._logger
|
||||
|
||||
def _fetch_trades(self, pair: str) -> list:
|
||||
try:
|
||||
ex = self.dp._exchange
|
||||
if ex is None:
|
||||
return []
|
||||
api = getattr(ex, "_api", None) or getattr(ex, "api", None)
|
||||
if api is None:
|
||||
return []
|
||||
return api.fetch_trades(pair, limit=self.trade_lookback) or []
|
||||
except Exception as e:
|
||||
logger.debug("fetch_trades failed: %s", e)
|
||||
return []
|
||||
|
||||
def _inventory(self) -> float:
|
||||
try:
|
||||
inv = 0.0
|
||||
for t in Trade.get_open_trades():
|
||||
amt = float(t.amount or 0.0)
|
||||
inv += -amt if t.is_short else amt
|
||||
return inv
|
||||
except Exception:
|
||||
return 0.0
|
||||
|
||||
def _market_state(self, pair: str) -> dict:
|
||||
state = {
|
||||
"trend_state": "UNKNOWN",
|
||||
"atr_pct": None,
|
||||
"volatility_regime": "UNKNOWN",
|
||||
"ema_slope": None,
|
||||
}
|
||||
try:
|
||||
df, _ = self.dp.get_analyzed_dataframe(pair, self.timeframe)
|
||||
if df is None or len(df) == 0:
|
||||
return state
|
||||
last = df.iloc[-1]
|
||||
slope = float(last.get("ema_slope") or 0.0)
|
||||
atr_pct = float(last.get("atr_pct") or 0.0)
|
||||
state["ema_slope"] = slope
|
||||
state["atr_pct"] = atr_pct
|
||||
if bool(last.get("trend_block", False)):
|
||||
state["trend_state"] = "TREND_UP" if slope > 0 else "TREND_DOWN"
|
||||
else:
|
||||
state["trend_state"] = "RANGE"
|
||||
# 波动分位代理
|
||||
if "atr_pct" in df.columns:
|
||||
med = float(df["atr_pct"].tail(60).median() or 0)
|
||||
if atr_pct > med * 1.8:
|
||||
state["volatility_regime"] = "HIGH"
|
||||
elif atr_pct < med * 0.7:
|
||||
state["volatility_regime"] = "LOW"
|
||||
else:
|
||||
state["volatility_regime"] = "NORMAL"
|
||||
except Exception:
|
||||
pass
|
||||
return state
|
||||
|
||||
def _snapshot(self, pair: str):
|
||||
ob = self.dp.orderbook(pair, self.ob_levels)
|
||||
trades = self._fetch_trades(pair)
|
||||
snap = MakerEdgeLogger.snapshot_from_orderbook(
|
||||
ob,
|
||||
levels=self.ob_levels,
|
||||
recent_trades=trades,
|
||||
last_mid=self._last_mid,
|
||||
liq_proxy_low=self._recent_low or None,
|
||||
liq_proxy_high=self._recent_high or None,
|
||||
)
|
||||
if snap.mid:
|
||||
self._last_mid = snap.mid
|
||||
return snap
|
||||
|
||||
def bot_loop_start(self, current_time: datetime, **kwargs) -> None:
|
||||
if self.dp.runmode.value not in ("live", "dry_run"):
|
||||
return
|
||||
pair = self.config["exchange"]["pair_whitelist"][0]
|
||||
try:
|
||||
snap = self._snapshot(pair)
|
||||
tick = self.dp.ticker(pair) or {}
|
||||
last = float(tick.get("last") or tick.get("close") or 0.0) or snap.mid
|
||||
|
||||
df, _ = self.dp.get_analyzed_dataframe(pair, self.timeframe)
|
||||
if df is not None and len(df):
|
||||
self._recent_high = float(df.iloc[-1].get("roll_high") or self._recent_high or last)
|
||||
self._recent_low = float(df.iloc[-1].get("roll_low") or self._recent_low or last)
|
||||
|
||||
lg = self._get_logger()
|
||||
now = time.time()
|
||||
# 盘口历史(成交前5s恶化检测依赖此)
|
||||
if now - self._last_book_sample >= self.book_sample_every_sec:
|
||||
self._last_book_sample = now
|
||||
lg.record_book(snap, now=now)
|
||||
|
||||
if last:
|
||||
lg.update_paths(pair, last, now=now)
|
||||
except Exception as e:
|
||||
logger.warning("bot_loop_start probe error: %s", e)
|
||||
|
||||
def populate_indicators(self, dataframe: DataFrame, metadata: dict) -> DataFrame:
|
||||
df = dataframe
|
||||
close, high, low = df["close"], df["high"], df["low"]
|
||||
volume = df["volume"].astype(float)
|
||||
|
||||
close_c = close.clip(lower=low, upper=high)
|
||||
hl = (high - low).replace(0, np.nan)
|
||||
buy_frac = ((close_c - low) / hl).fillna(0.5).clip(0, 1)
|
||||
sell_vol = volume * (1.0 - buy_frac)
|
||||
buy_vol = volume * buy_frac
|
||||
df["sell_vol"] = sell_vol
|
||||
df["buy_vol"] = buy_vol
|
||||
df["delta"] = buy_vol - sell_vol
|
||||
|
||||
vol_ma = volume.rolling(20, min_periods=5).mean()
|
||||
df["shock_sell"] = (sell_vol > vol_ma * 3) & (df["delta"] < 0)
|
||||
df["shock_buy"] = (buy_vol > vol_ma * 3) & (df["delta"] > 0)
|
||||
|
||||
drop = (close.shift(3) - low).clip(lower=0) / close.shift(3)
|
||||
up = (high - close.shift(3)).clip(lower=0) / close.shift(3)
|
||||
df["de_sell"] = (sell_vol.rolling(3).sum() / (drop.replace(0, np.nan) * 1e4)).replace(
|
||||
[np.inf, -np.inf], np.nan
|
||||
).fillna(0)
|
||||
df["de_buy"] = (buy_vol.rolling(3).sum() / (up.replace(0, np.nan) * 1e4)).replace(
|
||||
[np.inf, -np.inf], np.nan
|
||||
).fillna(0)
|
||||
|
||||
df["ema26"] = ta.EMA(df, timeperiod=26)
|
||||
df["ema_slope"] = ((df["ema26"] - df["ema26"].shift(5)) / close).fillna(0)
|
||||
df["trend_block"] = df["ema_slope"].abs() > self.ema_slope_thr
|
||||
df["atr"] = ta.ATR(df, timeperiod=20)
|
||||
df["atr_pct"] = (df["atr"] / close).fillna(0)
|
||||
|
||||
s_ma, s_ref = sell_vol.rolling(3).mean(), sell_vol.rolling(8).mean()
|
||||
df["sell_exhaust"] = (s_ma < s_ref * 0.75) & (low >= low.rolling(8).min().shift(1))
|
||||
b_ma, b_ref = buy_vol.rolling(3).mean(), buy_vol.rolling(8).mean()
|
||||
df["buy_exhaust"] = (b_ma < b_ref * 0.75) & (high <= high.rolling(8).max().shift(1))
|
||||
|
||||
df["roll_high"] = high.rolling(60, min_periods=10).max()
|
||||
df["roll_low"] = low.rolling(60, min_periods=10).min()
|
||||
return df
|
||||
|
||||
def populate_entry_trend(self, dataframe: DataFrame, metadata: dict) -> DataFrame:
|
||||
df = dataframe
|
||||
long_c = (
|
||||
(~df["trend_block"])
|
||||
& df["shock_sell"].rolling(5).max().astype(bool)
|
||||
& (df["de_sell"] > 10)
|
||||
& df["sell_exhaust"]
|
||||
)
|
||||
short_c = (
|
||||
(~df["trend_block"])
|
||||
& df["shock_buy"].rolling(5).max().astype(bool)
|
||||
& (df["de_buy"] > 10)
|
||||
& df["buy_exhaust"]
|
||||
)
|
||||
df.loc[long_c, ["enter_long", "enter_tag"]] = (1, "probe_bid_lp")
|
||||
df.loc[short_c, ["enter_short", "enter_tag"]] = (1, "probe_ask_lp")
|
||||
return df
|
||||
|
||||
def populate_exit_trend(self, dataframe: DataFrame, metadata: dict) -> DataFrame:
|
||||
dataframe["exit_long"] = 0
|
||||
dataframe["exit_short"] = 0
|
||||
return dataframe
|
||||
|
||||
def custom_entry_price(
|
||||
self,
|
||||
pair: str,
|
||||
trade: Trade | None,
|
||||
current_time: datetime,
|
||||
proposed_rate: float,
|
||||
entry_tag: str | None,
|
||||
side: str,
|
||||
**kwargs,
|
||||
) -> float:
|
||||
offset = self.quote_depth_ticks * self.tick_size
|
||||
try:
|
||||
snap = self._snapshot(pair)
|
||||
price = snap.best_bid - offset if side == "long" else snap.best_ask + offset
|
||||
state = self._market_state(pair)
|
||||
qid = self._get_logger().create_quote(
|
||||
pair=pair,
|
||||
side="bid" if side == "long" else "ask",
|
||||
quote_price=price,
|
||||
inventory=self._inventory(),
|
||||
snap=snap,
|
||||
reason=entry_tag or "entry",
|
||||
trade_id=trade.id if trade else None,
|
||||
state=state,
|
||||
)
|
||||
self._pending_quote_id = qid
|
||||
return price
|
||||
except Exception as e:
|
||||
logger.debug("custom_entry_price: %s", e)
|
||||
return proposed_rate - offset if side == "long" else proposed_rate + offset
|
||||
|
||||
def confirm_trade_entry(
|
||||
self,
|
||||
pair: str,
|
||||
order_type: str,
|
||||
amount: float,
|
||||
rate: float,
|
||||
time_in_force: str,
|
||||
current_time: datetime,
|
||||
entry_tag: str | None,
|
||||
side: str,
|
||||
**kwargs,
|
||||
) -> bool:
|
||||
if self._last_entry_time:
|
||||
last = self._last_entry_time
|
||||
if last.tzinfo is None:
|
||||
last = last.replace(tzinfo=timezone.utc)
|
||||
now = current_time if current_time.tzinfo else current_time.replace(tzinfo=timezone.utc)
|
||||
if now - last < timedelta(minutes=self.cooldown_minutes):
|
||||
return False
|
||||
try:
|
||||
df, _ = self.dp.get_analyzed_dataframe(pair, self.timeframe)
|
||||
if df is not None and len(df) and bool(df.iloc[-1].get("trend_block", False)):
|
||||
return False
|
||||
snap = self._snapshot(pair)
|
||||
if side == "long" and snap.bid_depth_1 < snap.ask_depth_1 * 0.7:
|
||||
return False
|
||||
if side == "short" and snap.ask_depth_1 < snap.bid_depth_1 * 0.7:
|
||||
return False
|
||||
except Exception:
|
||||
pass
|
||||
self._last_entry_time = current_time
|
||||
return True
|
||||
|
||||
def check_entry_timeout(
|
||||
self, pair: str, trade: Trade, order: Order, current_time: datetime, **kwargs
|
||||
) -> bool:
|
||||
"""超时撤单 → 记录 quote_cancel(坏时间未成交 vs 被动成交的对照)。"""
|
||||
try:
|
||||
snap = self._snapshot(pair)
|
||||
self._get_logger().cancel_quote(
|
||||
quote_id=self._pending_quote_id,
|
||||
trade_id=trade.id,
|
||||
reason="entry_timeout",
|
||||
snap=snap,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.debug("cancel_quote on timeout: %s", e)
|
||||
# False = 不额外强制取消;交给 unfilledtimeout 配置。若要立刻取消返回 True
|
||||
return False
|
||||
|
||||
def order_filled(
|
||||
self,
|
||||
pair: str,
|
||||
trade: Trade,
|
||||
order: Order,
|
||||
current_time: datetime,
|
||||
**kwargs,
|
||||
) -> None:
|
||||
try:
|
||||
lg = self._get_logger()
|
||||
# 入场成交
|
||||
if order.ft_order_side == trade.entry_side:
|
||||
snap = self._snapshot(pair)
|
||||
side = "short" if trade.is_short else "long"
|
||||
# 粗分 fill_reason:time_to_fill 在 logger 内算;这里标 maker_hit
|
||||
# 若成交前5s盘口已恶化 → toxic_passive 候选
|
||||
det = lg.book_deterioration(side)
|
||||
fill_reason = "toxic_passive" if det.get("pre_5s_deteriorated") else "maker_hit"
|
||||
if self._pending_quote_id:
|
||||
lg.bind_trade(self._pending_quote_id, trade.id)
|
||||
fill_id = lg.log_fill(
|
||||
pair=pair,
|
||||
side=side,
|
||||
fill_price=float(order.safe_price or trade.open_rate),
|
||||
amount=float(order.safe_filled or order.safe_amount or 0),
|
||||
inventory=self._inventory(),
|
||||
snap=snap,
|
||||
order_type=str(getattr(order, "order_type", None) or "limit"),
|
||||
quote_id=self._pending_quote_id,
|
||||
trade_id=trade.id,
|
||||
fill_reason=fill_reason,
|
||||
state=self._market_state(pair),
|
||||
extra={"entry_tag": trade.enter_tag},
|
||||
)
|
||||
self._fill_by_trade[trade.id] = fill_id
|
||||
self._pending_quote_id = None
|
||||
else:
|
||||
# 出场:把 exit_reason 挂到入场 fill,供 H2
|
||||
fill_id = self._fill_by_trade.get(trade.id)
|
||||
reason = trade.exit_reason or getattr(order, "ft_order_tag", None) or "exit"
|
||||
if fill_id:
|
||||
lg.attach_exit_reason(fill_id, str(reason))
|
||||
except Exception as e:
|
||||
logger.warning("order_filled log error: %s", e)
|
||||
|
||||
def custom_exit(
|
||||
self,
|
||||
pair: str,
|
||||
trade: Trade,
|
||||
current_time: datetime,
|
||||
current_rate: float,
|
||||
current_profit: float,
|
||||
**kwargs,
|
||||
):
|
||||
open_time = trade.open_date_utc
|
||||
if open_time.tzinfo is None:
|
||||
open_time = open_time.replace(tzinfo=timezone.utc)
|
||||
now = current_time if current_time.tzinfo else current_time.replace(tzinfo=timezone.utc)
|
||||
if now - open_time >= timedelta(minutes=self.max_hold_minutes):
|
||||
return "probe_time"
|
||||
entry = trade.open_rate
|
||||
edge = (
|
||||
(current_rate - entry) / entry
|
||||
if not trade.is_short
|
||||
else (entry - current_rate) / entry
|
||||
)
|
||||
if edge >= self.edge_exit_pct:
|
||||
return "probe_edge_restore"
|
||||
if edge <= -self.adverse_exit_pct:
|
||||
return "probe_adverse"
|
||||
# 趋势切换 → 撤流动性思维
|
||||
try:
|
||||
st = self._market_state(pair)
|
||||
if st.get("trend_state") in ("TREND_UP", "TREND_DOWN"):
|
||||
# 持仓方向与趋势相反时更危险
|
||||
if (not trade.is_short and st["trend_state"] == "TREND_DOWN") or (
|
||||
trade.is_short and st["trend_state"] == "TREND_UP"
|
||||
):
|
||||
return "probe_trend_cancel"
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
def leverage(
|
||||
self, pair: str, current_time: datetime, current_rate: float,
|
||||
proposed_leverage: float, max_leverage: float, entry_tag: Optional[str],
|
||||
side: str, **kwargs,
|
||||
) -> float:
|
||||
return min(self.max_leverage, float(max_leverage))
|
||||
|
||||
def custom_stake_amount(
|
||||
self, pair: str, current_time: datetime, current_rate: float,
|
||||
proposed_stake: float, min_stake: Optional[float], max_stake: float,
|
||||
leverage: float, entry_tag: Optional[str], side: str, **kwargs,
|
||||
) -> float:
|
||||
try:
|
||||
if self.wallets:
|
||||
free = self.wallets.get_free(self.config["stake_currency"])
|
||||
stake = free * self.stake_pct
|
||||
if min_stake:
|
||||
stake = max(stake, min_stake)
|
||||
return min(stake, max_stake)
|
||||
except Exception:
|
||||
pass
|
||||
return min(proposed_stake * self.stake_pct, max_stake)
|
||||
Reference in New Issue
Block a user