fa45d8aa5f
- health_checklist.json: 192.168.1.122→node122
- ocr_client.py: docstring IP→node122
- docs/market-data-requirements.md: IP→node122
- 所有API调用通过ProxyHandler({})绕过系统代理
Privoxy对node122:18003返回500,直连正常
282 lines
9.8 KiB
Python
282 lines
9.8 KiB
Python
# -*- coding: utf-8 -*-
|
|
"""Independent risk overlay for shortlisted picks."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from alphasift.models import Pick
|
|
|
|
_DEFAULT_RISK_PROFILE = {
|
|
"chase_change_pct": 8.0,
|
|
"chase_points": 4.0,
|
|
"breakdown_change_pct": -7.0,
|
|
"breakdown_points": 3.5,
|
|
"abnormal_volume_ratio": 6.0,
|
|
"abnormal_volume_ratio_points": 3.0,
|
|
"high_turnover_rate": 15.0,
|
|
"high_turnover_points": 3.0,
|
|
"invalid_pe_points": 3.0,
|
|
"high_pb": 8.0,
|
|
"high_pb_points": 2.0,
|
|
"weak_signal_score": 45.0,
|
|
"weak_signal_points": 2.5,
|
|
"macd_bearish_points": 2.0,
|
|
"rsi_overbought_points": 1.5,
|
|
"low_llm_confidence": 0.35,
|
|
"low_llm_confidence_points": 1.5,
|
|
"llm_risk_points": 1.2,
|
|
"llm_risk_points_cap": 4.0,
|
|
"deep_risk_points": 1.5,
|
|
"deep_risk_points_cap": 4.5,
|
|
}
|
|
_DEFAULT_PORTFOLIO_BUCKETS = {
|
|
"金融": ("券商", "银行", "保险", "金融"),
|
|
"地产链": ("地产", "房地产", "建材", "家居", "物业"),
|
|
"新能源": ("新能源", "光伏", "锂电", "电池", "储能"),
|
|
"AI算力": ("AI算力", "算力", "数据中心", "服务器", "光模块"),
|
|
"消费": ("白酒", "食品", "家电", "零售", "消费"),
|
|
"医药": ("医药", "医疗", "创新药"),
|
|
"半导体": ("半导体", "芯片"),
|
|
}
|
|
|
|
|
|
def apply_risk_overlay(
|
|
picks: list[Pick],
|
|
*,
|
|
max_penalty: float = 12.0,
|
|
veto_high_risk: bool = False,
|
|
profile: dict[str, object] | None = None,
|
|
) -> tuple[list[Pick], list[str]]:
|
|
"""Attach risk flags and subtract a bounded penalty from final_score."""
|
|
if not picks:
|
|
return picks, []
|
|
|
|
max_penalty = max(float(max_penalty), 0.0)
|
|
degradation: list[str] = []
|
|
kept: list[Pick] = []
|
|
risk_profile = _risk_profile(profile)
|
|
|
|
for pick in picks:
|
|
points, flags = assess_pick_risk(pick, profile=risk_profile)
|
|
penalty = min(points, max_penalty)
|
|
pick.risk_penalty = round(penalty, 4)
|
|
pick.risk_score = round(0.0 if max_penalty == 0 else min(points / max_penalty * 100, 100), 4)
|
|
pick.risk_level = _risk_level(points, max_penalty)
|
|
pick.risk_flags = _unique([*pick.risk_flags, *flags])
|
|
pick.final_score = round(float(pick.final_score) - penalty, 4)
|
|
pick.excluded_by_risk = veto_high_risk and pick.risk_level == "high"
|
|
if pick.excluded_by_risk:
|
|
degradation.append(f"Risk veto excluded {pick.code}: {', '.join(pick.risk_flags)}")
|
|
continue
|
|
kept.append(pick)
|
|
|
|
kept.sort(key=lambda item: item.final_score, reverse=True)
|
|
for i, pick in enumerate(kept, start=1):
|
|
pick.rank = i
|
|
return kept, degradation
|
|
|
|
|
|
def apply_portfolio_overlay(
|
|
picks: list[Pick],
|
|
*,
|
|
max_same_sector: int = 1,
|
|
concentration_penalty: float = 4.0,
|
|
profile: dict[str, object] | None = None,
|
|
) -> tuple[list[Pick], list[str]]:
|
|
"""Penalize repeated LLM sectors so Top N is not only one crowded trade."""
|
|
if not picks:
|
|
return picks, []
|
|
|
|
portfolio_profile = profile or {}
|
|
max_same_sector = max(int(portfolio_profile.get("max_same_bucket", max_same_sector)), 1)
|
|
penalty_step = max(float(portfolio_profile.get("concentration_penalty", concentration_penalty)), 0.0)
|
|
if penalty_step == 0:
|
|
return picks, []
|
|
|
|
ordered = sorted(picks, key=lambda item: item.final_score, reverse=True)
|
|
if not any(_canonical_sector(_pick_sector(pick)) for pick in ordered):
|
|
return ordered, []
|
|
|
|
bucket_counts: dict[str, int] = {}
|
|
bucket_penalties: dict[str, list[tuple[str, str, float]]] = {}
|
|
for pick in ordered:
|
|
sector = _canonical_sector(_pick_sector(pick))
|
|
if not sector:
|
|
continue
|
|
bucket = _portfolio_bucket(sector, pick.llm_theme, buckets=portfolio_profile.get("buckets"))
|
|
bucket_counts[bucket] = bucket_counts.get(bucket, 0) + 1
|
|
excess = bucket_counts[bucket] - max_same_sector
|
|
if excess <= 0:
|
|
continue
|
|
|
|
penalty = min(penalty_step * excess, penalty_step * 3)
|
|
flag = f"portfolio_sector_concentration:{bucket}"
|
|
pick.portfolio_penalty = round(float(pick.portfolio_penalty) + penalty, 4)
|
|
pick.portfolio_flags = _unique([*pick.portfolio_flags, flag])
|
|
pick.risk_flags = _unique([*pick.risk_flags, flag])
|
|
pick.final_score = round(float(pick.final_score) - penalty, 4)
|
|
bucket_penalties.setdefault(bucket, []).append((pick.code, sector, penalty))
|
|
|
|
ordered.sort(key=lambda item: item.final_score, reverse=True)
|
|
for i, pick in enumerate(ordered, start=1):
|
|
pick.rank = i
|
|
notes = [
|
|
(
|
|
f"Portfolio concentration bucket={bucket}: "
|
|
f"penalized={len(items)}, "
|
|
f"codes={_format_penalty_codes(items)}"
|
|
)
|
|
for bucket, items in bucket_penalties.items()
|
|
]
|
|
return ordered, notes
|
|
|
|
|
|
def assess_pick_risk(
|
|
pick: Pick,
|
|
*,
|
|
profile: dict[str, float] | None = None,
|
|
) -> tuple[float, list[str]]:
|
|
"""Return risk points and human-readable flags for one pick."""
|
|
profile = _risk_profile(profile)
|
|
points = 0.0
|
|
flags: list[str] = []
|
|
|
|
if pick.change_pct >= profile["chase_change_pct"]:
|
|
points += profile["chase_points"]
|
|
flags.append("single_day_chase_risk")
|
|
elif pick.change_pct <= profile["breakdown_change_pct"]:
|
|
points += profile["breakdown_points"]
|
|
flags.append("single_day_breakdown_risk")
|
|
|
|
if pick.volume_ratio is not None and pick.volume_ratio >= profile["abnormal_volume_ratio"]:
|
|
points += profile["abnormal_volume_ratio_points"]
|
|
flags.append("abnormal_volume_ratio")
|
|
if pick.turnover_rate is not None and pick.turnover_rate >= profile["high_turnover_rate"]:
|
|
points += profile["high_turnover_points"]
|
|
flags.append("high_turnover")
|
|
if pick.pe_ratio is not None and pick.pe_ratio <= 0:
|
|
points += profile["invalid_pe_points"]
|
|
flags.append("negative_or_invalid_pe")
|
|
if pick.pb_ratio is not None and pick.pb_ratio >= profile["high_pb"]:
|
|
points += profile["high_pb_points"]
|
|
flags.append("high_pb")
|
|
if pick.signal_score is not None and pick.signal_score < profile["weak_signal_score"]:
|
|
points += profile["weak_signal_points"]
|
|
flags.append("weak_daily_signal")
|
|
if pick.macd_status == "bearish":
|
|
points += profile["macd_bearish_points"]
|
|
flags.append("macd_bearish")
|
|
if pick.rsi_status == "overbought":
|
|
points += profile["rsi_overbought_points"]
|
|
flags.append("rsi_overbought")
|
|
if pick.llm_confidence is not None and pick.llm_confidence < profile["low_llm_confidence"]:
|
|
points += profile["low_llm_confidence_points"]
|
|
flags.append("low_llm_confidence")
|
|
|
|
llm_risks = [risk for risk in pick.llm_risks if risk]
|
|
if llm_risks:
|
|
points += min(len(llm_risks) * profile["llm_risk_points"], profile["llm_risk_points_cap"])
|
|
flags.extend(llm_risks)
|
|
|
|
if pick.deep_analysis_risk_flags:
|
|
points += min(
|
|
len(pick.deep_analysis_risk_flags) * profile["deep_risk_points"],
|
|
profile["deep_risk_points_cap"],
|
|
)
|
|
flags.extend(pick.deep_analysis_risk_flags)
|
|
|
|
return points, _unique(flags)
|
|
|
|
|
|
def _risk_level(points: float, max_penalty: float) -> str:
|
|
if max_penalty <= 0:
|
|
return "low"
|
|
if points >= max_penalty * 0.66:
|
|
return "high"
|
|
if points >= max_penalty * 0.33:
|
|
return "medium"
|
|
return "low"
|
|
|
|
|
|
def _canonical_sector(label: str) -> str:
|
|
cleaned = str(label or "").strip()[:40]
|
|
if not cleaned:
|
|
return ""
|
|
aliases = {
|
|
"券商": ("券商", "证券"),
|
|
"银行": ("银行",),
|
|
"保险": ("保险",),
|
|
"地产": ("地产", "房地产"),
|
|
"医药": ("医药", "医疗", "创新药"),
|
|
"白酒": ("白酒", "酿酒"),
|
|
"半导体": ("半导体", "芯片"),
|
|
"AI算力": ("AI算力", "算力", "数据中心"),
|
|
"新能源": ("新能源", "光伏", "锂电", "电池"),
|
|
}
|
|
for canonical, needles in aliases.items():
|
|
if any(needle in cleaned for needle in needles):
|
|
return canonical
|
|
return cleaned
|
|
|
|
|
|
def _pick_sector(pick: Pick) -> str:
|
|
return pick.llm_sector or pick.industry
|
|
|
|
|
|
def _risk_profile(profile: dict[str, object] | None) -> dict[str, float]:
|
|
result = dict(_DEFAULT_RISK_PROFILE)
|
|
for key, value in (profile or {}).items():
|
|
if key in result:
|
|
result[key] = float(value)
|
|
return result
|
|
|
|
|
|
def _portfolio_bucket(
|
|
sector: str,
|
|
theme: str = "",
|
|
*,
|
|
buckets: object = None,
|
|
) -> str:
|
|
text = f"{sector} {theme or ''}"
|
|
bucket_map = _portfolio_buckets(buckets)
|
|
for bucket, needles in bucket_map.items():
|
|
if any(needle in text for needle in needles):
|
|
return bucket
|
|
return sector
|
|
|
|
|
|
def _portfolio_buckets(custom_buckets: object = None) -> dict[str, tuple[str, ...]]:
|
|
bucket_map = {key: tuple(value) for key, value in _DEFAULT_PORTFOLIO_BUCKETS.items()}
|
|
if not isinstance(custom_buckets, dict):
|
|
return bucket_map
|
|
for bucket, needles in custom_buckets.items():
|
|
if isinstance(needles, str):
|
|
items = [needles]
|
|
elif isinstance(needles, list):
|
|
items = [str(item) for item in needles]
|
|
else:
|
|
continue
|
|
if items:
|
|
bucket_map[str(bucket)] = tuple(items)
|
|
return bucket_map
|
|
|
|
|
|
def _format_penalty_codes(items: list[tuple[str, str, float]], limit: int = 5) -> str:
|
|
shown = [
|
|
f"{code}:{sector}(-{penalty:.1f})"
|
|
for code, sector, penalty in items[:limit]
|
|
]
|
|
if len(items) > limit:
|
|
shown.append(f"+{len(items) - limit} more")
|
|
return ",".join(shown)
|
|
|
|
|
|
def _unique(items: list[str]) -> list[str]:
|
|
seen = set()
|
|
result = []
|
|
for item in items:
|
|
key = str(item).strip()
|
|
if key and key not in seen:
|
|
seen.add(key)
|
|
result.append(key)
|
|
return result
|