200 lines
8.1 KiB
Python
200 lines
8.1 KiB
Python
"""Repair recommendations whose strategy identity or evidence conflicts with trade side."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
from datetime import datetime
|
|
|
|
from app.core.signal_direction import excluded_factor_delta, sanitize_factor_breakdown_for_side, sanitize_signals_for_side
|
|
from app.core.signal_taxonomy import signal_codes, signal_labels
|
|
from app.core.strategy_registry import BREAKDOWN_RETEST_SHORT_1H_STRATEGY, MAIN_COMPOSITE_STRATEGY, is_strategy_allowed_for_side, strategy_label
|
|
from app.core.trade_direction import trade_side_from_payload
|
|
from app.db.postgres_connection import connect
|
|
|
|
|
|
def _loads(value) -> dict:
|
|
if isinstance(value, dict):
|
|
return dict(value)
|
|
if not value:
|
|
return {}
|
|
try:
|
|
parsed = json.loads(value)
|
|
return parsed if isinstance(parsed, dict) else {}
|
|
except Exception:
|
|
return {}
|
|
|
|
|
|
def _loads_list(value) -> list:
|
|
if isinstance(value, list):
|
|
return list(value)
|
|
if not value:
|
|
return []
|
|
try:
|
|
parsed = json.loads(value)
|
|
return parsed if isinstance(parsed, list) else []
|
|
except Exception:
|
|
return [str(value)]
|
|
|
|
|
|
def _dumps(value: dict) -> str:
|
|
return json.dumps(value or {}, ensure_ascii=False, default=str)
|
|
|
|
|
|
def _has_short_breakdown_context(*payloads: dict) -> bool:
|
|
for payload in payloads:
|
|
if not isinstance(payload, dict):
|
|
continue
|
|
short_ctx = payload.get("short_breakdown_retest_1h")
|
|
if isinstance(short_ctx, dict) and short_ctx.get("detected"):
|
|
return True
|
|
nested = payload.get("market_context")
|
|
if isinstance(nested, dict):
|
|
short_ctx = nested.get("short_breakdown_retest_1h")
|
|
if isinstance(short_ctx, dict) and short_ctx.get("detected"):
|
|
return True
|
|
return False
|
|
|
|
|
|
def repair_strategy_direction_mismatches(limit: int = 500, dry_run: bool = False) -> dict:
|
|
limit = max(1, min(int(limit or 500), 5000))
|
|
conn = connect()
|
|
rows = conn.execute(
|
|
"""
|
|
SELECT id, symbol, direction, strategy_code, signals, rec_score,
|
|
entry_plan_json, market_context_json, strategy_snapshot_json, factor_roles_json
|
|
FROM recommendation
|
|
WHERE strategy_code IS NOT NULL AND strategy_code != ''
|
|
ORDER BY id DESC
|
|
LIMIT %s
|
|
""",
|
|
(limit,),
|
|
).fetchall()
|
|
|
|
repaired = []
|
|
scanned = 0
|
|
now = datetime.now().isoformat()
|
|
try:
|
|
for row in rows:
|
|
scanned += 1
|
|
item = dict(row)
|
|
entry_plan = _loads(item.get("entry_plan_json"))
|
|
market_context = _loads(item.get("market_context_json"))
|
|
snapshot = _loads(item.get("strategy_snapshot_json"))
|
|
factor_roles = _loads(item.get("factor_roles_json"))
|
|
side = trade_side_from_payload(entry_plan, snapshot, item.get("direction"))
|
|
old_code = str(item.get("strategy_code") or "").strip()
|
|
new_code = old_code
|
|
reasons: list[str] = []
|
|
|
|
if not is_strategy_allowed_for_side(old_code, side):
|
|
if side == "short" and _has_short_breakdown_context(entry_plan, market_context, snapshot):
|
|
new_code = BREAKDOWN_RETEST_SHORT_1H_STRATEGY
|
|
reasons.append("short_breakdown_context")
|
|
else:
|
|
new_code = MAIN_COMPOSITE_STRATEGY
|
|
reasons.append("fallback_composite_direction_mismatch")
|
|
|
|
signals = _loads_list(item.get("signals"))
|
|
removed_signals: list[str] = []
|
|
removed_factors: list[dict] = []
|
|
score_delta_to_remove = 0.0
|
|
if side == "short":
|
|
clean_signals, removed_signals = sanitize_signals_for_side(signals, "short")
|
|
breakdown = _loads(entry_plan.get("factor_score_breakdown")) or _loads(market_context.get("factor_score_breakdown"))
|
|
score_delta_to_remove = excluded_factor_delta(breakdown.get("items") or [], "short")
|
|
clean_breakdown, removed_factors = sanitize_factor_breakdown_for_side(breakdown, "short")
|
|
if removed_signals or removed_factors:
|
|
reasons.append("short_direction_signal_cleanup")
|
|
signals = clean_signals or signals
|
|
entry_plan["factor_score_breakdown"] = clean_breakdown
|
|
market_context["factor_score_breakdown"] = clean_breakdown
|
|
market_context["direction_conflict_filter"] = {
|
|
"side": side,
|
|
"removed_signals": removed_signals,
|
|
"removed_factor_codes": [str(x.get("factor_code") or "") for x in removed_factors],
|
|
}
|
|
decision_log = market_context.get("decision_log") if isinstance(market_context.get("decision_log"), dict) else {}
|
|
risk_flags = list(decision_log.get("risk_flags") or [])
|
|
for sig in removed_signals[:3]:
|
|
flag = f"direction_conflict:{sig}"
|
|
if flag not in risk_flags:
|
|
risk_flags.append(flag)
|
|
decision_log["risk_flags"] = risk_flags
|
|
market_context["decision_log"] = decision_log
|
|
entry_plan["decision_log"] = decision_log
|
|
|
|
if not reasons:
|
|
continue
|
|
|
|
entry_plan["side"] = side
|
|
entry_plan["strategy_code"] = new_code
|
|
entry_plan["strategy_direction_repair"] = {
|
|
"old_strategy_code": old_code,
|
|
"new_strategy_code": new_code,
|
|
"side": side,
|
|
"reason": ",".join(reasons),
|
|
"repaired_at": now,
|
|
}
|
|
snapshot["strategy_code"] = new_code
|
|
snapshot["strategy_name"] = strategy_label(new_code)
|
|
snapshot["direction"] = side
|
|
snapshot["entry_plan"] = {**entry_plan, **(_loads(snapshot.get("entry_plan")) if isinstance(snapshot.get("entry_plan"), (str, dict)) else {})}
|
|
|
|
try:
|
|
rec_score = max(0, round(float(item.get("rec_score") or 0) - (score_delta_to_remove * 100.0 / 30.0)))
|
|
except Exception:
|
|
rec_score = item.get("rec_score") or 0
|
|
labels = signal_labels(signals)
|
|
codes = signal_codes(labels)
|
|
repaired.append(
|
|
{
|
|
"id": item["id"],
|
|
"symbol": item["symbol"],
|
|
"side": side,
|
|
"old_strategy_code": old_code,
|
|
"new_strategy_code": new_code,
|
|
"reason": ",".join(reasons),
|
|
"removed_signals": removed_signals,
|
|
"removed_factor_codes": [str(x.get("factor_code") or "") for x in removed_factors],
|
|
}
|
|
)
|
|
if not dry_run:
|
|
conn.execute(
|
|
"""
|
|
UPDATE recommendation
|
|
SET strategy_code=%s,
|
|
rec_score=%s,
|
|
signals=%s,
|
|
signal_codes_json=%s,
|
|
signal_labels_json=%s,
|
|
entry_plan_json=%s,
|
|
market_context_json=%s,
|
|
strategy_snapshot_json=%s,
|
|
factor_roles_json=%s
|
|
WHERE id=%s
|
|
""",
|
|
(
|
|
new_code,
|
|
rec_score,
|
|
json.dumps(labels, ensure_ascii=False),
|
|
json.dumps(codes, ensure_ascii=False),
|
|
json.dumps(labels, ensure_ascii=False),
|
|
_dumps(entry_plan),
|
|
_dumps(market_context),
|
|
_dumps(snapshot),
|
|
_dumps(factor_roles),
|
|
item["id"],
|
|
),
|
|
)
|
|
if dry_run:
|
|
conn.rollback()
|
|
else:
|
|
conn.commit()
|
|
except Exception:
|
|
conn.rollback()
|
|
raise
|
|
finally:
|
|
conn.close()
|
|
|
|
return {"scanned": scanned, "repaired_count": len(repaired), "dry_run": dry_run, "items": repaired}
|