From ffba2e60e6143b6f158e9d5b297da73105ed9d1d Mon Sep 17 00:00:00 2001 From: dekun Date: Sat, 23 May 2026 17:18:38 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E7=AA=81=E7=A0=B4=E8=AE=A1?= =?UTF-8?q?=E7=AE=97=EF=BC=8C=E6=89=A7=E8=A1=8C=E5=99=A8=E4=B8=8B=E5=8D=95?= =?UTF-8?q?=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- gate_order_executor/app/gate_futures_live.py | 68 ++++++++++++-- gate_order_executor/app/gate_operations.py | 9 +- gate_order_executor/tests/test_market_fill.py | 21 +++++ onchain_scout_gate/app/config.py | 2 + onchain_scout_gate/app/key_gate.py | 92 +++++++++++++------ onchain_scout_gate/app/monitor.py | 2 +- onchain_scout_gate/app/web.py | 2 + onchain_scout_gate/config.example.yaml | 2 + onchain_scout_gate/tests/test_key_gate.py | 82 +++++++++++++++++ 9 files changed, 242 insertions(+), 38 deletions(-) create mode 100644 gate_order_executor/tests/test_market_fill.py create mode 100644 onchain_scout_gate/tests/test_key_gate.py diff --git a/gate_order_executor/app/gate_futures_live.py b/gate_order_executor/app/gate_futures_live.py index ba666e5..72a5db7 100644 --- a/gate_order_executor/app/gate_futures_live.py +++ b/gate_order_executor/app/gate_futures_live.py @@ -256,6 +256,46 @@ def _tp_sl_triggers(side: str, tp_price: str, sl_price: str) -> tuple[dict[str, return tp_tr, sl_tr +def _order_filled_abs(order: dict[str, Any]) -> float: + """市价单已成交张数(绝对值):|size| - |left|。""" + total = abs(_float(order.get("size"))) + left = abs(_float(order.get("left"))) + filled = total - left + if filled > 1e-12: + return filled + # 部分 Gate 响应在 finished 时 left 为空,用 finish_as / status 兜底 + if str(order.get("status") or "") == "finished" and total > 1e-12 and left <= 1e-12: + return total + return 0.0 + + +def _market_fill_accepted( + order: dict[str, Any], + *, + net_size: float, + order_size_min: float, +) -> tuple[bool, float, str]: + """ + 判定市价是否已有有效成交(含 IOC 部分成交)。 + 返回 (accepted, filled_abs, note)。 + """ + filled = _order_filled_abs(order) + net_abs = abs(net_size) + effective = max(filled, net_abs) + min_sz = max(float(order_size_min), 1e-12) + if effective + 1e-12 < min_sz: + return False, effective, "below_order_size_min" + st = str(order.get("status") or "") + finish = str(order.get("finish_as") or "") + if effective >= min_sz: + if st == "finished" or net_abs >= min_sz: + note = "partial_fill" if filled > 1e-12 and abs(_float(order.get("left"))) > 1e-12 else "filled" + if finish and finish not in {"filled", "ioc", ""} and net_abs < min_sz: + return False, effective, f"finish_as={finish}" + return True, effective, note + return False, effective, "no_fill" + + async def execute_signal_live(settings: Settings, sig: TradeSignal) -> dict: """ 市价开仓 + 计划委托止盈/止损(reduce_only 市价 IOC)。 @@ -329,13 +369,21 @@ async def execute_signal_live(settings: Settings, sig: TradeSignal) -> dict: if not isinstance(order, dict): return {"status": "error", "reason": "order_response_invalid"} - st = str(order.get("status") or "") - finish = str(order.get("finish_as") or "") - left_abs = abs(_float(order.get("left"))) - if st != "finished" or left_abs > 1e-12: - return {"status": "error", "reason": "market_not_filled", "order": order} - if finish and finish not in {"filled", "ioc"}: - return {"status": "error", "reason": "market_not_filled", "order": order} + net_size = await fetch_net_position_size(client, contract) + fill_ok, filled_abs, fill_note = _market_fill_accepted( + order, + net_size=net_size, + order_size_min=order_size_min, + ) + if not fill_ok: + return { + "status": "error", + "reason": "market_not_filled", + "detail": fill_note, + "order": order, + "net_position_size": net_size, + "filled_abs": filled_abs, + } tp_s = _format_trigger_price(float(sig.take_profit), price_tick) sl_s = _format_trigger_price(float(sig.stop_loss), price_tick) @@ -368,6 +416,9 @@ async def execute_signal_live(settings: Settings, sig: TradeSignal) -> dict: "signal_id": sig.signal_id, "market_order": order, "sized_contracts": open_size, + "filled_contracts": filled_abs, + "fill_note": fill_note, + "net_position_size": net_size, "risk_budget_usdt": round(risk_usdt, 6), "reference_entry": entry, "trigger_price_tick": str(price_tick) if price_tick is not None else None, @@ -420,6 +471,9 @@ async def execute_signal_live(settings: Settings, sig: TradeSignal) -> dict: "take_profit_order": tp_resp, "stop_loss_order": sl_resp, "sized_contracts": open_size, + "filled_contracts": filled_abs, + "fill_note": fill_note, + "net_position_size": net_size, "risk_budget_usdt": round(risk_usdt, 6), "reference_entry": entry, "trigger_price_tick": str(price_tick) if price_tick is not None else None, diff --git a/gate_order_executor/app/gate_operations.py b/gate_order_executor/app/gate_operations.py index 29d1cf6..0e6906f 100644 --- a/gate_order_executor/app/gate_operations.py +++ b/gate_order_executor/app/gate_operations.py @@ -57,9 +57,12 @@ async def list_futures_positions( if abs(_float_field(row.get("size"))) <= 1e-12: continue out.append(_slim_futures_position(row)) - if len(out) >= cap: - break - return out, None + out.sort( + key=lambda r: abs(_float_field(r.get("size"))) + * max(_float_field(r.get("mark_price"), _float_field(r.get("entry_price"), 1.0)), 1e-9), + reverse=True, + ) + return out[:cap], None except Exception as exc: # noqa: BLE001 return None, str(exc) diff --git a/gate_order_executor/tests/test_market_fill.py b/gate_order_executor/tests/test_market_fill.py new file mode 100644 index 0000000..2a08d5c --- /dev/null +++ b/gate_order_executor/tests/test_market_fill.py @@ -0,0 +1,21 @@ +from __future__ import annotations + +import unittest + +from app.gate_futures_live import _market_fill_accepted, _order_filled_abs + + +class TestMarketFill(unittest.TestCase): + def test_partial_ioc_fill_by_net_position(self): + order = {"status": "finished", "finish_as": "ioc", "size": "-1", "left": "-0.6"} + ok, filled, note = _market_fill_accepted(order, net_size=-0.4, order_size_min=0.1) + self.assertTrue(ok) + self.assertAlmostEqual(filled, 0.4, places=6) + + def test_order_filled_abs(self): + self.assertAlmostEqual(_order_filled_abs({"size": "0.4", "left": "0"}), 0.4, places=6) + self.assertAlmostEqual(_order_filled_abs({"size": "-1", "left": "-0.6"}), 0.4, places=6) + + +if __name__ == "__main__": + unittest.main() diff --git a/onchain_scout_gate/app/config.py b/onchain_scout_gate/app/config.py index d7e7b5e..36171a9 100644 --- a/onchain_scout_gate/app/config.py +++ b/onchain_scout_gate/app/config.py @@ -115,6 +115,8 @@ class MonitorConfig(BaseModel): symbol_signal_dedupe_hours: float = 4.0 # 企业微信主推送(突破预警):仅对本轮监控池内 24h 成交额排名前 N 的合约推送;0 表示不限制 wecom_push_max_volume_rank: int = 30 + # 全市场自动箱体 WATCH/TRIGGER 是否发企微(默认关;仅 GEMMA 漏斗优先推送 + 关键位监控推送) + push_watch_trigger_wecom: bool = False class GemmaConfig(BaseModel): diff --git a/onchain_scout_gate/app/key_gate.py b/onchain_scout_gate/app/key_gate.py index 11f7447..afefbe0 100644 --- a/onchain_scout_gate/app/key_gate.py +++ b/onchain_scout_gate/app/key_gate.py @@ -4,7 +4,12 @@ from __future__ import annotations from statistics import mean -from .candle_rows import rows_to_ohlcv +from .candle_rows import field_float + + +def _row_vol(item: list[str]) -> float: + v = field_float(item, 5) + return float(v) if v is not None else 0.0 def key_hard_checks_from_rows( @@ -21,45 +26,67 @@ def key_hard_checks_from_rows( volume_rank_total: int = 0, volume_rank_max: int = 30, ) -> dict: - """rows:Gate K 线行,最后一根应为最近一根已闭合 5m(调用方负责剔除未闭合)。""" + """ + rows:Gate K 线行(时间正序),最后一根应为最近一根已闭合 5m。 + 突破 K / 确认 K 固定为 rows[-2] / rows[-1](与 crypto_monitor_gate 一致)。 + """ out: dict = {"ok": False} - _, ah, al, ac, av = rows_to_ohlcv(rows) vol_lb = max(5, int(volume_ma_bars)) min_need = vol_lb + 3 - if len(ac) < min_need: - out["reason"] = f"insufficient_candles have={len(ac)} need>={min_need}" + if len(rows) < min_need: + out["reason"] = f"insufficient_candles have={len(rows)} need>={min_need}" return out - breakout_close = float(ac[-2]) - confirm_close = float(ac[-1]) - breakout_high = float(ah[-2]) - breakout_low = float(al[-2]) - try: - open_b = float(rows[-2][1]) - except (IndexError, TypeError, ValueError): - open_b = breakout_close + br_row = rows[-2] + cf_row = rows[-1] + open_b = field_float(br_row, 1) + breakout_high = field_float(br_row, 2) + breakout_low = field_float(br_row, 3) + breakout_close = field_float(br_row, 4) + confirm_close = field_float(cf_row, 4) + if None in (open_b, breakout_high, breakout_low, breakout_close, confirm_close): + out["reason"] = "invalid_breakout_or_confirm_ohlc" + return out - prev_vol = av[-vol_lb - 2 : -2] + open_b = float(open_b) + breakout_high = float(breakout_high) + breakout_low = float(breakout_low) + breakout_close = float(breakout_close) + confirm_close = float(confirm_close) + + hist = rows[-vol_lb - 2 : -2] + prev_vol = [_row_vol(x) for x in hist if _row_vol(x) > 0] avg20 = mean(prev_vol) if prev_vol else 0.0 - vol_break = float(av[-2]) + vol_break = _row_vol(br_row) vol_ok = vol_break > avg20 * volume_ratio_min if avg20 > 0 else False - amp_pct = abs(breakout_close - open_b) / open_b * 100 if open_b > 0 else 0.0 - amp_ok = (amp_pct > breakout_amp_min_pct) and (amp_pct < breakout_amp_max_pct) - direction = (direction or "long").strip().lower() edge = float(upper) if direction == "long" else float(lower) - breakout_ok = (breakout_close > float(upper)) if direction == "long" else (breakout_close < float(lower)) - confirm_ok_raw = (confirm_close > edge) if direction == "long" else (confirm_close < edge) - amp_ok = amp_ok and breakout_ok - confirm_ok = confirm_ok_raw and breakout_ok + edge_ref = edge if edge > 0 else 1.0 + + if direction == "long": + breach_pct = (breakout_close - edge) / edge_ref * 100.0 if breakout_close > edge else 0.0 + breakout_ok = breakout_close > float(upper) + confirm_ok = confirm_close > edge + else: + breach_pct = (edge - breakout_close) / edge_ref * 100.0 if breakout_close < edge else 0.0 + breakout_ok = breakout_close < float(lower) + confirm_ok = confirm_close < edge + + body_pct = abs(breakout_close - open_b) / open_b * 100.0 if open_b > 0 else 0.0 + amp_ok = ( + breakout_ok + and breach_pct > breakout_amp_min_pct + and breach_pct < breakout_amp_max_pct + ) + confirm_ok = confirm_ok and breakout_ok rank_ok = (volume_rank is not None) and (int(volume_rank) <= volume_rank_max) try: seg = rows[-48:] if len(rows) >= 48 else rows - hh = max(float(x[2]) for x in seg) - ll = min(float(x[3]) for x in seg) + hh = max(float(x[2]) for x in seg if field_float(x, 2) is not None) + ll = min(float(x[3]) for x in seg if field_float(x, 3) is not None) swing4h_pct = ((hh - ll) / ll * 100.0) if ll > 0 else 0.0 except Exception: swing4h_pct = 0.0 @@ -71,7 +98,9 @@ def key_hard_checks_from_rows( "avg20": avg20, "vol_break": vol_break, "amp_ok": amp_ok, - "amp_pct": amp_pct, + "amp_pct": breach_pct, + "breach_pct": breach_pct, + "body_pct": body_pct, "breakout_ok": breakout_ok, "breakout_close": breakout_close, "confirm_ok": confirm_ok, @@ -82,18 +111,27 @@ def key_hard_checks_from_rows( "rank_ok": rank_ok, "breakout_high": breakout_high, "breakout_low": breakout_low, - "swing4h_pct": swing4h_pct, + "breakout_open": open_b, "direction": direction, + "swing4h_pct": swing4h_pct, } ) return out def key_hard_lines_from_checks(checks: dict, *, volume_ratio_min: float) -> list[str]: + breach = float(checks.get("breach_pct") if checks.get("breach_pct") is not None else checks.get("amp_pct") or 0) + body = float(checks.get("body_pct") or 0) + br_hi = checks.get("breakout_high") + br_lo = checks.get("breakout_low") return [ f"量能:{'通过' if checks.get('vol_ok') else '不通过'}(突破K量 {round(float(checks.get('vol_break') or 0), 4)} / 前20均量 {round(float(checks.get('avg20') or 0), 4)},阈值{volume_ratio_min:g}x)", f"突破价位:{'通过' if checks.get('breakout_ok') else '不通过'}(突破K收盘 {round(float(checks.get('breakout_close') or 0), 8)},关键位 {checks.get('edge_price')})", - f"突破K幅度:{'通过' if checks.get('amp_ok') else '不通过'}({round(float(checks.get('amp_pct') or 0), 4)}%,要求0.03%~0.5%)", + ( + f"突破越过关键位:{'通过' if checks.get('amp_ok') else '不通过'}" + f"(越过 {round(breach, 4)}%,K线实体 {round(body, 4)}%,要求越过 0.03%~0.5%)" + ), f"第二根确认:{'通过' if checks.get('confirm_ok') else '不通过'}(确认收盘 {checks.get('confirm_close')},关键位 {checks.get('edge_price')})", f"日成交量排名:{'通过' if checks.get('rank_ok') else '不通过'}({checks.get('rank')}/{checks.get('rank_total')},要求前30)", + f"突破K极值:高 {br_hi}|低 {br_lo}(止损据此 ± 外扩%)", ] diff --git a/onchain_scout_gate/app/monitor.py b/onchain_scout_gate/app/monitor.py index 609f791..033c4b7 100644 --- a/onchain_scout_gate/app/monitor.py +++ b/onchain_scout_gate/app/monitor.py @@ -553,7 +553,7 @@ class MonitorService: }, ) if result.signal_level == "TRIGGER": - if strict_push_ok: + if strict_push_ok and self.settings.monitor.push_watch_trigger_wecom: push_metrics = dict(result.metrics) push_metrics["signal_side"] = signal_side push_metrics["btc_bias"] = btc_bias_5m diff --git a/onchain_scout_gate/app/web.py b/onchain_scout_gate/app/web.py index c8e1ddf..a52a2ad 100644 --- a/onchain_scout_gate/app/web.py +++ b/onchain_scout_gate/app/web.py @@ -530,6 +530,7 @@ def create_app(settings: Settings) -> FastAPI: km = settings.key_monitor return ( f"周期 5m|突破K/确认K:倒数第2/第1根闭合K|量能:突破K量 > 前{km.volume_ma_bars}均量×{km.volume_ratio_min}|" + f"突破越过关键位 {km.breakout_amp_min_pct:g}%~{km.breakout_amp_max_pct:g}%(非K线实体)|" f"计划RR须 > {km.min_planned_rr:g}|日成交额排名前{km.daily_volume_rank_max}|" f"箱体/收敛方案:标准突破(止损突破K外{km.standard_stop_outside_pct:g}%|止盈1×H)或 " f"趋势突破(止损突破K外{km.trend_stop_outside_pct:g}%|止盈手填)|" @@ -721,6 +722,7 @@ def create_app(settings: Settings) -> FastAPI: "btc_sideways_max_range_pct": settings.monitor.btc_sideways_max_range_pct, "symbol_signal_dedupe_hours": settings.monitor.symbol_signal_dedupe_hours, "wecom_push_max_volume_rank": settings.monitor.wecom_push_max_volume_rank, + "push_watch_trigger_wecom": settings.monitor.push_watch_trigger_wecom, "gemma": { "enabled": g.enabled, "ollama_base_url": g.ollama_base_url, diff --git a/onchain_scout_gate/config.example.yaml b/onchain_scout_gate/config.example.yaml index b098b09..8aa04ad 100644 --- a/onchain_scout_gate/config.example.yaml +++ b/onchain_scout_gate/config.example.yaml @@ -64,6 +64,8 @@ monitor: symbol_signal_dedupe_hours: 4 # 企业微信主推送:仅成交量排名前 N;0 表示不限制 wecom_push_max_volume_rank: 30 + # 全市场自动箱体 WATCH/TRIGGER 企微(默认 false;只保留 GEMMA 漏斗推送 + 关键位推送) + push_watch_trigger_wecom: false # 仅在 universe=watchlist 时使用;all_swaps 下可留空列表 watch_symbols: [] diff --git a/onchain_scout_gate/tests/test_key_gate.py b/onchain_scout_gate/tests/test_key_gate.py new file mode 100644 index 0000000..7bf6083 --- /dev/null +++ b/onchain_scout_gate/tests/test_key_gate.py @@ -0,0 +1,82 @@ +from __future__ import annotations + +import unittest + +from app.key_gate import key_hard_checks_from_rows +from app.key_sl_tp import plan_key_sl_tp + + +def _row(ts: int, o: float, h: float, l: float, c: float, v: float = 1000.0) -> list[str]: + return [str(ts), str(o), str(h), str(l), str(c), str(v)] + + +def _history(n: int, *, base_ts: int = 1_700_000_000_000, vol: float = 1000.0) -> list[list[str]]: + return [_row(base_ts + i * 300_000, 84.0, 84.2, 83.8, 84.0, vol) for i in range(n)] + + +class TestKeyGate(unittest.TestCase): + def test_short_breach_too_far_fails(self): + """SOL 类:收盘远低于下沿,越过 >0.5% 应拒绝(不再用 K 线实体误判)。""" + rows = _history(22, vol=40000.0) + rows.append(_row(1, 83.0, 83.55, 82.0, 82.19, 76791.0)) # 突破 K + rows.append(_row(2, 82.1, 82.3, 81.9, 82.02, 50000.0)) # 确认 K + checks = key_hard_checks_from_rows( + rows, + direction="short", + upper=87.8, + lower=83.28, + volume_rank=3, + volume_rank_total=721, + ) + breach = (83.28 - 82.19) / 83.28 * 100 + self.assertGreater(breach, 0.5) + self.assertFalse(checks["amp_ok"]) + self.assertFalse(checks["ok"]) + + def test_short_breach_in_band_passes_amp(self): + rows = _history(22, vol=40000.0) + # 越过下沿约 0.12%:83.28 -> 83.18 + rows.append(_row(1, 83.4, 83.5, 83.1, 83.18, 76791.0)) + rows.append(_row(2, 83.15, 83.2, 83.0, 83.10, 50000.0)) + checks = key_hard_checks_from_rows( + rows, + direction="short", + upper=87.8, + lower=83.28, + volume_rank=3, + volume_rank_total=721, + ) + self.assertTrue(checks["amp_ok"]) + self.assertAlmostEqual(checks["breach_pct"], (83.28 - 83.18) / 83.28 * 100, places=4) + + def test_trend_short_sl_uses_breakout_high(self): + rows = _history(22, vol=40000.0) + br_hi = 83.55 + rows.append(_row(1, 83.0, br_hi, 82.0, 82.19, 76791.0)) + rows.append(_row(2, 82.1, 82.3, 81.9, 82.02, 50000.0)) + checks = key_hard_checks_from_rows( + rows, + direction="short", + upper=87.8, + lower=83.28, + volume_rank=3, + volume_rank_total=721, + ) + self.assertEqual(checks["breakout_high"], br_hi) + plan = plan_key_sl_tp( + "trend_manual", + "short", + 87.8, + 83.28, + checks, + outside_pct=0.3, + trend_outside_pct=1.0, + manual_take_profit=78.7, + ) + self.assertIsNotNone(plan) + _, sl, _, _ = plan # type: ignore[misc] + self.assertAlmostEqual(sl, br_hi * 1.01, places=4) + + +if __name__ == "__main__": + unittest.main()