fix: 하드닝 루프 8회차 — 체결 보정·고아 감지·청산 경보·실시간/차트·리스크 가드 일괄 수정
- 스윙 체결가 확인 전 executed_price NULL + fill_sweep, 진입가 resolve(ActiveTrade↔Portfolio), 0체결/부분체결 매수 장부 원복 - DT 고아 감지(근거 한정)·0체결 매도 복원·stale 점검 확대, 시간손절 -0.3% 원복 - 폴백 손절 ATR 캐시, 거래량부족-차단 신호 매수 차단, 현금 5% 유보 재적용, MDD peak/reset 수정 - KIS 서킷브레이커 청산 우회·경보, db_worker 재큐잉 순서, heartbeat·health-watch 사이드카 - 실시간 WS 재구독·41건 우선순위·다건 프레임 분할, 차트 샘플 제거·당일봉 합성, WS 서브프로토콜 인증 - 프론트: 미관리 그룹·확인 모달·차트 실시간 반영·예수금 표시 정정 - 전문가 에이전트 6종 추가(.claude/agents) - 유닛 스위트 2667 passed (소켓 차단 하), tsc OK Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Y83Za98XsNw9zuA4FTUytN
This commit is contained in:
@@ -10,6 +10,7 @@ from fastapi import APIRouter, HTTPException, Query, Depends
|
||||
from typing import List, Optional
|
||||
from datetime import datetime, date, timedelta
|
||||
import asyncio
|
||||
import re
|
||||
import logging
|
||||
from collections import namedtuple
|
||||
|
||||
@@ -39,6 +40,228 @@ def _today_kst() -> date:
|
||||
return datetime.now(_KST).date()
|
||||
|
||||
|
||||
def _row_price_date_v2(stock) -> tuple:
|
||||
"""DB 행의 (price_date 'YYYYMMDD', stale 여부). stock_analysis 에는 price_date 컬럼이 없다.
|
||||
|
||||
장전(09:00 이전) 갱신 행은 차트 마지막 봉이 전일이라 analysis_date 를 가격일로 쓰면 거짓이다.
|
||||
갱신 시각(KST naive)이 analysis_date 당일 09:00 이전이면 가격일 미상("")+stale=True.
|
||||
"""
|
||||
ad = stock.analysis_date
|
||||
if isinstance(ad, datetime):
|
||||
ad = ad.date()
|
||||
ts = getattr(stock, "updated_at", None)
|
||||
if isinstance(ts, datetime) and ts < datetime(ad.year, ad.month, ad.day, 9, 0):
|
||||
return "", True
|
||||
return ad.strftime("%Y%m%d"), False
|
||||
|
||||
|
||||
def _persistable_items_v2(items, today: date) -> list:
|
||||
"""분석 결과 중 DB(analysis_date=오늘)에 저장해도 되는 것만 남긴다.
|
||||
|
||||
price_date 가 오늘이 아닌(장전 전일 봉 등) 결과를 오늘 날짜로 UPSERT 하면 소비처
|
||||
(analysis_date >= today 후보 쿼리)가 전일 데이터를 오늘 것으로 읽는다. 가격일 미상("")은 유지.
|
||||
"""
|
||||
ts = today.strftime("%Y%m%d")
|
||||
out = []
|
||||
for it in items:
|
||||
pd_ = str(getattr(it[2], "price_date", "") or "")
|
||||
if pd_ and pd_ != ts:
|
||||
continue
|
||||
out.append(it)
|
||||
return out
|
||||
|
||||
|
||||
# ── 스캐너 표시용 매수 차단 판정 (2026-10-08) ────────────────────────────────
|
||||
# 신호 문자열에 이미 "…(거래량부족-차단)", "이격도 과열… - 매수금지", "급등과열: …" 처럼
|
||||
# 차단 사유가 섞여 있는데 화면은 이를 구분하지 못해 '추천'과 '차단'이 같은 줄에 표시됐다.
|
||||
_BLOCK_MARKERS = ("차단", "매수금지", "급등과열", "이격도 과열")
|
||||
|
||||
|
||||
def _block_reasons_v2(signals) -> list:
|
||||
out = []
|
||||
for sig in (signals or []):
|
||||
text = str(sig)
|
||||
if any(m in text for m in _BLOCK_MARKERS):
|
||||
out.append(text)
|
||||
return out
|
||||
|
||||
|
||||
_VOL_RATIO_SIGNAL_RE = re.compile(r"거래량 실린 상승 \(([0-9]+(?:\.[0-9]+)?)배\)")
|
||||
|
||||
|
||||
def _stock_volume_ratio_v2(stock):
|
||||
"""종목의 거래량 배수(20일 평균 대비). 없으면 None.
|
||||
|
||||
StockRecommendation 에는 volume_ratio 필드가 없으므로(market_analyzer 소유),
|
||||
1) 속성이 있으면 사용, 2) 없으면 market_analyzer 가 남기는 신호 문자열
|
||||
'거래량 실린 상승 (N.N배)' 에서 복원한다. 이 신호는 배수>=1.5 & 상승일 때만 생성되므로
|
||||
복원 불가면 None(= 배수 미확인, 호출부 fail-closed). 장중 부분거래량은 배수를 낮게 만들 뿐이라
|
||||
보수적 방향으로만 틀린다.
|
||||
"""
|
||||
ratio = getattr(stock, "volume_ratio", None)
|
||||
if ratio is not None:
|
||||
return ratio
|
||||
for sig in (getattr(stock, "signals", None) or []):
|
||||
m = _VOL_RATIO_SIGNAL_RE.search(str(sig))
|
||||
if m:
|
||||
return float(m.group(1))
|
||||
return None
|
||||
|
||||
|
||||
def _generic_rule_matches_v2(rule, stock, conditions: dict) -> bool:
|
||||
"""realtime 빌더 else 분기(비-MEAN_REVERSION 규칙)의 후보 판정.
|
||||
|
||||
[하드닝 R1] 이전에는 rule_type 을 가리지 않아 SELL 규칙까지 rule_id 가 붙은 매수 후보가
|
||||
되었고, trade_executor 의 has_rule 강제매수가 '거래량부족-차단' 등 차단 신호를 덮어썼다.
|
||||
- SELL(및 EXIT/STOP 계열) 규칙은 매수 후보에서 제외
|
||||
- 차단 신호(_block_reasons_v2)가 있는 종목은 규칙 후보에서 제외
|
||||
RSI 임계(기본 40)는 기존 값을 유지한다(변경은 백테스트 필요).
|
||||
"""
|
||||
if str(getattr(rule, "rule_type", "") or "").upper() in ("SELL", "EXIT", "STOP_LOSS", "TAKE_PROFIT"):
|
||||
return False
|
||||
if _block_reasons_v2(getattr(stock, "signals", None)):
|
||||
return False
|
||||
conditions = conditions or {}
|
||||
has_rsi_key = ("rsi_below" in conditions) or ("rsi_threshold" in conditions)
|
||||
has_vol_key = "volume_above_average" in conditions
|
||||
if conditions and not (has_rsi_key or has_vol_key):
|
||||
# 해석 가능한 조건 키가 하나도 없으면 규칙 의도를 알 수 없다 -> fail-closed
|
||||
return False
|
||||
if stock.rsi is None:
|
||||
return False
|
||||
# rsi_below(엄격 미만) / rsi_threshold(이하, 기존 의미). 둘 다 있으면 더 엄격한 쪽.
|
||||
if has_rsi_key:
|
||||
try:
|
||||
if "rsi_below" in conditions and not (stock.rsi < float(conditions["rsi_below"])):
|
||||
return False
|
||||
if "rsi_threshold" in conditions and not (stock.rsi <= float(conditions["rsi_threshold"])):
|
||||
return False
|
||||
except (TypeError, ValueError):
|
||||
return False
|
||||
elif not (stock.rsi <= 40): # RSI 키가 없는 조건의 기존 기본 동작 유지
|
||||
return False
|
||||
if has_vol_key:
|
||||
# 거래량 배수를 알 수 없으면(필드 미노출) 통과시키지 않는다(fail-closed).
|
||||
ratio = _stock_volume_ratio_v2(stock)
|
||||
try:
|
||||
if ratio is None or not (float(ratio) >= float(conditions["volume_above_average"])):
|
||||
return False
|
||||
except (TypeError, ValueError):
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
# trade_executor 의 'ML HOLD(conf≥0.6) → 매수 차단' 게이트와 같은 신뢰도 기준(리터럴 단일화).
|
||||
_ML_HOLD_BLOCK_CONF_V2 = 0.6
|
||||
|
||||
|
||||
def _ml_block_reasons_v2(ml_signal, ml_confidence) -> list:
|
||||
"""trade_executor 실제 매수 ML 게이트와 같은 정의의 ML 차단 사유.
|
||||
|
||||
- ML HOLD & 신뢰도 ≥ 0.6 & ML_ENSEMBLE_ENABLED → 매수 차단('ML HOLD(conf≥0.6)')
|
||||
- ML SELL 은 ML_SELL_SIGNAL_ENABLED=True 일 때만 차단 사유(False 면 trade_executor 가
|
||||
매수를 막지 않으므로 사유에 넣지 않는다 — 참고 정보는 ml_signal 필드로 노출).
|
||||
"""
|
||||
sig = str(ml_signal or "").upper()
|
||||
reasons = []
|
||||
try:
|
||||
conf = float(ml_confidence or 0)
|
||||
except (TypeError, ValueError):
|
||||
conf = 0.0
|
||||
if sig == "HOLD" and settings.ML_ENSEMBLE_ENABLED and conf >= _ML_HOLD_BLOCK_CONF_V2:
|
||||
reasons.append(f"ML HOLD(conf≥{_ML_HOLD_BLOCK_CONF_V2:g})")
|
||||
if sig == "SELL" and settings.ML_SELL_SIGNAL_ENABLED:
|
||||
reasons.append("ML 신호 SELL")
|
||||
return reasons
|
||||
|
||||
|
||||
def _scanner_flags_v2(stock, rule=None) -> dict:
|
||||
"""스캐너 항목의 규칙 출처·차단 사유·buy_allowed·ML 판정. 기존 필드는 건드리지 않는다.
|
||||
|
||||
buy_allowed = 신호 차단 사유 없음 AND ML 게이트 비차단(`_ml_block_reasons_v2`, trade_executor 와 동일 정의).
|
||||
(MDD·일일한도 등 실행 시점 게이트는 여기서 판정하지 않는다 — 표시용 1차 판정)
|
||||
"""
|
||||
reasons = _block_reasons_v2(getattr(stock, "signals", None))
|
||||
ml_reasons = _ml_block_reasons_v2(getattr(stock, "ml_signal", None), getattr(stock, "ml_confidence", None))
|
||||
reasons = reasons + ml_reasons
|
||||
matched = rule is not None
|
||||
return {
|
||||
"match_source": "rule" if matched else "fallback_high_score",
|
||||
"match_source_label": "활성 규칙 매칭" if matched else "고점수 분석 종목(규칙 미충족 대체)",
|
||||
"block_reasons": reasons,
|
||||
"buy_allowed": not reasons,
|
||||
"ml_blocks_buy": bool(ml_reasons),
|
||||
}
|
||||
|
||||
|
||||
def _batch_time_iso_v2(stocks) -> str:
|
||||
"""배치 실제 스캔 시각 — KST 타임존 포함 ISO(예: 2026-10-08T09:10:03+09:00).
|
||||
|
||||
DB updated_at/created_at 은 KST naive(core.timeutil). 시각이 없으면 분석일 00:00 KST.
|
||||
(이전: 날짜만 내려가 브라우저가 UTC 자정 → KST 09:00 으로 표시)
|
||||
"""
|
||||
latest = None
|
||||
for st in stocks:
|
||||
ts = getattr(st, "updated_at", None) or getattr(st, "created_at", None)
|
||||
if isinstance(ts, datetime) and (latest is None or ts > latest):
|
||||
latest = ts
|
||||
if latest is None:
|
||||
d = max((st.analysis_date for st in stocks), default=_today_kst())
|
||||
if isinstance(d, datetime):
|
||||
d = d.date()
|
||||
latest = datetime(d.year, d.month, d.day)
|
||||
if latest.tzinfo is None:
|
||||
latest = _KST.localize(latest)
|
||||
else:
|
||||
latest = latest.astimezone(_KST)
|
||||
return latest.isoformat()
|
||||
|
||||
|
||||
async def _overlay_realtime_v2(result: dict) -> dict:
|
||||
"""Redis 실시간 틱(tick:{code}, TTL 10s)이 있으면 현재가와 등락률을 **같은 소스**로 덮어쓴다.
|
||||
|
||||
배치 값(change_rate)은 배치 시점 기준이라 실시간 가격과 섞이면 서로 어긋난다.
|
||||
원본 result 는 변경하지 않는다(자동매매 스냅샷과 공유될 수 있음) — 항목 dict 를 복사한다.
|
||||
틱이 없는 종목은 배치 값 유지 + price_source="batch".
|
||||
"""
|
||||
from app.core.redis import get_json
|
||||
|
||||
lists = [result.get("recommendations") or [], result.get("analyzed_stock_list") or []]
|
||||
for g in result.get("recommendations_by_rule") or []:
|
||||
lists.append(g.get("stocks") or [])
|
||||
codes = sorted({it["stock_code"] for lst in lists for it in lst if it.get("stock_code")})[:400]
|
||||
if not codes:
|
||||
return result
|
||||
ticks = await asyncio.gather(*[get_json(f"tick:{c}") for c in codes], return_exceptions=True)
|
||||
tick_by = {
|
||||
c: t for c, t in zip(codes, ticks)
|
||||
if isinstance(t, dict) and (t.get("price") or 0) > 0 and t.get("change_rate") is not None
|
||||
}
|
||||
|
||||
def _apply(item: dict) -> dict:
|
||||
new = dict(item)
|
||||
t = tick_by.get(item.get("stock_code"))
|
||||
if t:
|
||||
new["batch_current_price"] = item.get("current_price")
|
||||
new["batch_change_rate"] = item.get("change_rate")
|
||||
new["current_price"] = int(t["price"])
|
||||
new["change_rate"] = round(float(t["change_rate"]), 2)
|
||||
new["price_source"] = "realtime"
|
||||
else:
|
||||
new["price_source"] = "batch"
|
||||
return new
|
||||
|
||||
out = dict(result)
|
||||
out["recommendations"] = [_apply(i) for i in lists[0]]
|
||||
out["analyzed_stock_list"] = [_apply(i) for i in lists[1]]
|
||||
out["recommendations_by_rule"] = [
|
||||
{**g, "stocks": [_apply(i) for i in (g.get("stocks") or [])]}
|
||||
for g in (result.get("recommendations_by_rule") or [])
|
||||
]
|
||||
out["realtime_overlay_count"] = len(tick_by)
|
||||
return out
|
||||
|
||||
|
||||
# 매매 규칙 namedtuple
|
||||
Rule = namedtuple('Rule', ['id', 'name', 'description', 'rule_type', 'priority', 'conditions', 'actions', 'is_active'])
|
||||
|
||||
@@ -60,7 +283,10 @@ def _stock_analysis_to_dict(stock, rule=None, actions=None) -> dict:
|
||||
"ml_score": stock.ml_score,
|
||||
"ml_confidence": round(float(stock.ml_confidence), 4) if stock.ml_confidence else None,
|
||||
"ml_signal": stock.ml_signal,
|
||||
"price_date": stock.analysis_date.strftime("%Y%m%d"),
|
||||
"price_date": _row_price_date_v2(stock)[0],
|
||||
"price_stale": _row_price_date_v2(stock)[1],
|
||||
"price_source": "batch",
|
||||
**_scanner_flags_v2(stock, rule),
|
||||
}
|
||||
if rule is not None:
|
||||
_a = actions or {}
|
||||
@@ -167,6 +393,17 @@ async def get_recommendations(
|
||||
"recommendations": recommendations[:20]
|
||||
}
|
||||
|
||||
# 이벤트 루프는 태스크를 약참조로만 보관 — 주문 경로가 중간 GC 되지 않도록 강참조 보관
|
||||
_bg_trade_tasks: set = set()
|
||||
|
||||
|
||||
def _spawn_bg_trade_v2(coro) -> "asyncio.Task":
|
||||
task = asyncio.create_task(coro)
|
||||
_bg_trade_tasks.add(task)
|
||||
task.add_done_callback(_bg_trade_tasks.discard)
|
||||
return task
|
||||
|
||||
|
||||
@router.get("/rule-based")
|
||||
async def get_rule_based_recommendations(
|
||||
realtime: bool = Query(False, description="실시간 분석 여부 (느림, 30초)"),
|
||||
@@ -188,10 +425,50 @@ async def get_rule_based_recommendations(
|
||||
await execute_auto_trade_background(rec_snapshot, bg_db)
|
||||
except Exception as e:
|
||||
logger.error(f"[_bg_trade] 백그라운드 자동매매 실행 중 예외: {e}", exc_info=True)
|
||||
asyncio.create_task(_bg_trade())
|
||||
|
||||
_spawn_bg_trade_v2(_bg_trade())
|
||||
|
||||
# 표시용 실시간 보정(현재가+등락률 동일 소스). 매매 스냅샷(rec_snapshot)은 원본 유지.
|
||||
if not realtime:
|
||||
try:
|
||||
return await _overlay_realtime_v2(result)
|
||||
except Exception as e:
|
||||
logger.warning(f"[rule-based] 실시간 오버레이 실패 — 배치 값 사용: {e}")
|
||||
return result
|
||||
|
||||
def _merge_json_mean_reversion_rule_v2(rules: list) -> list:
|
||||
"""DB 활성 규칙에 MEAN_REVERSION 이 없으면 trading_rules_config.json 의 MR 규칙을 병합한다.
|
||||
|
||||
운영 DB 에는 BUY/SELL/SELL 3개뿐이라 `is_mean_reversion_candidate`(own_trend·ADX·%B 게이트)가
|
||||
한 번도 호출되지 않았다. 진입 집합이 크게 바뀌므로 플래그
|
||||
`MEAN_REVERSION_RULE_MERGE_ENABLED`(기본 False, §8.4 백테스트 후 켤 것)로 제어한다.
|
||||
플래그/파일 처리는 병합 자체에만 영향 — 읽기 실패는 warning 후 DB 규칙 그대로 반환.
|
||||
"""
|
||||
if not getattr(settings, "MEAN_REVERSION_RULE_MERGE_ENABLED", False):
|
||||
return rules
|
||||
if any(str(getattr(r, "rule_type", "") or "").upper() == "MEAN_REVERSION" for r in rules):
|
||||
return rules
|
||||
try:
|
||||
import json as _json
|
||||
from pathlib import Path as _Path
|
||||
config_path = _Path(__file__).parent.parent.parent.parent / "trading_rules_config.json"
|
||||
with open(config_path, 'r', encoding='utf-8') as f:
|
||||
config = _json.load(f)
|
||||
mr = [r for r in config.get("rules", [])
|
||||
if r.get("is_active") and str(r.get("rule_type", "")).upper() == "MEAN_REVERSION"]
|
||||
except Exception as e:
|
||||
logger.warning(f"JSON MEAN_REVERSION 규칙 병합 실패 — DB 규칙만 사용: {e}")
|
||||
return rules
|
||||
if not mr:
|
||||
return rules
|
||||
r = mr[0]
|
||||
next_id = max([int(x.id) for x in rules if x.id is not None] + [0]) + 1
|
||||
return rules + [Rule(
|
||||
id=next_id, name=r["name"], description=r.get("description", ""),
|
||||
rule_type="MEAN_REVERSION", priority=r.get("priority", 1),
|
||||
conditions=r.get("conditions", {}), actions=r.get("actions", {}), is_active=True,
|
||||
)]
|
||||
|
||||
|
||||
async def _load_active_rules_from_db(db: AsyncSession) -> list:
|
||||
from app.models.models import TradingRule as TradingRuleModel
|
||||
try:
|
||||
@@ -202,12 +479,13 @@ async def _load_active_rules_from_db(db: AsyncSession) -> list:
|
||||
)
|
||||
db_rules = result.scalars().all()
|
||||
if db_rules:
|
||||
return [
|
||||
rules = [
|
||||
Rule(id=r.id, name=r.name, description=r.description or "",
|
||||
rule_type=r.rule_type, priority=r.priority,
|
||||
conditions=r.conditions, actions=r.actions, is_active=True)
|
||||
for r in db_rules
|
||||
]
|
||||
return _merge_json_mean_reversion_rule_v2(rules)
|
||||
except Exception as e:
|
||||
logger.warning(f"trading_rules DB 조회 실패: {e}")
|
||||
|
||||
@@ -451,6 +729,16 @@ async def get_rule_based_recommendations_from_db(db: AsyncSession):
|
||||
continue
|
||||
already_added.add(stock.stock_code)
|
||||
stock_dict = _stock_analysis_to_dict(stock)
|
||||
# 규칙 미충족 대체 목록은 매수 후보가 아니다: MR 게이트(own_trend·ADX·%B·RSI)를 통과한
|
||||
# 종목만 매수 가능, 나머지는 청산 판정 전용(_exit_only — trade_executor 가 should_buy 에서 SKIP).
|
||||
# 게이트 판정 예외는 fail-closed(청산 전용).
|
||||
try:
|
||||
_mr_ok = bool(is_mean_reversion_candidate(stock, {}, current_market_regime))
|
||||
except Exception as e:
|
||||
logger.warning(f"[rule-based] 대체 목록 MR 게이트 판정 실패 — 청산 전용 처리 {stock.stock_code}: {e}")
|
||||
_mr_ok = False
|
||||
stock_dict["_exit_only"] = not _mr_ok
|
||||
stock_dict["buy_allowed"] = bool(stock_dict.get("buy_allowed")) and _mr_ok
|
||||
fallback_stocks.append(stock_dict)
|
||||
rule_matches.append(stock_dict)
|
||||
|
||||
@@ -479,13 +767,20 @@ async def get_rule_based_recommendations_from_db(db: AsyncSession):
|
||||
"stochastic_k": round(float(s.stochastic_k), 2) if s.stochastic_k else None,
|
||||
"signals": s.signals or [],
|
||||
"score": s.score or 0,
|
||||
"ml_signal": s.ml_signal,
|
||||
"ml_confidence": round(float(s.ml_confidence), 4) if s.ml_confidence else None,
|
||||
"price_source": "batch",
|
||||
"block_reasons": _block_reasons_v2(s.signals) + _ml_block_reasons_v2(s.ml_signal, s.ml_confidence),
|
||||
"buy_allowed": not (_block_reasons_v2(s.signals) + _ml_block_reasons_v2(s.ml_signal, s.ml_confidence)),
|
||||
}
|
||||
for s in all_analyzed_stocks
|
||||
],
|
||||
"recommendations": rule_matches,
|
||||
"recommendations_by_rule": recommendations_by_rule,
|
||||
"rules_applied": [{"id": r.id, "name": r.name, "priority": r.priority} for r in active_rules],
|
||||
"batch_time": (lambda d: d.date() if isinstance(d, datetime) else d)(
|
||||
# batch_time: 실제 스캔 시각(KST 오프셋 포함 ISO). 날짜만 필요하면 batch_date.
|
||||
"batch_time": _batch_time_iso_v2(all_analyzed_stocks),
|
||||
"batch_date": (lambda d: d.date() if isinstance(d, datetime) else d)(
|
||||
max((s.analysis_date for s in all_analyzed_stocks), default=_today_kst())
|
||||
).isoformat(),
|
||||
}
|
||||
@@ -627,8 +922,7 @@ async def get_rule_based_recommendations_realtime():
|
||||
elif rule.rule_type in ("TREND", "BREAKOUT", "MOMENTUM"):
|
||||
matches = False
|
||||
else:
|
||||
rsi_threshold = conditions.get("rsi_threshold", 40)
|
||||
matches = (stock.rsi is not None and stock.rsi <= rsi_threshold)
|
||||
matches = _generic_rule_matches_v2(rule, stock, conditions)
|
||||
|
||||
if matches:
|
||||
rule_matched_stocks.append(stock)
|
||||
@@ -673,8 +967,11 @@ async def get_rule_based_recommendations_realtime():
|
||||
# db_worker 큐 경유 — 실시간 분석 경로에서 AsyncSessionLocal 직접 사용 금지 (CLAUDE.md 3.2)
|
||||
from app.services.analysis_db_service import queue_bulk_upsert_analysis as _queue_bulk
|
||||
_today = _today_kst()
|
||||
_items = [(rec.stock_code, rec.stock_name, rec) for rec in valid_analyzed_stocks]
|
||||
await _queue_bulk(_items, _today)
|
||||
_items = _persistable_items_v2(
|
||||
[(rec.stock_code, rec.stock_name, rec) for rec in valid_analyzed_stocks], _today
|
||||
)
|
||||
if _items:
|
||||
await _queue_bulk(_items, _today)
|
||||
|
||||
return {
|
||||
"total": len(rule_matches),
|
||||
|
||||
Reference in New Issue
Block a user