-
Notifications
You must be signed in to change notification settings - Fork 54.4k
Expand file tree
/
Copy pathanalyzer.py
More file actions
2150 lines (1878 loc) · 88.1 KB
/
Copy pathanalyzer.py
File metadata and controls
2150 lines (1878 loc) · 88.1 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
# -*- coding: utf-8 -*-
"""
===================================
A股自选股智能分析系统 - AI分析层
===================================
职责:
1. 封装 LLM 调用逻辑(通过 LiteLLM 统一调用 Gemini/Anthropic/OpenAI 等)
2. 结合技术面和消息面生成分析报告
3. 解析 LLM 响应为结构化 AnalysisResult
"""
import json
import logging
import math
import re
import time
from dataclasses import dataclass
from typing import Optional, Dict, Any, List, Tuple
import litellm
from json_repair import repair_json
from litellm import Router
from src.agent.llm_adapter import get_thinking_extra_body
from src.agent.skills.defaults import CORE_TRADING_SKILL_POLICY_ZH
from src.config import (
Config,
extra_litellm_params,
get_api_keys_for_model,
get_config,
get_configured_llm_models,
resolve_news_window_days,
)
from src.storage import persist_llm_usage
from src.data.stock_mapping import STOCK_NAME_MAP
from src.report_language import (
get_signal_level,
get_no_data_text,
get_placeholder_text,
get_unknown_text,
infer_decision_type_from_advice,
localize_chip_health,
localize_confidence_level,
normalize_report_language,
)
from src.schemas.report_schema import AnalysisReportSchema
from src.market_context import get_market_role, get_market_guidelines
from src.logging_config import is_sensitive_log_preview_enabled
logger = logging.getLogger(__name__)
_LLM_PREVIEW_MAX_CHARS = 240
_LLM_AUTHORIZATION_SAFE_SCHEMES = {
"aws4-hmac-sha256",
"basic",
"bearer",
"digest",
"dpop",
"hoba",
"mutual",
"negotiate",
"ntlm",
"pop",
"signature",
"token",
"vapid",
}
_LLM_SENSITIVE_ASSIGNMENT_VALUE_PATTERN = (
r"[^\s]+(?:\s+(?!(?:[\w.-]+|\"[^\"]+\"|'[^']+')\s*[:=])\S+)*"
)
_LLM_SENSITIVE_FIELD_NAME_PATTERN = (
r"(?:api[_-]?keys?|tokens?|secrets?|passwords?|passwd|passphrase|credentials?|session[_-]?id"
r"|[\w.-]+(?:api[_-]?keys?|tokens?|secrets?|passwords?|passwd|passphrase|credentials?|session[_-]?id|keys?))"
)
def _redact_authorization_preview_value(value: str) -> str:
parts = str(value or "").strip().split(None, 1)
if len(parts) == 2 and parts[0] and parts[0].lower() in _LLM_AUTHORIZATION_SAFE_SCHEMES:
return f"{parts[0]} [REDACTED]"
return "[REDACTED]"
def _replace_quoted_authorization_preview(match) -> str:
return (
f"{match.group(1)}{match.group(2)}{match.group(1)}"
f"{match.group(3)}{match.group(4)}"
f"{_redact_authorization_preview_value(match.group('value'))}"
f"{match.group(4)}"
)
def _replace_authorization_preview(match) -> str:
return f"{match.group(1)}={_redact_authorization_preview_value(match.group('value'))}"
_LLM_RAW_LINE_SENSITIVE_PATTERNS = (
(
re.compile(r"(?im)\b(authorization)\s*[:=]\s*(?P<value>[^\n\r]*)"),
_replace_authorization_preview,
),
)
_LLM_SENSITIVE_PATTERNS = (
(
re.compile(r'(?i)(["\'])(authorization)\1\s*([:=])\s*(["\'])(?P<value>(?:\\.|(?!\4).)*)\4'),
_replace_quoted_authorization_preview,
),
(
re.compile(rf"(?i)\b(authorization)\s*[:=]\s*(?P<value>{_LLM_SENSITIVE_ASSIGNMENT_VALUE_PATTERN})"),
_replace_authorization_preview,
),
(
re.compile(r'(?i)(["\'])(set-cookie|cookie)\1\s*([:=])\s*(["\'])(?:\\.|(?!\4).)*\4'),
r"\1\2\1\3\4[REDACTED]\4",
),
(
re.compile(
r"(?i)\b(set-cookie|cookie)\s*[:=]\s*[^;=\s]+(?:\s*=\s*[^;\s]+)?(?:\s*;\s*[^;\n\r]+(?:\s*=\s*[^;\n\r]+)?)*"
),
r"\1=[REDACTED]",
),
(
re.compile(
rf'(?i)(["\'])({_LLM_SENSITIVE_FIELD_NAME_PATTERN})\1\s*:\s*(["\'])(?:\\.|(?!\3).)*\3'
),
r"\1\2\1:\3[REDACTED]\3",
),
(
re.compile(
rf'(?i)(["\'])({_LLM_SENSITIVE_FIELD_NAME_PATTERN})\1\s*:\s*(?!["\'])(-?[\w.+\-]+)'
),
r"\1\2\1:[REDACTED]",
),
(
re.compile(
rf"(?i)\b({_LLM_SENSITIVE_FIELD_NAME_PATTERN})\b\s*[:=]\s*(['\"])(?:\\.|(?!\2).)*\2"
),
r"\1=\2[REDACTED]\2",
),
(
re.compile(
rf"(?i)\b({_LLM_SENSITIVE_FIELD_NAME_PATTERN})\b\s*[:=]\s*(?!['\"])({_LLM_SENSITIVE_ASSIGNMENT_VALUE_PATTERN})"
),
r"\1=[REDACTED]",
),
(
re.compile(r"(?i)\b[\w.+-]+@[\w.-]+\.[a-z]{2,}\b"),
"[REDACTED_EMAIL]",
),
)
def _should_log_llm_content_preview(config: Optional[Config] = None) -> bool:
"""Allow LLM content preview only under explicit debug switches."""
if is_sensitive_log_preview_enabled():
return True
runtime_config = config if config is not None else get_config()
return bool(
getattr(runtime_config, "debug", False)
or str(getattr(runtime_config, "log_level", "INFO") or "INFO").upper() == "DEBUG"
)
def _sanitize_llm_log_preview(content: str, max_chars: int = _LLM_PREVIEW_MAX_CHARS) -> str:
"""Normalize, redact, and truncate preview text for logs."""
sanitized_raw = str(content or "")
for pattern, replacement in _LLM_RAW_LINE_SENSITIVE_PATTERNS:
sanitized_raw = pattern.sub(replacement, sanitized_raw)
normalized = re.sub(r"\s+", " ", sanitized_raw).strip()
if not normalized:
return "[empty]"
sanitized = normalized
for pattern, replacement in _LLM_SENSITIVE_PATTERNS:
sanitized = pattern.sub(replacement, sanitized)
if len(sanitized) <= max_chars:
return sanitized
return sanitized[:max_chars].rstrip() + "..."
def _build_llm_log_preview(label: str, content: str, max_chars: int = _LLM_PREVIEW_MAX_CHARS) -> str:
"""Build a safe one-line preview entry for debug logs."""
return f"[{label}] len={len(content or '')} preview={_sanitize_llm_log_preview(content, max_chars=max_chars)}"
def check_content_integrity(result: "AnalysisResult") -> Tuple[bool, List[str]]:
"""
Check mandatory fields for report content integrity.
Returns (pass, missing_fields). Module-level for use by pipeline (agent weak mode).
"""
missing: List[str] = []
if result.sentiment_score is None:
missing.append("sentiment_score")
advice = result.operation_advice
if not advice or not isinstance(advice, str) or not advice.strip():
missing.append("operation_advice")
summary = result.analysis_summary
if not summary or not isinstance(summary, str) or not summary.strip():
missing.append("analysis_summary")
dash = result.dashboard if isinstance(result.dashboard, dict) else {}
core = dash.get("core_conclusion")
core = core if isinstance(core, dict) else {}
if not (core.get("one_sentence") or "").strip():
missing.append("dashboard.core_conclusion.one_sentence")
intel = dash.get("intelligence")
intel = intel if isinstance(intel, dict) else None
if intel is None or "risk_alerts" not in intel:
missing.append("dashboard.intelligence.risk_alerts")
if result.decision_type in ("buy", "hold"):
battle = dash.get("battle_plan")
battle = battle if isinstance(battle, dict) else {}
sp = battle.get("sniper_points")
sp = sp if isinstance(sp, dict) else {}
stop_loss = sp.get("stop_loss")
if stop_loss is None or (isinstance(stop_loss, str) and not stop_loss.strip()):
missing.append("dashboard.battle_plan.sniper_points.stop_loss")
return len(missing) == 0, missing
def apply_placeholder_fill(result: "AnalysisResult", missing_fields: List[str]) -> None:
"""Fill missing mandatory fields with placeholders (in-place). Module-level for pipeline."""
placeholder = get_placeholder_text(getattr(result, "report_language", "zh"))
for field in missing_fields:
if field == "sentiment_score":
result.sentiment_score = 50
elif field == "operation_advice":
result.operation_advice = result.operation_advice or placeholder
elif field == "analysis_summary":
result.analysis_summary = result.analysis_summary or placeholder
elif field == "dashboard.core_conclusion.one_sentence":
if not result.dashboard:
result.dashboard = {}
if "core_conclusion" not in result.dashboard:
result.dashboard["core_conclusion"] = {}
result.dashboard["core_conclusion"]["one_sentence"] = (
result.dashboard["core_conclusion"].get("one_sentence") or placeholder
)
elif field == "dashboard.intelligence.risk_alerts":
if not result.dashboard:
result.dashboard = {}
if "intelligence" not in result.dashboard:
result.dashboard["intelligence"] = {}
if "risk_alerts" not in result.dashboard["intelligence"]:
result.dashboard["intelligence"]["risk_alerts"] = []
elif field == "dashboard.battle_plan.sniper_points.stop_loss":
if not result.dashboard:
result.dashboard = {}
if "battle_plan" not in result.dashboard:
result.dashboard["battle_plan"] = {}
if "sniper_points" not in result.dashboard["battle_plan"]:
result.dashboard["battle_plan"]["sniper_points"] = {}
result.dashboard["battle_plan"]["sniper_points"]["stop_loss"] = placeholder
# ---------- chip_structure fallback (Issue #589) ----------
_CHIP_KEYS: tuple = ("profit_ratio", "avg_cost", "concentration", "chip_health")
def _is_value_placeholder(v: Any) -> bool:
"""True if value is empty or placeholder (N/A, 数据缺失, etc.)."""
if v is None:
return True
if isinstance(v, (int, float)) and v == 0:
return True
s = str(v).strip().lower()
return s in ("", "n/a", "na", "数据缺失", "未知", "data unavailable", "unknown", "tbd")
def _safe_float(v: Any, default: float = 0.0) -> float:
"""Safely convert to float; return default on failure. Private helper for chip fill."""
if v is None:
return default
if isinstance(v, (int, float)):
try:
return default if math.isnan(float(v)) else float(v)
except (ValueError, TypeError):
return default
try:
return float(str(v).strip())
except (TypeError, ValueError):
return default
def _derive_chip_health(profit_ratio: float, concentration_90: float, language: str = "zh") -> str:
"""Derive chip_health from profit_ratio and concentration_90."""
if profit_ratio >= 0.9:
return localize_chip_health("警惕", language) # 获利盘极高
if concentration_90 >= 0.25:
return localize_chip_health("警惕", language) # 筹码分散
if concentration_90 < 0.15 and 0.3 <= profit_ratio < 0.9:
return localize_chip_health("健康", language) # 集中且获利比例适中
return localize_chip_health("一般", language)
def _build_chip_structure_from_data(chip_data: Any, language: str = "zh") -> Dict[str, Any]:
"""Build chip_structure dict from ChipDistribution or dict."""
if hasattr(chip_data, "profit_ratio"):
pr = _safe_float(chip_data.profit_ratio)
ac = chip_data.avg_cost
c90 = _safe_float(chip_data.concentration_90)
else:
d = chip_data if isinstance(chip_data, dict) else {}
pr = _safe_float(d.get("profit_ratio"))
ac = d.get("avg_cost")
c90 = _safe_float(d.get("concentration_90"))
chip_health = _derive_chip_health(pr, c90, language=language)
return {
"profit_ratio": f"{pr:.1%}",
"avg_cost": ac if (ac is not None and _safe_float(ac) != 0.0) else "N/A",
"concentration": f"{c90:.2%}",
"chip_health": chip_health,
}
def fill_chip_structure_if_needed(result: "AnalysisResult", chip_data: Any) -> None:
"""When chip_data exists, fill chip_structure placeholder fields from chip_data (in-place)."""
if not result or not chip_data:
return
try:
if not result.dashboard:
result.dashboard = {}
dash = result.dashboard
# Use `or {}` rather than setdefault so that an explicit `null` from LLM is also replaced
dp = dash.get("data_perspective") or {}
dash["data_perspective"] = dp
cs = dp.get("chip_structure") or {}
filled = _build_chip_structure_from_data(
chip_data,
language=getattr(result, "report_language", "zh"),
)
# Start from a copy of cs to preserve any extra keys the LLM may have added
merged = dict(cs)
for k in _CHIP_KEYS:
if _is_value_placeholder(merged.get(k)):
merged[k] = filled[k]
if merged != cs:
dp["chip_structure"] = merged
logger.info("[chip_structure] Filled placeholder chip fields from data source (Issue #589)")
except Exception as e:
logger.warning("[chip_structure] Fill failed, skipping: %s", e)
_PRICE_POS_KEYS = ("ma5", "ma10", "ma20", "bias_ma5", "bias_status", "current_price", "support_level", "resistance_level")
def fill_price_position_if_needed(
result: "AnalysisResult",
trend_result: Any = None,
realtime_quote: Any = None,
) -> None:
"""Fill missing price_position fields from trend_result / realtime data (in-place)."""
if not result:
return
try:
if not result.dashboard:
result.dashboard = {}
dash = result.dashboard
dp = dash.get("data_perspective") or {}
dash["data_perspective"] = dp
pp = dp.get("price_position") or {}
computed: Dict[str, Any] = {}
if trend_result:
tr = trend_result if isinstance(trend_result, dict) else (
trend_result.__dict__ if hasattr(trend_result, "__dict__") else {}
)
computed["ma5"] = tr.get("ma5")
computed["ma10"] = tr.get("ma10")
computed["ma20"] = tr.get("ma20")
computed["bias_ma5"] = tr.get("bias_ma5")
computed["current_price"] = tr.get("current_price")
support_levels = tr.get("support_levels") or []
resistance_levels = tr.get("resistance_levels") or []
if support_levels:
computed["support_level"] = support_levels[0]
if resistance_levels:
computed["resistance_level"] = resistance_levels[0]
if realtime_quote:
rq = realtime_quote if isinstance(realtime_quote, dict) else (
realtime_quote.to_dict() if hasattr(realtime_quote, "to_dict") else {}
)
if _is_value_placeholder(computed.get("current_price")):
computed["current_price"] = rq.get("price")
filled = False
for k in _PRICE_POS_KEYS:
if _is_value_placeholder(pp.get(k)) and not _is_value_placeholder(computed.get(k)):
pp[k] = computed[k]
filled = True
if filled:
dp["price_position"] = pp
logger.info("[price_position] Filled placeholder fields from computed data")
except Exception as e:
logger.warning("[price_position] Fill failed, skipping: %s", e)
def get_stock_name_multi_source(
stock_code: str,
context: Optional[Dict] = None,
data_manager = None
) -> str:
"""
多来源获取股票中文名称
获取策略(按优先级):
1. 从传入的 context 中获取(realtime 数据)
2. 从静态映射表 STOCK_NAME_MAP 获取
3. 从 DataFetcherManager 获取(各数据源)
4. 返回默认名称(股票+代码)
Args:
stock_code: 股票代码
context: 分析上下文(可选)
data_manager: DataFetcherManager 实例(可选)
Returns:
股票中文名称
"""
# 1. 从上下文获取(实时行情数据)
if context:
# 优先从 stock_name 字段获取
if context.get('stock_name'):
name = context['stock_name']
if name and not name.startswith('股票'):
return name
# 其次从 realtime 数据获取
if 'realtime' in context and context['realtime'].get('name'):
return context['realtime']['name']
# 2. 从静态映射表获取
if stock_code in STOCK_NAME_MAP:
return STOCK_NAME_MAP[stock_code]
# 3. 从数据源获取
if data_manager is None:
try:
from data_provider.base import DataFetcherManager
data_manager = DataFetcherManager()
except Exception as e:
logger.debug(f"无法初始化 DataFetcherManager: {e}")
if data_manager:
try:
name = data_manager.get_stock_name(stock_code)
if name:
# 更新缓存
STOCK_NAME_MAP[stock_code] = name
return name
except Exception as e:
logger.debug(f"从数据源获取股票名称失败: {e}")
# 4. 返回默认名称
return f'股票{stock_code}'
@dataclass
class AnalysisResult:
"""
AI 分析结果数据类 - 决策仪表盘版
封装 Gemini 返回的分析结果,包含决策仪表盘和详细分析
"""
code: str
name: str
# ========== 核心指标 ==========
sentiment_score: int # 综合评分 0-100 (>70强烈看多, >60看多, 40-60震荡, <40看空)
trend_prediction: str # 趋势预测:强烈看多/看多/震荡/看空/强烈看空
operation_advice: str # 操作建议:买入/加仓/持有/减仓/卖出/观望
decision_type: str = "hold" # 决策类型:buy/hold/sell(用于统计)
confidence_level: str = "中" # 置信度:高/中/低
report_language: str = "zh" # 报告输出语言:zh/en
# ========== 决策仪表盘 (新增) ==========
dashboard: Optional[Dict[str, Any]] = None # 完整的决策仪表盘数据
# ========== 走势分析 ==========
trend_analysis: str = "" # 走势形态分析(支撑位、压力位、趋势线等)
short_term_outlook: str = "" # 短期展望(1-3日)
medium_term_outlook: str = "" # 中期展望(1-2周)
# ========== 技术面分析 ==========
technical_analysis: str = "" # 技术指标综合分析
ma_analysis: str = "" # 均线分析(多头/空头排列,金叉/死叉等)
volume_analysis: str = "" # 量能分析(放量/缩量,主力动向等)
pattern_analysis: str = "" # K线形态分析
# ========== 基本面分析 ==========
fundamental_analysis: str = "" # 基本面综合分析
sector_position: str = "" # 板块地位和行业趋势
company_highlights: str = "" # 公司亮点/风险点
# ========== 情绪面/消息面分析 ==========
news_summary: str = "" # 近期重要新闻/公告摘要
market_sentiment: str = "" # 市场情绪分析
hot_topics: str = "" # 相关热点话题
# ========== 综合分析 ==========
analysis_summary: str = "" # 综合分析摘要
key_points: str = "" # 核心看点(3-5个要点)
risk_warning: str = "" # 风险提示
buy_reason: str = "" # 买入/卖出理由
# ========== 元数据 ==========
market_snapshot: Optional[Dict[str, Any]] = None # 当日行情快照(展示用)
raw_response: Optional[str] = None # 原始响应(调试用)
search_performed: bool = False # 是否执行了联网搜索
data_sources: str = "" # 数据来源说明
success: bool = True
error_message: Optional[str] = None
# ========== 价格数据(分析时快照)==========
current_price: Optional[float] = None # 分析时的股价
change_pct: Optional[float] = None # 分析时的涨跌幅(%)
# ========== 模型标记(Issue #528)==========
model_used: Optional[str] = None # 分析使用的 LLM 模型(完整名,如 gemini/gemini-2.0-flash)
# ========== 历史对比(Report Engine P0)==========
query_id: Optional[str] = None # 本次分析 query_id,用于历史对比时排除本次记录
def to_dict(self) -> Dict[str, Any]:
"""转换为字典"""
return {
'code': self.code,
'name': self.name,
'sentiment_score': self.sentiment_score,
'trend_prediction': self.trend_prediction,
'operation_advice': self.operation_advice,
'decision_type': self.decision_type,
'confidence_level': self.confidence_level,
'report_language': self.report_language,
'dashboard': self.dashboard, # 决策仪表盘数据
'trend_analysis': self.trend_analysis,
'short_term_outlook': self.short_term_outlook,
'medium_term_outlook': self.medium_term_outlook,
'technical_analysis': self.technical_analysis,
'ma_analysis': self.ma_analysis,
'volume_analysis': self.volume_analysis,
'pattern_analysis': self.pattern_analysis,
'fundamental_analysis': self.fundamental_analysis,
'sector_position': self.sector_position,
'company_highlights': self.company_highlights,
'news_summary': self.news_summary,
'market_sentiment': self.market_sentiment,
'hot_topics': self.hot_topics,
'analysis_summary': self.analysis_summary,
'key_points': self.key_points,
'risk_warning': self.risk_warning,
'buy_reason': self.buy_reason,
'market_snapshot': self.market_snapshot,
'search_performed': self.search_performed,
'success': self.success,
'error_message': self.error_message,
'current_price': self.current_price,
'change_pct': self.change_pct,
'model_used': self.model_used,
}
def get_core_conclusion(self) -> str:
"""获取核心结论(一句话)"""
if self.dashboard and 'core_conclusion' in self.dashboard:
return self.dashboard['core_conclusion'].get('one_sentence', self.analysis_summary)
return self.analysis_summary
def get_position_advice(self, has_position: bool = False) -> str:
"""获取持仓建议"""
if self.dashboard and 'core_conclusion' in self.dashboard:
pos_advice = self.dashboard['core_conclusion'].get('position_advice', {})
if has_position:
return pos_advice.get('has_position', self.operation_advice)
return pos_advice.get('no_position', self.operation_advice)
return self.operation_advice
def get_sniper_points(self) -> Dict[str, str]:
"""获取狙击点位"""
if self.dashboard and 'battle_plan' in self.dashboard:
return self.dashboard['battle_plan'].get('sniper_points', {})
return {}
def get_checklist(self) -> List[str]:
"""获取检查清单"""
if self.dashboard and 'battle_plan' in self.dashboard:
return self.dashboard['battle_plan'].get('action_checklist', [])
return []
def get_risk_alerts(self) -> List[str]:
"""获取风险警报"""
if self.dashboard and 'intelligence' in self.dashboard:
return self.dashboard['intelligence'].get('risk_alerts', [])
return []
def get_emoji(self) -> str:
"""根据操作建议返回对应 emoji"""
_, emoji, _ = get_signal_level(
self.operation_advice,
self.sentiment_score,
self.report_language,
)
return emoji
def get_confidence_stars(self) -> str:
"""返回置信度星级"""
star_map = {
"高": "⭐⭐⭐",
"high": "⭐⭐⭐",
"中": "⭐⭐",
"medium": "⭐⭐",
"低": "⭐",
"low": "⭐",
}
return star_map.get(str(self.confidence_level or "").strip().lower(), "⭐⭐")
class GeminiAnalyzer:
"""
Gemini AI 分析器
职责:
1. 调用 Google Gemini API 进行股票分析
2. 结合预先搜索的新闻和技术面数据生成分析报告
3. 解析 AI 返回的 JSON 格式结果
使用方式:
analyzer = GeminiAnalyzer()
result = analyzer.analyze(context, news_context)
"""
# ========================================
# 系统提示词 - 决策仪表盘 v2.0
# ========================================
# 输出格式升级:从简单信号升级为决策仪表盘
# 核心模块:核心结论 + 数据透视 + 舆情情报 + 作战计划
# ========================================
LEGACY_DEFAULT_SYSTEM_PROMPT = """你是一位专注于趋势交易的{market_placeholder}投资分析师,负责生成专业的【决策仪表盘】分析报告。
{guidelines_placeholder}
""" + CORE_TRADING_SKILL_POLICY_ZH + """
## 输出格式:决策仪表盘 JSON
请严格按照以下 JSON 格式输出,这是一个完整的【决策仪表盘】:
```json
{
"stock_name": "股票中文名称",
"sentiment_score": 0-100整数,
"trend_prediction": "强烈看多/看多/震荡/看空/强烈看空",
"operation_advice": "买入/加仓/持有/减仓/卖出/观望",
"decision_type": "buy/hold/sell",
"confidence_level": "高/中/低",
"dashboard": {
"core_conclusion": {
"one_sentence": "一句话核心结论(30字以内,直接告诉用户做什么)",
"signal_type": "🟢买入信号/🟡持有观望/🔴卖出信号/⚠️风险警告",
"time_sensitivity": "立即行动/今日内/本周内/不急",
"position_advice": {
"no_position": "空仓者建议:具体操作指引",
"has_position": "持仓者建议:具体操作指引"
}
},
"data_perspective": {
"trend_status": {
"ma_alignment": "均线排列状态描述",
"is_bullish": true/false,
"trend_score": 0-100
},
"price_position": {
"current_price": 当前价格数值,
"ma5": MA5数值,
"ma10": MA10数值,
"ma20": MA20数值,
"bias_ma5": 乖离率百分比数值,
"bias_status": "安全/警戒/危险",
"support_level": 支撑位价格,
"resistance_level": 压力位价格
},
"volume_analysis": {
"volume_ratio": 量比数值,
"volume_status": "放量/缩量/平量",
"turnover_rate": 换手率百分比,
"volume_meaning": "量能含义解读(如:缩量回调表示抛压减轻)"
},
"chip_structure": {
"profit_ratio": 获利比例,
"avg_cost": 平均成本,
"concentration": 筹码集中度,
"chip_health": "健康/一般/警惕"
}
},
"intelligence": {
"latest_news": "【最新消息】近期重要新闻摘要",
"risk_alerts": ["风险点1:具体描述", "风险点2:具体描述"],
"positive_catalysts": ["利好1:具体描述", "利好2:具体描述"],
"earnings_outlook": "业绩预期分析(基于年报预告、业绩快报等)",
"sentiment_summary": "舆情情绪一句话总结"
},
"battle_plan": {
"sniper_points": {
"ideal_buy": "理想买入点:XX元(在MA5附近)",
"secondary_buy": "次优买入点:XX元(在MA10附近)",
"stop_loss": "止损位:XX元(跌破MA20或X%)",
"take_profit": "目标位:XX元(前高/整数关口)"
},
"position_strategy": {
"suggested_position": "建议仓位:X成",
"entry_plan": "分批建仓策略描述",
"risk_control": "风控策略描述"
},
"action_checklist": [
"✅/⚠️/❌ 检查项1:多头排列",
"✅/⚠️/❌ 检查项2:乖离率合理(强势趋势可放宽)",
"✅/⚠️/❌ 检查项3:量能配合",
"✅/⚠️/❌ 检查项4:无重大利空",
"✅/⚠️/❌ 检查项5:筹码健康",
"✅/⚠️/❌ 检查项6:PE估值合理"
]
}
},
"analysis_summary": "100字综合分析摘要",
"key_points": "3-5个核心看点,逗号分隔",
"risk_warning": "风险提示",
"buy_reason": "操作理由,引用交易理念",
"trend_analysis": "走势形态分析",
"short_term_outlook": "短期1-3日展望",
"medium_term_outlook": "中期1-2周展望",
"technical_analysis": "技术面综合分析",
"ma_analysis": "均线系统分析",
"volume_analysis": "量能分析",
"pattern_analysis": "K线形态分析",
"fundamental_analysis": "基本面分析",
"sector_position": "板块行业分析",
"company_highlights": "公司亮点/风险",
"news_summary": "新闻摘要",
"market_sentiment": "市场情绪",
"hot_topics": "相关热点",
"search_performed": true/false,
"data_sources": "数据来源说明"
}
```
## 评分标准
### 强烈买入(80-100分):
- ✅ 多头排列:MA5 > MA10 > MA20
- ✅ 低乖离率:<2%,最佳买点
- ✅ 缩量回调或放量突破
- ✅ 筹码集中健康
- ✅ 消息面有利好催化
### 买入(60-79分):
- ✅ 多头排列或弱势多头
- ✅ 乖离率 <5%
- ✅ 量能正常
- ⚪ 允许一项次要条件不满足
### 观望(40-59分):
- ⚠️ 乖离率 >5%(追高风险)
- ⚠️ 均线缠绕趋势不明
- ⚠️ 有风险事件
### 卖出/减仓(0-39分):
- ❌ 空头排列
- ❌ 跌破MA20
- ❌ 放量下跌
- ❌ 重大利空
## 决策仪表盘核心原则
1. **核心结论先行**:一句话说清该买该卖
2. **分持仓建议**:空仓者和持仓者给不同建议
3. **精确狙击点**:必须给出具体价格,不说模糊的话
4. **检查清单可视化**:用 ✅⚠️❌ 明确显示每项检查结果
5. **风险优先级**:舆情中的风险点要醒目标出"""
SYSTEM_PROMPT = """你是一位{market_placeholder}投资分析师,负责生成专业的【决策仪表盘】分析报告。
{guidelines_placeholder}
{default_skill_policy_section}
{skills_section}
## 输出格式:决策仪表盘 JSON
请严格按照以下 JSON 格式输出,这是一个完整的【决策仪表盘】:
```json
{
"stock_name": "股票中文名称",
"sentiment_score": 0-100整数,
"trend_prediction": "强烈看多/看多/震荡/看空/强烈看空",
"operation_advice": "买入/加仓/持有/减仓/卖出/观望",
"decision_type": "buy/hold/sell",
"confidence_level": "高/中/低",
"dashboard": {
"core_conclusion": {
"one_sentence": "一句话核心结论(30字以内,直接告诉用户做什么)",
"signal_type": "🟢买入信号/🟡持有观望/🔴卖出信号/⚠️风险警告",
"time_sensitivity": "立即行动/今日内/本周内/不急",
"position_advice": {
"no_position": "空仓者建议:具体操作指引",
"has_position": "持仓者建议:具体操作指引"
}
},
"data_perspective": {
"trend_status": {
"ma_alignment": "均线排列状态描述",
"is_bullish": true/false,
"trend_score": 0-100
},
"price_position": {
"current_price": 当前价格数值,
"ma5": MA5数值,
"ma10": MA10数值,
"ma20": MA20数值,
"bias_ma5": 乖离率百分比数值,
"bias_status": "安全/警戒/危险",
"support_level": 支撑位价格,
"resistance_level": 压力位价格
},
"volume_analysis": {
"volume_ratio": 量比数值,
"volume_status": "放量/缩量/平量",
"turnover_rate": 换手率百分比,
"volume_meaning": "量能含义解读(如:缩量回调表示抛压减轻)"
},
"chip_structure": {
"profit_ratio": 获利比例,
"avg_cost": 平均成本,
"concentration": 筹码集中度,
"chip_health": "健康/一般/警惕"
}
},
"intelligence": {
"latest_news": "【最新消息】近期重要新闻摘要",
"risk_alerts": ["风险点1:具体描述", "风险点2:具体描述"],
"positive_catalysts": ["利好1:具体描述", "利好2:具体描述"],
"earnings_outlook": "业绩预期分析(基于年报预告、业绩快报等)",
"sentiment_summary": "舆情情绪一句话总结"
},
"battle_plan": {
"sniper_points": {
"ideal_buy": "理想入场位:XX元(满足主要技能触发条件)",
"secondary_buy": "次优入场位:XX元(更保守或确认后执行)",
"stop_loss": "止损位:XX元(失效条件或X%风险)",
"take_profit": "目标位:XX元(按阻力位/风险回报比制定)"
},
"position_strategy": {
"suggested_position": "建议仓位:X成",
"entry_plan": "分批建仓策略描述",
"risk_control": "风控策略描述"
},
"action_checklist": [
"✅/⚠️/❌ 检查项1:当前结构是否满足激活技能条件",
"✅/⚠️/❌ 检查项2:入场位置与风险回报是否合理",
"✅/⚠️/❌ 检查项3:量价/波动/筹码是否支持判断",
"✅/⚠️/❌ 检查项4:无重大利空",
"✅/⚠️/❌ 检查项5:仓位与止损计划明确",
"✅/⚠️/❌ 检查项6:估值/业绩/催化与结论匹配"
]
}
},
"analysis_summary": "100字综合分析摘要",
"key_points": "3-5个核心看点,逗号分隔",
"risk_warning": "风险提示",
"buy_reason": "操作理由,引用激活技能或风险框架",
"trend_analysis": "走势形态分析",
"short_term_outlook": "短期1-3日展望",
"medium_term_outlook": "中期1-2周展望",
"technical_analysis": "技术面综合分析",
"ma_analysis": "均线系统分析",
"volume_analysis": "量能分析",
"pattern_analysis": "K线形态分析",
"fundamental_analysis": "基本面分析",
"sector_position": "板块行业分析",
"company_highlights": "公司亮点/风险",
"news_summary": "新闻摘要",
"market_sentiment": "市场情绪",
"hot_topics": "相关热点",
"search_performed": true/false,
"data_sources": "数据来源说明"
}
```
## 评分标准
### 强烈买入(80-100分):
- ✅ 多个激活技能同时支持积极结论
- ✅ 上行空间、触发条件与风险回报清晰
- ✅ 关键风险已排查,仓位与止损计划明确
- ✅ 重要数据和情报结论彼此一致
### 买入(60-79分):
- ✅ 主信号偏积极,但仍有少量待确认项
- ✅ 允许存在可控风险或次优入场点
- ✅ 需要在报告中明确补充观察条件
### 观望(40-59分):
- ⚠️ 信号分歧较大,或缺乏足够确认
- ⚠️ 风险与机会大致均衡
- ⚠️ 更适合等待触发条件或回避不确定性
### 卖出/减仓(0-39分):
- ❌ 主要结论转弱,风险明显高于收益
- ❌ 触发了止损/失效条件或重大利空
- ❌ 现有仓位更需要保护而不是进攻
## 决策仪表盘核心原则
1. **核心结论先行**:一句话说清该买该卖
2. **分持仓建议**:空仓者和持仓者给不同建议
3. **精确狙击点**:必须给出具体价格,不说模糊的话
4. **检查清单可视化**:用 ✅⚠️❌ 明确显示每项检查结果
5. **风险优先级**:舆情中的风险点要醒目标出"""
TEXT_SYSTEM_PROMPT = """你是一位专业的股票分析助手。
- 回答必须基于用户提供的数据与上下文
- 若信息不足,要明确指出不确定性
- 不要编造价格、财报或新闻事实
"""
def __init__(
self,
api_key: Optional[str] = None,
*,
config: Optional[Config] = None,
skills: Optional[List[str]] = None,
skill_instructions: Optional[str] = None,
default_skill_policy: Optional[str] = None,
use_legacy_default_prompt: Optional[bool] = None,
):
"""Initialize LLM Analyzer via LiteLLM.
Args:
api_key: Ignored (kept for backward compatibility). Keys are loaded from config.
"""
self._config_override = config
self._requested_skills = list(skills) if skills is not None else None
self._skill_instructions_override = skill_instructions
self._default_skill_policy_override = default_skill_policy
self._use_legacy_default_prompt_override = use_legacy_default_prompt
self._resolved_prompt_state: Optional[Dict[str, Any]] = None
self._router = None
self._litellm_available = False
self._init_litellm()
if not self._litellm_available:
logger.warning("No LLM configured (LITELLM_MODEL / API keys), AI analysis will be unavailable")
def _get_runtime_config(self) -> Config:
"""Return the runtime config, honoring injected overrides for tests/pipeline."""
return getattr(self, "_config_override", None) or get_config()
def _get_skill_prompt_sections(self) -> tuple[str, str, bool]:
"""Resolve skill instructions + default baseline + prompt mode."""
skill_instructions = getattr(self, "_skill_instructions_override", None)
default_skill_policy = getattr(self, "_default_skill_policy_override", None)
use_legacy_default_prompt = getattr(self, "_use_legacy_default_prompt_override", None)
if skill_instructions is not None and default_skill_policy is not None:
return (
skill_instructions,
default_skill_policy,
bool(use_legacy_default_prompt) if use_legacy_default_prompt is not None else False,
)
resolved_state = getattr(self, "_resolved_prompt_state", None)
if resolved_state is None:
from src.agent.factory import resolve_skill_prompt_state
prompt_state = resolve_skill_prompt_state(
self._get_runtime_config(),
skills=getattr(self, "_requested_skills", None),
)
resolved_state = {
"skill_instructions": prompt_state.skill_instructions,
"default_skill_policy": prompt_state.default_skill_policy,
"use_legacy_default_prompt": bool(getattr(prompt_state, "use_legacy_default_prompt", False)),
}
self._resolved_prompt_state = resolved_state