diff --git a/onchain_scout_gate/app/btc_regime.py b/onchain_scout_gate/app/btc_regime.py index ecc95fc..21384b3 100644 --- a/onchain_scout_gate/app/btc_regime.py +++ b/onchain_scout_gate/app/btc_regime.py @@ -3,6 +3,8 @@ from __future__ import annotations from dataclasses import dataclass, field from statistics import mean +from .candle_rows import rows_to_hlc + @dataclass class BtcDailyGateResult: @@ -14,18 +16,6 @@ class BtcDailyGateResult: metrics: dict = field(default_factory=dict) -def _rows_to_hlc(rows: list[list[str]]) -> tuple[list[float], list[float], list[float]]: - """与行情 K 线行对齐:h, l, c(ts,o,h,l,c,...)。""" - h, l_, c = [], [], [] - for item in rows: - if len(item) < 6: - continue - h.append(float(item[2])) - l_.append(float(item[3])) - c.append(float(item[4])) - return h, l_, c - - def evaluate_btc_daily_gate( btc_1d_rows: list[list[str]], *, @@ -39,7 +29,7 @@ def evaluate_btc_daily_gate( - 下跌(唯一不扫):非横盘,且收盘低于近 20 日收盘均线,且该均线相对前一段走低。 - 其余(横盘、上涨、宽幅震荡、数据不足 unknown 等):一律允许扫山寨。 """ - ah, al, ac = _rows_to_hlc(btc_1d_rows) + ah, al, ac = rows_to_hlc(btc_1d_rows) if len(ac) < min_bars: return BtcDailyGateResult( diff --git a/onchain_scout_gate/app/candle_rows.py b/onchain_scout_gate/app/candle_rows.py new file mode 100644 index 0000000..1f017a9 --- /dev/null +++ b/onchain_scout_gate/app/candle_rows.py @@ -0,0 +1,91 @@ +from __future__ import annotations + + +def field_float(item: list[str], idx: int) -> float | None: + """Parse one K-line field; empty or invalid strings return None.""" + if idx >= len(item): + return None + raw = str(item[idx]).strip() + if not raw: + return None + try: + return float(raw) + except (TypeError, ValueError): + return None + + +def rows_to_ohlcv( + rows: list[list[str]], + *, + require_volume: bool = True, +) -> tuple[list[float], list[float], list[float], list[float], list[float]]: + """ + Gate candle rows: [ts_ms, o, h, l, c, v, ...]. + Skips rows with missing/invalid OHLC; skips rows with missing volume when require_volume=True. + """ + o, h, l, c, v = [], [], [], [], [] + for item in rows: + if len(item) < 5: + continue + o_val = field_float(item, 1) + h_val = field_float(item, 2) + l_val = field_float(item, 3) + c_val = field_float(item, 4) + if o_val is None or h_val is None or l_val is None or c_val is None: + continue + vol_val = field_float(item, 5) if len(item) > 5 else None + if require_volume and vol_val is None: + continue + o.append(o_val) + h.append(h_val) + l.append(l_val) + c.append(c_val) + v.append(vol_val if vol_val is not None else 0.0) + return o, h, l, c, v + + +def rows_to_hlc(rows: list[list[str]]) -> tuple[list[float], list[float], list[float]]: + """High / low / close series aligned to valid OHLC rows.""" + h, l_, c = [], [], [] + for item in rows: + if len(item) < 5: + continue + h_val = field_float(item, 2) + l_val = field_float(item, 3) + c_val = field_float(item, 4) + if h_val is None or l_val is None or c_val is None: + continue + h.append(h_val) + l_.append(l_val) + c.append(c_val) + return h, l_, c + + +def rows_to_ohl(rows: list[list[str]]) -> tuple[list[float], list[float], list[float], list[float]]: + """Open / high / low / close for chart rendering.""" + o, h, l, c = [], [], [], [] + for item in rows: + if len(item) < 5: + continue + o_val = field_float(item, 1) + h_val = field_float(item, 2) + l_val = field_float(item, 3) + c_val = field_float(item, 4) + if o_val is None or h_val is None or l_val is None or c_val is None: + continue + o.append(o_val) + h.append(h_val) + l.append(l_val) + c.append(c_val) + return o, h, l, c + + +def rows_to_close(rows: list[list[str]]) -> list[float]: + out: list[float] = [] + for item in rows: + if len(item) < 5: + continue + c_val = field_float(item, 4) + if c_val is not None: + out.append(c_val) + return out diff --git a/onchain_scout_gate/app/chart_candles.py b/onchain_scout_gate/app/chart_candles.py index b57bfd4..a8a1a4a 100644 --- a/onchain_scout_gate/app/chart_candles.py +++ b/onchain_scout_gate/app/chart_candles.py @@ -4,6 +4,8 @@ import base64 import io import logging +from .candle_rows import rows_to_ohl + LOGGER = logging.getLogger("onchain_scout.chart_candles") @@ -22,14 +24,7 @@ def daily_candles_png_base64(rows_1d: list[list[str]], symbol: str, max_bars: in LOGGER.warning("matplotlib not installed, skip chart image") return None - o, h, l, c, _ = [], [], [], [], [] - for item in rows_1d: - if len(item) < 6: - continue - o.append(float(item[1])) - h.append(float(item[2])) - l.append(float(item[3])) - c.append(float(item[4])) + o, h, l, c = rows_to_ohl(rows_1d) n = len(c) if n < 5: return None diff --git a/onchain_scout_gate/app/daily_features.py b/onchain_scout_gate/app/daily_features.py index 8bd3156..550eeb1 100644 --- a/onchain_scout_gate/app/daily_features.py +++ b/onchain_scout_gate/app/daily_features.py @@ -3,18 +3,7 @@ from __future__ import annotations import math from statistics import mean - -def rows_to_ohlcv(rows: list[list[str]]) -> tuple[list[float], list[float], list[float], list[float], list[float]]: - o, h, l, c, v = [], [], [], [], [] - for item in rows: - if len(item) < 6: - continue - o.append(float(item[1])) - h.append(float(item[2])) - l.append(float(item[3])) - c.append(float(item[4])) - v.append(float(item[5])) - return o, h, l, c, v +from .candle_rows import rows_to_ohlcv def build_daily_programmatic(rows_1d: list[list[str]], est_quote_vol_24h_usdt: float) -> dict: diff --git a/onchain_scout_gate/app/daily_report.py b/onchain_scout_gate/app/daily_report.py index 63b6357..5ba249b 100644 --- a/onchain_scout_gate/app/daily_report.py +++ b/onchain_scout_gate/app/daily_report.py @@ -7,6 +7,7 @@ from statistics import mean from typing import TYPE_CHECKING from zoneinfo import ZoneInfo +from .candle_rows import rows_to_close from .config import Settings from .notifier import WeComNotifier from .gate import GateClient @@ -20,15 +21,6 @@ CN_TZ = ZoneInfo("Asia/Shanghai") BTC_INST = "BTC_USDT" -def _rows_to_close(rows: list[list[str]]) -> list[float]: - out: list[float] = [] - for r in rows: - if len(r) < 5: - continue - out.append(float(r[4])) - return out - - def _sma(values: list[float], n: int) -> float: if not values: return 0.0 @@ -118,7 +110,7 @@ class DailyReportService: funnel_push_count += 1 btc_rows = await self.gate.get_candles(BTC_INST, "1D", limit=100) - closes = _rows_to_close(btc_rows) + closes = rows_to_close(btc_rows) last_close = closes[-1] if closes else 0.0 prev_close = closes[-2] if len(closes) >= 2 else last_close day_change_pct = ((last_close - prev_close) / prev_close * 100.0) if prev_close else 0.0 diff --git a/onchain_scout_gate/app/exchange_rules.py b/onchain_scout_gate/app/exchange_rules.py index e06c1d5..180adab 100644 --- a/onchain_scout_gate/app/exchange_rules.py +++ b/onchain_scout_gate/app/exchange_rules.py @@ -3,6 +3,8 @@ from __future__ import annotations from dataclasses import dataclass, field from statistics import mean +from .candle_rows import rows_to_ohlcv + # 以下换算仅针对 5m K(与是否单独拉 4h 图无关): # 每小时 60/5 = 12 根;一根「4 小时」大周期对应 4×12 = 48 根 5m。 BARS_5M_PER_HOUR = 12 @@ -29,19 +31,6 @@ class ExchangeRuleResult: metrics: dict = field(default_factory=dict) -def _rows_to_ohlcv(rows: list[list[str]]) -> tuple[list[float], list[float], list[float], list[float], list[float]]: - o, h, l, c, v = [], [], [], [], [] - for item in rows: - if len(item) < 6: - continue - o.append(float(item[1])) - h.append(float(item[2])) - l.append(float(item[3])) - c.append(float(item[4])) - v.append(float(item[5])) - return o, h, l, c, v - - def evaluate_exchange( symbol: str, alt_rows: list[list[str]], @@ -56,7 +45,7 @@ def evaluate_exchange( """ breakout_max_pct = 0.5 result = ExchangeRuleResult() - _, ah, al, ac, av = _rows_to_ohlcv(alt_rows) + _, ah, al, ac, av = rows_to_ohlcv(alt_rows) bars_for_range = max( MIN_BOX_LOOKBACK_BARS_5M, diff --git a/onchain_scout_gate/app/monitor.py b/onchain_scout_gate/app/monitor.py index 4a79f8a..37bf5be 100644 --- a/onchain_scout_gate/app/monitor.py +++ b/onchain_scout_gate/app/monitor.py @@ -460,7 +460,12 @@ class MonitorService: await self.storage.add_log("WARN", f"{sym} candles_failed: {exc}") continue - result = evaluate_exchange(sym, alt_rows, btc_rows, rule_params) + try: + result = evaluate_exchange(sym, alt_rows, btc_rows, rule_params) + except Exception as exc: # noqa: BLE001 + await self.storage.add_log("WARN", f"{sym} evaluate_failed: {exc}") + continue + if result.signal_level in {"WATCH", "TRIGGER"}: est_vol = float(vol_map.get(inst, 0.0)) if vol_map else 0.0 signal_side = str((result.metrics or {}).get("signal_side") or result.signal_side or "NONE") diff --git a/onchain_scout_gate/tests/test_candle_rows.py b/onchain_scout_gate/tests/test_candle_rows.py new file mode 100644 index 0000000..977f970 --- /dev/null +++ b/onchain_scout_gate/tests/test_candle_rows.py @@ -0,0 +1,23 @@ +from app.candle_rows import rows_to_ohlcv + + +def test_rows_to_ohlcv_skips_empty_volume() -> None: + rows = [ + ["1", "100", "101", "99", "100.5", "123"], + ["2", "100.5", "102", "100", "101", ""], + ["3", "101", "103", "100.5", "102", "50"], + ] + o, h, l, c, v = rows_to_ohlcv(rows) + assert len(c) == 2 + assert c == [100.5, 102.0] + assert v == [123.0, 50.0] + + +def test_rows_to_ohlcv_skips_invalid_ohlc() -> None: + rows = [ + ["1", "", "101", "99", "100.5", "10"], + ["2", "100", "101", "99", "100.5", "20"], + ] + _, _, _, c, v = rows_to_ohlcv(rows) + assert c == [100.5] + assert v == [20.0]