2026-04-28 17:13:45 +08:00
from __future__ import annotations
import re
from typing import Any , Dict , Optional
2026-05-25 23:03:55 +08:00
from web.core import _sf as _safe_float
2026-04-28 17:13:45 +08:00
from web.scan_terminal_filters import safe_int as _safe_int
def _target_range_from_row ( row : Dict [ str , Any ]) -> tuple [ Optional [ float ], Optional [ float ]]:
lower = _safe_float ( row . get ( "target_lower" ))
upper = _safe_float ( row . get ( "target_upper" ))
if lower is not None or upper is not None :
return lower , upper
threshold = _safe_float ( row . get ( "target_threshold" ))
target_value = _safe_float ( row . get ( "target_value" ))
raw_label = str ( row . get ( "target_label" ) or row . get ( "action" ) or "" )
numbers = [ float ( match . group ( 0 )) for match in re . finditer ( r "-?\d+(?:\.\d+)?" , raw_label )]
if len ( numbers ) >= 2 :
return min ( numbers [ 0 ], numbers [ 1 ]), max ( numbers [ 0 ], numbers [ 1 ])
value = threshold if threshold is not None else target_value if target_value is not None else ( numbers [ 0 ] if numbers else None )
if value is None :
return None , None
if re . search ( r "(\+|above|higher|or\s+higher|>=|≥|以上)" , raw_label , re . I ):
return value , None
if re . search ( r "(below|or\s+below|<=|≤|以下)" , raw_label , re . I ):
return None , value
return value , value
def _metar_gate_for_row ( row : Dict [ str , Any ]) -> Optional [ Dict [ str , Any ]]:
context = row . get ( "metar_context" ) if isinstance ( row . get ( "metar_context" ), dict ) else {}
side = str ( row . get ( "side" ) or "" ) . strip () . lower ()
if side not in { "yes" , "no" }:
return None
obs_count = _safe_int ( context . get ( "obs_count" ), 0 )
if obs_count <= 0 or context . get ( "stale_for_today" ):
return {
"decision" : "downgrade" ,
"reason_zh" : "V4 未拿到同日 METAR 实测,不能只凭 edge/Kelly 给出交易。" ,
"reason_en" : "V4 has no same-day METAR observations, so edge/Kelly alone cannot drive a trade." ,
}
lower , upper = _target_range_from_row ( row )
max_temp = _safe_float ( context . get ( "max_temp" ))
last_temp = _safe_float ( context . get ( "last_temp" ))
trend_delta = _safe_float ( context . get ( "trend_delta" ))
if max_temp is None or ( lower is None and upper is None ):
return None
unit = str ( row . get ( "target_unit" ) or row . get ( "temp_symbol" ) or "" )
epsilon = 0.7 if "F" in unit . upper () else 0.4
phase = str ( row . get ( "window_phase" ) or "" ) . lower ()
remaining = _safe_float ( row . get ( "remaining_window_minutes" ))
minutes_until_peak_start = _safe_float ( row . get ( "minutes_until_peak_start" ))
is_late = phase in { "active_peak" , "post_peak" } or ( remaining is not None and remaining <= 180 )
is_before_peak = phase in { "early_today" , "setup_today" , "tomorrow" , "week_ahead" } or (
minutes_until_peak_start is not None and minutes_until_peak_start > 0
)
is_falling = trend_delta is not None and trend_delta <= - epsilon
is_not_rising = trend_delta is not None and trend_delta <= epsilon
above_upper = upper is not None and max_temp > upper + epsilon
below_lower = lower is not None and max_temp < lower - epsilon
inside_bucket = (
( lower is None or max_temp >= lower - epsilon )
and ( upper is None or max_temp <= upper + epsilon )
)
if side == "no" :
if above_upper :
return {
"decision" : "approve" ,
"reason_zh" : "METAR 实测最高已越过目标桶上沿,V4 确认 BUY NO 有实测支撑。" ,
"reason_en" : "METAR max has already moved above the bucket, so V4 confirms BUY NO has observation support." ,
}
if below_lower and ( is_late or is_falling or is_not_rising ):
if is_before_peak and not is_late :
return {
"decision" : "watchlist" ,
"reason_zh" : "峰值窗口尚未到来,METAR 暂未触达不能直接确认 BUY NO,V4 先列观察。" ,
"reason_en" : "The peak window has not arrived, so a still-low METAR path cannot confirm BUY NO yet; V4 keeps it on watch." ,
}
return {
"decision" : "approve" ,
"reason_zh" : "METAR 最高仍低于目标桶且近期走势不强,V4 确认 BUY NO 优先。" ,
"reason_en" : "METAR max remains below the bucket and recent observations are not strengthening, so V4 favors BUY NO." ,
}
if inside_bucket and is_late and is_not_rising :
return {
"decision" : "downgrade" ,
"reason_zh" : "METAR 最高仍贴近目标桶,V4 不允许只因 edge 高就直接交易 NO。" ,
"reason_en" : "METAR max is still close to the target bucket, so V4 will not trade NO on edge alone." ,
}
else :
if above_upper :
return {
"decision" : "veto" ,
"reason_zh" : "METAR 实测最高已越过目标桶上沿,V4 排除该 BUY YES。" ,
"reason_en" : "METAR max has already exceeded the bucket, so V4 vetoes this BUY YES." ,
}
if below_lower and ( is_late or is_falling or is_not_rising ):
if is_before_peak and not is_late :
return {
"decision" : "watchlist" ,
"reason_zh" : "峰值窗口尚未到来,METAR 未触达目标桶只能说明仍需等待峰值验证,V4 暂列观察。" ,
"reason_en" : "The peak window has not arrived, so METAR not reaching the bucket only means the setup still needs peak-window confirmation; V4 keeps it on watch." ,
}
return {
"decision" : "downgrade" ,
"reason_zh" : "METAR 最高仍未触达目标桶且走势不强,V4 将 BUY YES 降级观察。" ,
"reason_en" : "METAR max has not reached the bucket and recent observations are weak, so V4 downgrades BUY YES." ,
}
if inside_bucket :
return {
"decision" : "approve" ,
"reason_zh" : "METAR 实测最高已落入目标桶,V4 认为 BUY YES 有实测依据,但仍需防止继续升穿上沿。" ,
"reason_en" : "METAR max is inside the target bucket, so V4 sees observation support for BUY YES while monitoring an overshoot." ,
}
if last_temp is not None and trend_delta is not None :
direction = "走弱" if trend_delta < - epsilon else "走强" if trend_delta > epsilon else "横盘"
return {
"decision" : "watchlist" ,
"reason_zh" : f "METAR 最新 { last_temp : .1f } ,近期 { direction } ,V4 暂不把该合约升级为最终交易。" ,
"reason_en" : f "Latest METAR is { last_temp : .1f } with a recent { 'downtrend' if trend_delta < - epsilon else 'uptrend' if trend_delta > epsilon else 'flat trend' } , so V4 keeps this as watchlist." ,
}
return None
def _apply_metar_gate_to_row ( row : Dict [ str , Any ]) -> None :
gate = _metar_gate_for_row ( row )
if not gate :
return
decision = str ( gate . get ( "decision" ) or "" ) . lower ()
row [ "v4_metar_decision" ] = decision
row [ "v4_metar_reason_zh" ] = gate . get ( "reason_zh" )
row [ "v4_metar_reason_en" ] = gate . get ( "reason_en" )
current_decision = str ( row . get ( "ai_decision" ) or "" ) . lower ()
hard_decisions = { "veto" , "downgrade" }
if decision == "veto" :
row [ "ai_decision" ] = "veto"
row . pop ( "ai_rank" , None )
elif decision == "downgrade" and current_decision != "veto" :
row [ "ai_decision" ] = "downgrade"
row . pop ( "ai_rank" , None )
elif decision == "approve" and current_decision not in hard_decisions :
row [ "ai_decision" ] = "approve"
elif decision == "watchlist" and current_decision not in { "approve" , "veto" , "downgrade" }:
row [ "ai_decision" ] = "watchlist"
if decision in { "approve" , "veto" , "downgrade" }:
row [ "ai_reason_zh" ] = gate . get ( "reason_zh" ) or row . get ( "ai_reason_zh" )
row [ "ai_reason_en" ] = gate . get ( "reason_en" ) or row . get ( "ai_reason_en" )