180 lines
7.5 KiB
Python
180 lines
7.5 KiB
Python
"""Independent short strategy builders.
|
|
|
|
Short setups are not inverted long setups. They need their own trigger,
|
|
confirmation, invalidation and review lineage.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from app.core.factor_roles import CONFIRMATION, ENTRY, RISK, TRIGGER
|
|
from app.core.strategy_contract import StrategySignal, current_strategy_version
|
|
from app.core.strategy_registry import BREAKDOWN_RETEST_SHORT_1H_STRATEGY
|
|
|
|
|
|
def _safe_float(value, default=0.0) -> float:
|
|
try:
|
|
if value is None or value == "":
|
|
return default
|
|
return float(value)
|
|
except Exception:
|
|
return default
|
|
|
|
|
|
def detect_breakdown_retest_short_1h(df, *, change_24h: float = 0.0) -> dict:
|
|
"""Detect a 1H breakdown followed by a failed retest of the broken box.
|
|
|
|
This is intentionally conservative: the setup needs a prior box, a close
|
|
below support, a retest near the broken support, and rejection back below it.
|
|
"""
|
|
if df is None or len(df) < 60:
|
|
return {"detected": False, "reason": "insufficient_data"}
|
|
try:
|
|
work = df.tail(72).copy()
|
|
prev = work.iloc[:-8]
|
|
recent = work.iloc[-8:]
|
|
current = float(work["close"].iloc[-1])
|
|
box_low = float(prev["low"].tail(48).min())
|
|
box_high = float(prev["high"].tail(48).max())
|
|
if current <= 0 or box_low <= 0 or box_high <= box_low:
|
|
return {"detected": False, "reason": "invalid_box"}
|
|
box_width_pct = (box_high / box_low - 1) * 100
|
|
if box_width_pct > 38:
|
|
return {"detected": False, "reason": "box_too_wide", "box_width_pct": round(box_width_pct, 2)}
|
|
closes = [float(x) for x in recent["close"].tolist()]
|
|
highs = [float(x) for x in recent["high"].tolist()]
|
|
opens = [float(x) for x in recent["open"].tolist()]
|
|
breakdown_closes = [x for x in closes if x < box_low * 0.992]
|
|
touched_retest = any(box_low * 0.988 <= h <= box_low * 1.035 for h in highs[-5:])
|
|
latest_rejected = current < box_low * 0.992 and closes[-1] < opens[-1]
|
|
below_ema = current < float(work["close"].ewm(span=25, adjust=False).mean().iloc[-1])
|
|
if not breakdown_closes or not touched_retest:
|
|
return {
|
|
"detected": False,
|
|
"reason": "no_breakdown_retest",
|
|
"box_low": round(box_low, 8),
|
|
"box_high": round(box_high, 8),
|
|
}
|
|
stop_level = max(max(highs[-5:]) * 1.012, box_low * 1.025)
|
|
risk = max(stop_level - current, current * 0.025)
|
|
target_1 = max(current - risk * 1.8, current * 0.72)
|
|
quality_score = 0
|
|
quality_score += 3 if latest_rejected else 1
|
|
quality_score += 2 if below_ema else 0
|
|
quality_score += 2 if float(change_24h or 0) <= -3 else 0
|
|
quality_score += 1 if box_width_pct <= 24 else 0
|
|
quality = "优质" if quality_score >= 7 else "良好" if quality_score >= 5 else "普通"
|
|
return {
|
|
"detected": True,
|
|
"breakdown_level": round(box_low, 8),
|
|
"box_high": round(box_high, 8),
|
|
"box_width_pct": round(box_width_pct, 2),
|
|
"retest_zone": round(box_low, 8),
|
|
"stop_level": round(stop_level, 8),
|
|
"target_1": round(target_1, 8),
|
|
"quality": quality,
|
|
"quality_score": quality_score,
|
|
"score": quality_score,
|
|
"retest_rejected": latest_rejected,
|
|
"relative_weakness": bool(below_ema or float(change_24h or 0) <= -3),
|
|
"pullback_age_bars": 0,
|
|
"signals": [
|
|
f"1H破位反抽做空(破位{box_low:.6g})",
|
|
"反抽失败确认" if latest_rejected else "等待反抽失败确认",
|
|
],
|
|
}
|
|
except Exception as exc:
|
|
return {"detected": False, "reason": f"error:{exc}"}
|
|
|
|
|
|
def build_breakdown_retest_short_1h_signal(
|
|
*,
|
|
symbol: str,
|
|
current_price: float,
|
|
detection: dict,
|
|
entry_plan: dict | None = None,
|
|
market_regime: dict | None = None,
|
|
decision_log: dict | None = None,
|
|
) -> StrategySignal | None:
|
|
"""Build a short signal for breakdown -> failed retest setups.
|
|
|
|
Expected detection fields are intentionally simple so scanner/confirm
|
|
implementations can evolve without changing the strategy contract:
|
|
detected, breakdown_level, retest_zone, stop_level, target_1, quality,
|
|
retest_rejected, score.
|
|
"""
|
|
if not (detection or {}).get("detected"):
|
|
return None
|
|
entry_plan = dict(entry_plan or {})
|
|
market_regime = market_regime or {}
|
|
quality = str(detection.get("quality") or "")
|
|
retest_rejected = bool(detection.get("retest_rejected"))
|
|
current_price = _safe_float(current_price)
|
|
retest_zone = _safe_float(detection.get("retest_zone") or detection.get("breakdown_level"))
|
|
distance_pct = abs(current_price / retest_zone - 1) * 100 if current_price > 0 and retest_zone > 0 else 0.0
|
|
risk_level = str(market_regime.get("risk_level") or "medium").lower()
|
|
reasons = []
|
|
status = "candidate"
|
|
if quality not in {"良好", "优质"}:
|
|
status = "observe"
|
|
reasons.append(f"反抽质量 {quality or '未知'},不直接做空")
|
|
if not retest_rejected:
|
|
status = "observe"
|
|
reasons.append("尚未确认反抽失败")
|
|
if risk_level in {"low", "medium"} and not bool(detection.get("relative_weakness")):
|
|
status = "observe"
|
|
reasons.append("市场并非明显弱势,且缺少相对弱势确认")
|
|
if distance_pct > 8:
|
|
status = "observe"
|
|
reasons.append(f"当前价离反抽区 {distance_pct:.1f}%,不追空")
|
|
|
|
entry_plan.setdefault("side", "short")
|
|
entry_plan.setdefault("entry_action", "可即刻买入" if status == "candidate" else "观察")
|
|
entry_plan.setdefault("entry_price", current_price)
|
|
entry_plan.setdefault("stop_loss", detection.get("stop_level"))
|
|
entry_plan.setdefault("tp1", detection.get("target_1"))
|
|
entry_plan.setdefault("risk_reward_ok", True)
|
|
|
|
score = _safe_float(detection.get("score"))
|
|
confidence = min(100.0, max(0.0, score * 8 + (12 if retest_rejected else 0)))
|
|
return StrategySignal(
|
|
strategy_code=BREAKDOWN_RETEST_SHORT_1H_STRATEGY,
|
|
strategy_version=current_strategy_version(),
|
|
symbol=symbol,
|
|
direction="short",
|
|
status=status,
|
|
confidence=confidence,
|
|
score=score,
|
|
trigger={
|
|
"factor_code": "breakdown_retest_1h_short",
|
|
"factor_label": "1H破位反抽做空",
|
|
"breakdown_level": detection.get("breakdown_level"),
|
|
"retest_zone": retest_zone,
|
|
"stop_level": detection.get("stop_level"),
|
|
"target_1": detection.get("target_1"),
|
|
"quality": quality,
|
|
"retest_rejected": retest_rejected,
|
|
"distance_to_retest_zone_pct": round(distance_pct, 4),
|
|
"risk_level": risk_level,
|
|
},
|
|
factor_roles={
|
|
"breakdown_retest_1h_short": TRIGGER,
|
|
"retest_reject_15m_short": ENTRY,
|
|
"market_risk_off_short": CONFIRMATION,
|
|
"false_breakout": RISK,
|
|
"funding_extreme": RISK,
|
|
},
|
|
entry_plan=entry_plan,
|
|
risk_plan={
|
|
"invalid_if": ["重新站回破位区", "反抽放量突破", "BTC/ETH快速转强", "RR不足"],
|
|
"risk_reasons": reasons,
|
|
},
|
|
decision_log=decision_log or {
|
|
"module": BREAKDOWN_RETEST_SHORT_1H_STRATEGY,
|
|
"decision": status,
|
|
"reasons": reasons,
|
|
},
|
|
)
|
|
|
|
|
|
__all__ = ["build_breakdown_retest_short_1h_signal", "detect_breakdown_retest_short_1h"]
|