diff --git a/hub_kline_store.py b/hub_kline_store.py index 7b4ba8b..5dde267 100644 --- a/hub_kline_store.py +++ b/hub_kline_store.py @@ -15,6 +15,8 @@ from hub_ohlcv_lib import ( format_price_by_tick, last_closed_bar_open_ms, normalize_chart_timeframe, + normalize_price_tick, + round_ohlcv_bars_to_tick, window_start_ms, ) @@ -73,6 +75,72 @@ def init_db(db_path: Path | None = None) -> None: ON ohlcv_bars (exchange_key, symbol, timeframe, open_time_ms) """ ) + conn.execute( + """ + CREATE TABLE IF NOT EXISTS ohlcv_symbol_meta ( + exchange_key TEXT NOT NULL, + symbol TEXT NOT NULL, + price_tick REAL, + updated_at INTEGER NOT NULL, + PRIMARY KEY (exchange_key, symbol) + ) + """ + ) + finally: + conn.close() + + +def save_symbol_price_tick( + exchange_key: str, + symbol: str, + price_tick: float | None, + db_path: Path | None = None, +) -> None: + tick = price_tick + if tick is None: + return + try: + t = float(tick) + except (TypeError, ValueError): + return + if t <= 0: + return + ex_k = (exchange_key or "").strip().lower() + sym = (symbol or "").strip().upper() + conn = _connect(db_path) + try: + conn.execute( + """ + INSERT INTO ohlcv_symbol_meta (exchange_key, symbol, price_tick, updated_at) + VALUES (?,?,?,?) + ON CONFLICT(exchange_key, symbol) DO UPDATE SET + price_tick=excluded.price_tick, + updated_at=excluded.updated_at + """, + (ex_k, sym, t, int(time.time())), + ) + finally: + conn.close() + + +def load_symbol_price_tick( + exchange_key: str, + symbol: str, + db_path: Path | None = None, +) -> float | None: + ex_k = (exchange_key or "").strip().lower() + sym = (symbol or "").strip().upper() + conn = _connect(db_path) + try: + row = conn.execute( + "SELECT price_tick FROM ohlcv_symbol_meta WHERE exchange_key=? AND symbol=?", + (ex_k, sym), + ).fetchone() + if not row or row["price_tick"] is None: + return None + return float(row["price_tick"]) + except (TypeError, ValueError): + return None finally: conn.close() @@ -273,6 +341,8 @@ def resolve_chart_bars( if remote.get("ok") and remote.get("bars"): fetched = upsert_bars(ex_k, sym, tf, remote["bars"], db_path) price_tick = remote.get("price_tick") + if price_tick is not None: + save_symbol_price_tick(ex_k, sym, price_tick, db_path) db_rows = load_bars_range(ex_k, sym, tf, fetch_start_ms, now_ms, db_path) else: remote_err = remote.get("msg") or remote.get("error") or "实例拉取 K 线失败" @@ -282,6 +352,8 @@ def resolve_chart_bars( if len(db_rows) > need: db_rows = db_rows[-need:] + if price_tick is None: + price_tick = load_symbol_price_tick(ex_k, sym, db_path) if price_tick is None: try: tick_probe = remote_fetch( @@ -292,9 +364,15 @@ def resolve_chart_bars( ) if tick_probe.get("ok"): price_tick = tick_probe.get("price_tick") + if price_tick is not None: + save_symbol_price_tick(ex_k, sym, price_tick, db_path) except Exception: pass + price_tick = normalize_price_tick(price_tick) + if db_rows and price_tick is not None: + round_ohlcv_bars_to_tick(db_rows, price_tick) + candles = _to_chart_candles(db_rows) if not candles: return {"ok": False, "msg": remote_err or "无 K 线数据", "purged": purged} diff --git a/hub_ohlcv_lib.py b/hub_ohlcv_lib.py index 7a4b756..a282745 100644 --- a/hub_ohlcv_lib.py +++ b/hub_ohlcv_lib.py @@ -85,6 +85,75 @@ def chart_fetch_start_ms(timeframe: str, need: int, now_ms: int | None = None) - return max(0, now - max(1, int(need)) * period) +def _positive_float(value: Any) -> Optional[float]: + if value in (None, ""): + return None + try: + v = float(value) + except (TypeError, ValueError): + return None + return v if v > 0 else None + + +def _price_tick_from_market_info(info: dict) -> Optional[float]: + """从 market.info 解析 tick(含币安 PRICE_FILTER.filters)。""" + for key in ("tickSize", "tickSz", "price_increment", "order_price_round", "quote_increment"): + v = _positive_float(info.get(key)) + if v is not None: + return v + + for key in ("pricePrecision", "price_precision"): + raw = info.get(key) + if raw in (None, ""): + continue + try: + p = float(raw) + except (TypeError, ValueError): + continue + if p >= 1 and abs(p - round(p)) < 1e-9 and p <= 12: + return 10 ** (-int(p)) + if 0 < p < 1: + return p + + filters = info.get("filters") + if isinstance(filters, list): + for f in filters: + if not isinstance(f, dict): + continue + if str(f.get("filterType") or "").upper() != "PRICE_FILTER": + continue + v = _positive_float(f.get("tickSize")) + if v is not None: + return v + return None + + +def round_price_to_tick(value: Any, tick: Optional[float]) -> Optional[float]: + """按交易所 tick 对齐价格(K 线/标记线与坐标轴一致)。""" + t = normalize_price_tick(tick) + if t is None: + return None + try: + v = float(value) + except (TypeError, ValueError): + return None + n = round(v / t) * t + d = _decimals_from_tick(t) + return float(f"{n:.{d}f}") + + +def round_ohlcv_bars_to_tick(bars: list[dict[str, Any]], tick: Optional[float]) -> None: + t = normalize_price_tick(tick) + if t is None: + return + for b in bars: + for key in ("open", "high", "low", "close"): + if key in b: + rounded = round_price_to_tick(b.get(key), t) + if rounded is not None: + b[key] = rounded + + def price_tick_from_market(exchange, exchange_symbol: str) -> Optional[float]: """最小价格变动单位(与交易所 tick / price_to_precision 一致)。""" try: @@ -96,14 +165,9 @@ def price_tick_from_market(exchange, exchange_symbol: str) -> Optional[float]: info = market.get("info") or {} if isinstance(info, dict): - for key in ("tickSize", "tickSz", "price_increment", "order_price_round", "quote_increment"): - if info.get(key) not in (None, ""): - try: - v = float(info[key]) - if v > 0: - return v - except (TypeError, ValueError): - pass + tick = _price_tick_from_market_info(info) + if tick is not None: + return tick limits = market.get("limits") or {} price_limits = limits.get("price") or {} @@ -434,6 +498,7 @@ def fetch_ohlcv_for_hub( return {"ok": False, "msg": "交易所未返回 K 线"} tick = normalize_price_tick(price_tick_from_market(exchange, ex_sym)) + round_ohlcv_bars_to_tick(merged, tick) return { "ok": True, diff --git a/manual_trading_hub/agent.py b/manual_trading_hub/agent.py index 353ab6e..a00655e 100644 --- a/manual_trading_hub/agent.py +++ b/manual_trading_hub/agent.py @@ -416,9 +416,43 @@ def _position_mark_price(p: dict[str, Any]) -> float | None: for key in ( p.get("markPrice"), p.get("mark_price"), + p.get("mark"), info.get("markPx"), info.get("mark_price"), info.get("markPrice"), + info.get("last"), + info.get("lastPrice"), + ): + px = _finite_or_none(key) + if px is not None and px > 0: + return px + contracts = _position_contracts(p) + if abs(contracts) >= 1e-12: + notional = _finite_or_none(p.get("notional")) + if notional is not None and abs(notional) > 0: + return abs(notional) / abs(contracts) + return None + + +def _ticker_mark_price(ex: Any, symbol: str) -> float | None: + """持仓行无 mark 时,用 ticker 补标记价(last/mark)。""" + sym = (symbol or "").strip() + if not sym: + return None + try: + t = ex.fetch_ticker(sym) + except Exception: + return None + if not isinstance(t, dict): + return None + info = t.get("info") if isinstance(t.get("info"), dict) else {} + for key in ( + t.get("mark"), + t.get("last"), + t.get("close"), + info.get("markPrice"), + info.get("mark_price"), + info.get("markPx"), ): px = _finite_or_none(key) if px is not None and px > 0: @@ -602,6 +636,8 @@ def _status_inner(x_control_token: str | None) -> Any: entry_f = _position_entry_price(p) _, entry_fmt, price_tick = _position_price_fmt(ex, sym, entry_f) mark_f = _position_mark_price(p) + if mark_f is None and sym: + mark_f = _ticker_mark_price(ex, sym) _, mark_fmt, mark_tick = _position_price_fmt(ex, sym, mark_f) if price_tick is None and mark_tick is not None: price_tick = mark_tick diff --git a/manual_trading_hub/static/chart.js b/manual_trading_hub/static/chart.js index 78f5221..209c85c 100644 --- a/manual_trading_hub/static/chart.js +++ b/manual_trading_hub/static/chart.js @@ -949,9 +949,11 @@ } specs.forEach(function (s) { if (s.price == null || !Number.isFinite(Number(s.price))) return; + const px = roundToTick(s.price); + if (px == null || !Number.isFinite(Number(px))) return; positionLines.push( candleSeries.createPriceLine({ - price: Number(s.price), + price: Number(px), color: s.color, lineWidth: 1, lineStyle: 2, @@ -1009,30 +1011,52 @@ return Math.max(0, Math.min(12, Math.round(-Math.log10(minMove)))); } - const SAFE_PRICE_FORMAT = { type: "price", precision: 4, minMove: 0.0001, base: 10000 }; + const SAFE_PRICE_FORMAT = { type: "price", precision: 4, minMove: 0.0001 }; function tickToPriceFormat(tick) { try { if (tick == null || !Number.isFinite(Number(tick)) || Number(tick) <= 0) { - return { type: "price", precision: 2, minMove: 0.01, base: 100 }; + return { type: "price", precision: 2, minMove: 0.01 }; } - const raw = Number(tick); - if (raw >= 1) { - return { type: "price", precision: 0, minMove: 1 }; - } - let prec = decimalsFromTick(raw); + const minMove = Number(tick); + let prec = decimalsFromTick(minMove); if (prec == null || prec < 0) prec = 4; - prec = Math.min(8, Math.max(0, Math.floor(prec))); - const base = Math.pow(10, prec); - if (!Number.isFinite(base) || base < 1 || base > 1e12) { - return SAFE_PRICE_FORMAT; - } - return { type: "price", precision: prec, minMove: 1 / base, base: base }; + prec = Math.min(12, Math.max(0, Math.floor(prec))); + return { type: "price", precision: prec, minMove: minMove }; } catch (e) { return SAFE_PRICE_FORMAT; } } + function roundToTick(v) { + if (v == null || Number.isNaN(Number(v))) return v; + const n = Number(v); + const tick = priceTick; + if (tick == null || !Number.isFinite(Number(tick)) || Number(tick) <= 0) return n; + const t = Number(tick); + const rounded = Math.round(n / t) * t; + const dec = decimalsFromTick(t); + if (dec == null) return rounded; + return parseFloat(rounded.toFixed(dec)); + } + + function alignCandlesToTick(candles) { + if (!Array.isArray(candles) || !candles.length) return candles || []; + if (priceTick == null || !Number.isFinite(Number(priceTick)) || Number(priceTick) <= 0) { + return candles; + } + return candles.map(function (c) { + return { + time: c.time, + open: roundToTick(c.open), + high: roundToTick(c.high), + low: roundToTick(c.low), + close: roundToTick(c.close), + volume: c.volume, + }; + }); + } + function applyPriceFormatToSeries(series, pf) { if (!series || !series.applyOptions) return; try { @@ -1065,7 +1089,8 @@ function fmtPrice(v) { if (v == null || Number.isNaN(Number(v))) return "-"; - const n = Number(v); + const aligned = roundToTick(v); + const n = Number(aligned); if (n === 0) return "0"; const dec = decimalsFromTick(priceTick); if (dec != null) return n.toFixed(dec); @@ -1172,7 +1197,7 @@ if (!bar || bar.close == null) return; const up = Number(bar.close) >= Number(bar.open); currentPriceLine = candleSeries.createPriceLine({ - price: Number(bar.close), + price: Number(roundToTick(bar.close)), color: up ? "#00ff9d" : "#ff4d6d", lineWidth: 1, lineStyle: 2, @@ -1416,7 +1441,7 @@ rangeMarkers.push( candleSeries.createPriceLine({ - price: Number(hi.high), + price: Number(roundToTick(hi.high)), color: "#ffb84d", lineWidth: 1, lineStyle: 2, @@ -1426,7 +1451,7 @@ ); rangeMarkers.push( candleSeries.createPriceLine({ - price: Number(lo.low), + price: Number(roundToTick(lo.low)), color: "#4cd97f", lineWidth: 1, lineStyle: 2, @@ -1535,7 +1560,7 @@ priceTick = null; applyChartPriceFormat(); } - lastCandles = data.candles; + lastCandles = alignCandlesToTick(data.candles); indexCandles(lastCandles); candleSeries.setData(lastCandles); volumeSeries.setData(buildVolumeData(lastCandles)); diff --git a/manual_trading_hub/static/index.html b/manual_trading_hub/static/index.html index 51429b7..88cd056 100644 --- a/manual_trading_hub/static/index.html +++ b/manual_trading_hub/static/index.html @@ -244,7 +244,7 @@
- - + +