dsa-server 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (143) hide show
  1. __init__.py +0 -0
  2. agent/__init__.py +53 -0
  3. agent/agents/__init__.py +23 -0
  4. agent/agents/base_agent.py +286 -0
  5. agent/agents/decision_agent.py +232 -0
  6. agent/agents/intel_agent.py +118 -0
  7. agent/agents/portfolio_agent.py +154 -0
  8. agent/agents/risk_agent.py +128 -0
  9. agent/agents/technical_agent.py +103 -0
  10. agent/chat_context.py +483 -0
  11. agent/conversation.py +113 -0
  12. agent/events.py +563 -0
  13. agent/executor.py +819 -0
  14. agent/factory.py +397 -0
  15. agent/llm_adapter.py +829 -0
  16. agent/memory.py +312 -0
  17. agent/orchestrator.py +1614 -0
  18. agent/protocols.py +236 -0
  19. agent/provider_trace.py +267 -0
  20. agent/research.py +483 -0
  21. agent/runner.py +839 -0
  22. agent/skills/__init__.py +62 -0
  23. agent/skills/aggregator.py +160 -0
  24. agent/skills/base.py +481 -0
  25. agent/skills/defaults.py +316 -0
  26. agent/skills/router.py +164 -0
  27. agent/skills/skill_agent.py +118 -0
  28. agent/stock_scope.py +244 -0
  29. agent/strategies/__init__.py +19 -0
  30. agent/strategies/aggregator.py +5 -0
  31. agent/strategies/router.py +5 -0
  32. agent/strategies/strategy_agent.py +5 -0
  33. agent/tools/__init__.py +11 -0
  34. agent/tools/analysis_tools.py +520 -0
  35. agent/tools/backtest_tools.py +245 -0
  36. agent/tools/data_tools.py +694 -0
  37. agent/tools/market_tools.py +108 -0
  38. agent/tools/pool_tools.py +202 -0
  39. agent/tools/registry.py +266 -0
  40. agent/tools/search_tools.py +211 -0
  41. analysis_context_pack_overview.py +302 -0
  42. analysis_context_pack_prompt.py +519 -0
  43. analyzer.py +3828 -0
  44. auth.py +500 -0
  45. config.py +2842 -0
  46. core/backtest_engine.py +683 -0
  47. core/config_manager.py +214 -0
  48. core/config_registry.py +4379 -0
  49. core/market_profile.py +76 -0
  50. core/market_review.py +466 -0
  51. core/market_review_lock.py +227 -0
  52. core/market_review_runtime.py +85 -0
  53. core/market_strategy.py +172 -0
  54. core/pipeline.py +2536 -0
  55. core/trading_calendar.py +556 -0
  56. data/__init__.py +8 -0
  57. data/stock_index_loader.py +238 -0
  58. data/stock_mapping.py +139 -0
  59. dsa_server-0.1.0.dist-info/METADATA +243 -0
  60. dsa_server-0.1.0.dist-info/RECORD +143 -0
  61. dsa_server-0.1.0.dist-info/WHEEL +5 -0
  62. dsa_server-0.1.0.dist-info/entry_points.txt +3 -0
  63. dsa_server-0.1.0.dist-info/licenses/LICENSE +21 -0
  64. dsa_server-0.1.0.dist-info/top_level.txt +30 -0
  65. embedding_service.py +124 -0
  66. enums.py +50 -0
  67. feishu_doc.py +165 -0
  68. formatters.py +1077 -0
  69. llm/__init__.py +2 -0
  70. llm/errors.py +151 -0
  71. llm/generation_params.py +439 -0
  72. logging_config.py +197 -0
  73. market_analyzer.py +1487 -0
  74. market_context.py +124 -0
  75. market_phase_prompt.py +215 -0
  76. market_phase_summary.py +264 -0
  77. patches/__init__.py +0 -0
  78. patches/eastmoney_patch.py +182 -0
  79. phase_decision_guardrail.py +377 -0
  80. report_language.py +828 -0
  81. repositories/__init__.py +26 -0
  82. repositories/alert_repo.py +327 -0
  83. repositories/analysis_repo.py +130 -0
  84. repositories/backtest_repo.py +439 -0
  85. repositories/data_quality_repo.py +109 -0
  86. repositories/decision_signal_repo.py +322 -0
  87. repositories/portfolio_repo.py +1152 -0
  88. repositories/stock_metadata_repo.py +95 -0
  89. repositories/stock_pool_repo.py +234 -0
  90. repositories/stock_repo.py +161 -0
  91. scheduler.py +356 -0
  92. schemas/__init__.py +30 -0
  93. schemas/analysis_context_pack.py +128 -0
  94. schemas/decision_action.py +382 -0
  95. schemas/market_light.py +43 -0
  96. schemas/report_schema.py +175 -0
  97. search_service.py +3995 -0
  98. services/__init__.py +40 -0
  99. services/agent_model_service.py +138 -0
  100. services/alert_indicators.py +514 -0
  101. services/alert_service.py +1329 -0
  102. services/alert_worker.py +738 -0
  103. services/alphasift_service.py +1621 -0
  104. services/analysis_context_builder.py +783 -0
  105. services/analysis_service.py +248 -0
  106. services/analyzer_service.py +133 -0
  107. services/backtest_service.py +822 -0
  108. services/data_import_service.py +73 -0
  109. services/data_quality_service.py +81 -0
  110. services/decision_signal_service.py +547 -0
  111. services/history_comparison_service.py +96 -0
  112. services/history_loader.py +174 -0
  113. services/history_retention_service.py +319 -0
  114. services/history_service.py +1188 -0
  115. services/image_stock_extractor.py +340 -0
  116. services/import_parser.py +252 -0
  117. services/market_light_alerts.py +362 -0
  118. services/market_light_service.py +129 -0
  119. services/name_to_code_resolver.py +231 -0
  120. services/notification_diagnostics.py +8 -0
  121. services/portfolio_alerts.py +616 -0
  122. services/portfolio_import_service.py +448 -0
  123. services/portfolio_risk_service.py +441 -0
  124. services/portfolio_service.py +1610 -0
  125. services/report_renderer.py +211 -0
  126. services/run_diagnostics.py +973 -0
  127. services/run_flow.py +1263 -0
  128. services/social_sentiment_service.py +343 -0
  129. services/stock_code_utils.py +102 -0
  130. services/stock_index_remote_service.py +265 -0
  131. services/stock_metadata_service.py +91 -0
  132. services/stock_pool_service.py +104 -0
  133. services/stock_service.py +186 -0
  134. services/system_config_service.py +3950 -0
  135. services/task_queue.py +954 -0
  136. services/task_service.py +244 -0
  137. services/vector_search_service.py +427 -0
  138. stock_analyzer.py +848 -0
  139. storage.py +2977 -0
  140. utils/__init__.py +1 -0
  141. utils/analysis_metadata.py +10 -0
  142. utils/data_processing.py +259 -0
  143. utils/sanitize.py +232 -0
__init__.py ADDED
File without changes
agent/__init__.py ADDED
@@ -0,0 +1,53 @@
1
+ # -*- coding: utf-8 -*-
2
+ """
3
+ Agent module for stock analysis system.
4
+
5
+ Provides LLM-based agent with tool-calling capabilities,
6
+ pluggable trading strategies, and multi-turn conversation support.
7
+
8
+ Enabled via AGENT_MODE=true environment variable.
9
+
10
+ Use explicit imports to avoid pulling in heavy dependencies (e.g. json_repair)
11
+ when only lightweight sub-modules like tools.registry are needed::
12
+
13
+ from src.agent.executor import AgentExecutor, AgentResult
14
+ from src.agent.runner import run_agent_loop, RunLoopResult
15
+ from src.agent.protocols import AgentContext, AgentOpinion, StageResult, AgentRunStats
16
+ from src.agent.orchestrator import AgentOrchestrator
17
+ """
18
+
19
+
20
+ def __getattr__(name):
21
+ """Lazy import to avoid triggering json_repair etc. on package access."""
22
+ if name == "AgentExecutor":
23
+ from src.agent.executor import AgentExecutor
24
+ return AgentExecutor
25
+ if name == "AgentResult":
26
+ from src.agent.executor import AgentResult
27
+ return AgentResult
28
+ if name == "RunLoopResult":
29
+ from src.agent.runner import RunLoopResult
30
+ return RunLoopResult
31
+ if name in ("AgentContext", "AgentOpinion", "StageResult", "AgentRunStats"):
32
+ from src.agent import protocols
33
+ return getattr(protocols, name)
34
+ if name == "AgentOrchestrator":
35
+ from src.agent.orchestrator import AgentOrchestrator
36
+ return AgentOrchestrator
37
+ if name == "AgentMemory":
38
+ from src.agent.memory import AgentMemory
39
+ return AgentMemory
40
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
41
+
42
+
43
+ __all__ = [
44
+ "AgentExecutor",
45
+ "AgentResult",
46
+ "RunLoopResult",
47
+ "AgentContext",
48
+ "AgentOpinion",
49
+ "StageResult",
50
+ "AgentRunStats",
51
+ "AgentOrchestrator",
52
+ "AgentMemory",
53
+ ]
@@ -0,0 +1,23 @@
1
+ # -*- coding: utf-8 -*-
2
+ """
3
+ Specialised agents for the multi-agent pipeline.
4
+
5
+ Each agent class inherits from :class:`BaseAgent` and implements
6
+ a focused analysis scope (technical, intelligence, decision, risk).
7
+ """
8
+
9
+ from src.agent.agents.base_agent import BaseAgent
10
+ from src.agent.agents.technical_agent import TechnicalAgent
11
+ from src.agent.agents.intel_agent import IntelAgent
12
+ from src.agent.agents.decision_agent import DecisionAgent
13
+ from src.agent.agents.risk_agent import RiskAgent
14
+ from src.agent.agents.portfolio_agent import PortfolioAgent
15
+
16
+ __all__ = [
17
+ "BaseAgent",
18
+ "TechnicalAgent",
19
+ "IntelAgent",
20
+ "DecisionAgent",
21
+ "RiskAgent",
22
+ "PortfolioAgent",
23
+ ]
@@ -0,0 +1,286 @@
1
+ # -*- coding: utf-8 -*-
2
+ """
3
+ BaseAgent — abstract base for all specialised agents.
4
+
5
+ Every agent in the multi-agent pipeline inherits from this class and
6
+ implements :meth:`run`. The base class provides shared utilities:
7
+ tool-subset selection, prompt assembly, LLM invocation via the shared
8
+ runner, and structured opinion output.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import logging
14
+ import time
15
+ from abc import ABC, abstractmethod
16
+ from typing import Any, Callable, Dict, List, Optional
17
+
18
+ from src.agent.llm_adapter import LLMToolAdapter
19
+ from src.agent.memory import AgentMemory
20
+ from src.agent.protocols import AgentContext, AgentOpinion, StageResult, StageStatus
21
+ from src.agent.runner import RunLoopResult, run_agent_loop
22
+ from src.agent.skills.defaults import extract_skill_id
23
+ from src.agent.tools.registry import ToolRegistry
24
+ from src.market_phase_prompt import format_market_phase_prompt_section
25
+ from src.report_language import normalize_report_language
26
+
27
+ logger = logging.getLogger(__name__)
28
+
29
+
30
+ class BaseAgent(ABC):
31
+ """Abstract base for all specialised agents.
32
+
33
+ Subclasses **must** implement:
34
+ - :pyattr:`agent_name` — unique agent identifier
35
+ - :meth:`system_prompt` — return the LLM system prompt
36
+ - :meth:`build_user_message` — construct the user message for the LLM
37
+
38
+ Subclasses **may** override:
39
+ - :pyattr:`tool_names` — restrict which tools the agent can access
40
+ - :pyattr:`max_steps` — per-agent step limit (default 6)
41
+ - :meth:`post_process` — transform the raw LLM text into an :class:`AgentOpinion`
42
+ """
43
+
44
+ # Subclass overrides
45
+ agent_name: str = "base"
46
+ tool_names: Optional[List[str]] = None # None → all tools available
47
+ max_steps: int = 6
48
+
49
+ def __init__(
50
+ self,
51
+ tool_registry: ToolRegistry,
52
+ llm_adapter: LLMToolAdapter,
53
+ skill_instructions: str = "",
54
+ technical_skill_policy: str = "",
55
+ ):
56
+ self.tool_registry = tool_registry
57
+ self.llm_adapter = llm_adapter
58
+ self.skill_instructions = skill_instructions
59
+ self.technical_skill_policy = technical_skill_policy
60
+ self.memory = AgentMemory.from_config()
61
+
62
+ # -----------------------------------------------------------------
63
+ # Abstract interface
64
+ # -----------------------------------------------------------------
65
+
66
+ @abstractmethod
67
+ def system_prompt(self, ctx: AgentContext) -> str:
68
+ """Build the system prompt for this agent."""
69
+
70
+ @abstractmethod
71
+ def build_user_message(self, ctx: AgentContext) -> str:
72
+ """Build the user message sent to the LLM."""
73
+
74
+ # -----------------------------------------------------------------
75
+ # Default hook for structured output
76
+ # -----------------------------------------------------------------
77
+
78
+ def post_process(self, ctx: AgentContext, raw_text: str) -> Optional[AgentOpinion]:
79
+ """Extract a structured :class:`AgentOpinion` from the raw LLM text.
80
+
81
+ Default: returns ``None`` (the raw text is still stored in
82
+ ``StageResult.meta["raw_text"]``). Subclasses that produce
83
+ analysis opinions should override this.
84
+ """
85
+ return None
86
+
87
+ # -----------------------------------------------------------------
88
+ # Execution
89
+ # -----------------------------------------------------------------
90
+
91
+ def run(
92
+ self,
93
+ ctx: AgentContext,
94
+ progress_callback: Optional[Callable[[Dict[str, Any]], None]] = None,
95
+ timeout_seconds: Optional[float] = None,
96
+ ) -> StageResult:
97
+ """Execute this agent and return a :class:`StageResult`.
98
+
99
+ Steps:
100
+ 1. Build system + user messages.
101
+ 2. Optionally inject pre-fetched data from ``ctx.data``.
102
+ 3. Delegate to :func:`run_agent_loop`.
103
+ 4. Call :meth:`post_process` to produce an opinion.
104
+ 5. Append the opinion to ``ctx.opinions``.
105
+ """
106
+ t0 = time.time()
107
+ result = StageResult(stage_name=self.agent_name, status=StageStatus.RUNNING)
108
+
109
+ try:
110
+ messages = self._build_messages(ctx)
111
+
112
+ # Restrict tools if the agent declares a subset
113
+ registry = self._filtered_registry()
114
+
115
+ loop_result: RunLoopResult = run_agent_loop(
116
+ messages=messages,
117
+ tool_registry=registry,
118
+ llm_adapter=self.llm_adapter,
119
+ max_steps=self.max_steps,
120
+ progress_callback=progress_callback,
121
+ max_wall_clock_seconds=timeout_seconds,
122
+ stock_scope=ctx.meta.get("stock_scope"),
123
+ )
124
+
125
+ result.tokens_used = loop_result.total_tokens
126
+ result.tool_calls_count = len(loop_result.tool_calls_log)
127
+ result.meta["raw_text"] = loop_result.content
128
+ result.meta["models_used"] = loop_result.models_used
129
+ result.meta["tool_calls_log"] = loop_result.tool_calls_log
130
+
131
+ if not loop_result.success:
132
+ result.status = StageStatus.FAILED
133
+ result.error = loop_result.error or "Agent loop did not produce a final answer"
134
+ return result
135
+
136
+ # Post-process into structured opinion
137
+ opinion = self.post_process(ctx, loop_result.content)
138
+ if opinion is not None:
139
+ opinion.agent_name = self.agent_name
140
+ self._apply_memory_calibration(ctx, opinion, result)
141
+ ctx.add_opinion(opinion)
142
+ result.opinion = opinion
143
+
144
+ result.status = StageStatus.COMPLETED
145
+
146
+ except Exception as exc:
147
+ logger.error("[%s] execution failed: %s", self.agent_name, exc, exc_info=True)
148
+ result.status = StageStatus.FAILED
149
+ result.error = str(exc)
150
+ finally:
151
+ result.duration_s = round(time.time() - t0, 2)
152
+
153
+ return result
154
+
155
+ # -----------------------------------------------------------------
156
+ # Internal helpers
157
+ # -----------------------------------------------------------------
158
+
159
+ def _build_messages(self, ctx: AgentContext) -> List[Dict[str, Any]]:
160
+ """Assemble the initial messages list for the LLM."""
161
+ messages: List[Dict[str, Any]] = [
162
+ {"role": "system", "content": self.system_prompt(ctx)},
163
+ ]
164
+
165
+ history = ctx.meta.get("conversation_history")
166
+ if isinstance(history, list):
167
+ for message in history:
168
+ if not isinstance(message, dict):
169
+ continue
170
+ role = message.get("role")
171
+ content = message.get("content")
172
+ if role in {"user", "assistant", "system"} and isinstance(content, str) and content:
173
+ messages.append({"role": role, "content": content})
174
+
175
+ report_language = normalize_report_language(ctx.meta.get("report_language", "zh"))
176
+ market_phase_section = format_market_phase_prompt_section(
177
+ ctx.meta.get("market_phase_context"),
178
+ report_language=report_language,
179
+ )
180
+ if market_phase_section:
181
+ messages.append({"role": "user", "content": market_phase_section})
182
+
183
+ analysis_context_pack_summary = ctx.meta.get("analysis_context_pack_summary")
184
+ if isinstance(analysis_context_pack_summary, str) and analysis_context_pack_summary:
185
+ messages.append({"role": "user", "content": analysis_context_pack_summary})
186
+
187
+ # Inject pre-fetched data as a synthetic assistant context
188
+ cached_data = self._inject_cached_data(ctx)
189
+ if cached_data:
190
+ messages.append({"role": "user", "content": cached_data})
191
+ messages.append({"role": "assistant", "content": "Understood, I have the pre-fetched data. Proceeding with analysis."})
192
+
193
+ messages.append({"role": "user", "content": self.build_user_message(ctx)})
194
+ return messages
195
+
196
+ def _inject_cached_data(self, ctx: AgentContext) -> str:
197
+ """Build a context string from already-fetched data in ``ctx.data``.
198
+
199
+ This avoids redundant tool calls when earlier stages have already
200
+ fetched the data this agent needs.
201
+ """
202
+ import json
203
+ parts: List[str] = []
204
+ for key, value in ctx.data.items():
205
+ if value is not None:
206
+ try:
207
+ serialised = json.dumps(value, ensure_ascii=False, default=str)
208
+ except (TypeError, ValueError):
209
+ serialised = str(value)
210
+ # Cap per-field size to avoid overwhelming the context window
211
+ if len(serialised) > 8000:
212
+ serialised = serialised[:8000] + "...(truncated)"
213
+ parts.append(f"[Pre-fetched: {key}]\n{serialised}")
214
+ memory_context = self._build_memory_context(ctx)
215
+ if memory_context:
216
+ parts.append(memory_context)
217
+ return "\n\n".join(parts) if parts else ""
218
+
219
+ def _filtered_registry(self) -> ToolRegistry:
220
+ """Return a ToolRegistry restricted to ``self.tool_names``.
221
+
222
+ If ``tool_names`` is None (default), the full registry is returned.
223
+ """
224
+ if self.tool_names is None:
225
+ return self.tool_registry
226
+
227
+ from src.agent.tools.registry import ToolRegistry as TR
228
+ filtered = TR()
229
+ for name in self.tool_names:
230
+ tool_def = self.tool_registry.get(name)
231
+ if tool_def:
232
+ filtered.register(tool_def)
233
+ else:
234
+ logger.warning("[%s] requested tool '%s' not found in registry", self.agent_name, name)
235
+ return filtered
236
+
237
+ def _build_memory_context(self, ctx: AgentContext) -> str:
238
+ """Summarise recent analysis history for prompt injection."""
239
+ if not self.memory.enabled or not ctx.stock_code:
240
+ return ""
241
+
242
+ entries = self.memory.get_stock_history(ctx.stock_code, limit=3)
243
+ if not entries:
244
+ return ""
245
+
246
+ lines = ["[Memory: recent analysis history]"]
247
+ for entry in entries:
248
+ parts = [
249
+ entry.date or "unknown_date",
250
+ f"signal={entry.signal or 'unknown'}",
251
+ f"sentiment={entry.sentiment_score}",
252
+ ]
253
+ if entry.price_at_analysis:
254
+ parts.append(f"price={entry.price_at_analysis}")
255
+ if entry.outcome_5d is not None:
256
+ parts.append(f"outcome_5d={entry.outcome_5d}")
257
+ if entry.outcome_20d is not None:
258
+ parts.append(f"outcome_20d={entry.outcome_20d}")
259
+ if entry.was_correct is not None:
260
+ parts.append(f"was_correct={entry.was_correct}")
261
+ lines.append("- " + ", ".join(parts))
262
+ lines.append("Use this memory as context only; do not copy it verbatim into the final answer.")
263
+ return "\n".join(lines)
264
+
265
+ def _apply_memory_calibration(self, ctx: AgentContext, opinion: AgentOpinion, result: StageResult) -> None:
266
+ """Adjust confidence using historical calibration when enabled."""
267
+ if not self.memory.enabled:
268
+ return
269
+
270
+ skill_id = extract_skill_id(self.agent_name)
271
+ calibration = self.memory.get_calibration(
272
+ agent_name=self.agent_name,
273
+ stock_code=ctx.stock_code or None,
274
+ skill_id=skill_id,
275
+ )
276
+ if not calibration.calibrated:
277
+ return
278
+
279
+ raw_confidence = opinion.confidence
280
+ opinion.confidence = max(0.0, min(1.0, raw_confidence * calibration.calibration_factor))
281
+ result.meta["memory_calibration"] = {
282
+ "raw_confidence": raw_confidence,
283
+ "calibrated_confidence": opinion.confidence,
284
+ "factor": calibration.calibration_factor,
285
+ "samples": calibration.total_samples,
286
+ }
@@ -0,0 +1,232 @@
1
+ # -*- coding: utf-8 -*-
2
+ """
3
+ DecisionAgent — final synthesis and decision-making specialist.
4
+
5
+ Responsible for:
6
+ - Aggregating opinions from technical + intel + risk + skill agents
7
+ - Producing the final Decision Dashboard JSON
8
+ - Generating actionable buy/hold/sell recommendations with price levels
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import json
14
+ import logging
15
+ from typing import List, Optional
16
+
17
+ from src.agent.agents.base_agent import BaseAgent
18
+ from src.agent.protocols import AgentContext, AgentOpinion, normalize_decision_signal
19
+ from src.report_language import normalize_report_language
20
+
21
+ logger = logging.getLogger(__name__)
22
+
23
+
24
+ class DecisionAgent(BaseAgent):
25
+ """Synthesise prior agent opinions into the final dashboard."""
26
+
27
+ agent_name = "decision"
28
+ max_steps = 3 # pure synthesis, should not need many tool calls
29
+ tool_names: Optional[List[str]] = [] # no tool access — works from context only
30
+
31
+ @staticmethod
32
+ def _is_chat_mode(ctx: AgentContext) -> bool:
33
+ return ctx.meta.get("response_mode") == "chat"
34
+
35
+ def system_prompt(self, ctx: AgentContext) -> str:
36
+ report_language = normalize_report_language(ctx.meta.get("report_language", "zh"))
37
+ if self._is_chat_mode(ctx):
38
+ prompt = """\
39
+ You are a **Decision Synthesis Agent** replying directly to the user's latest
40
+ stock-analysis question.
41
+
42
+ You will receive structured opinions from the technical, intelligence, risk,
43
+ and skill stages. Synthesize them into a concise, natural-language answer.
44
+
45
+ Requirements:
46
+ - Answer the user's actual question directly
47
+ - Use Markdown when helpful
48
+ - Keep the response practical and specific
49
+ - Highlight the main signal, key reasoning, and major risks
50
+ - Do NOT output JSON or code fences unless the user explicitly asks for them
51
+ """
52
+ if report_language == "en":
53
+ return prompt + "\nAlways answer in English.\n"
54
+ return prompt + "\n默认使用中文回答。\n"
55
+
56
+ skills = ""
57
+ if self.skill_instructions:
58
+ skills = f"\n## Active Trading Skills\n\n{self.skill_instructions}\n"
59
+
60
+ prompt = f"""\
61
+ You are a **Decision Synthesis Agent** that produces the final investment \
62
+ Decision Dashboard.
63
+
64
+ You will receive:
65
+ 1. Structured opinions from a Technical Agent and an Intel Agent
66
+ 2. Any risk flags raised by a Risk Agent
67
+ 3. Skill evaluation results (if applicable)
68
+
69
+ Your task: synthesise all inputs into a single, actionable Decision Dashboard.
70
+ {skills}
71
+ ## Core Principles
72
+ 1. **Core conclusion first** — one sentence, ≤30 chars
73
+ 2. **Split advice** — different for no-position vs has-position
74
+ 3. **Precise sniper levels** — concrete price numbers, no hedging
75
+ 4. **Checklist visual** — ✅⚠️❌ for each checkpoint
76
+ 5. **Risk priority** — risk alerts must be prominent. If high-severity risk exists, \
77
+ the overall signal must be downgraded accordingly.
78
+
79
+ ## Signal Weighting Guidelines
80
+ - Technical opinion weight: ~40%
81
+ - Intel / sentiment weight: ~30%
82
+ - Risk flags weight: ~30% (negative override: any high-severity risk caps signal at "hold")
83
+ - If a skill opinion is present, blend it at 20% weight (reducing others proportionally)
84
+
85
+ ## Scoring
86
+ - 80-100: buy (all conditions met, high conviction)
87
+ - 60-79: buy (mostly positive, minor caveats)
88
+ - 40-59: hold (mixed signals, or risk present)
89
+ - 20-39: sell (negative trend + risk)
90
+ - 0-19: sell (major risk + bearish)
91
+
92
+ ## Actionability Guardrails
93
+ - Do not flip directly between buy and sell only because one trading day moved up or down.
94
+ - Base operation_advice on support/resistance, volume/chip context, main-force capital flow, and risk flags.
95
+ - If price is between support and resistance and capital flow is not clearly one-sided, prefer a neutral action such as hold/watch/range-bound/shakeout watch; keep decision_type as hold.
96
+ - Buy requires support confirmation or a valid resistance breakout with volume/capital-flow confirmation.
97
+ - Sell requires support failure, sustained main-force outflow, or clearly elevated risk.
98
+
99
+ ## Output Format
100
+ Return a valid JSON object following the Decision Dashboard schema. The JSON \
101
+ must include at minimum these top-level keys:
102
+ stock_name, sentiment_score, trend_prediction, operation_advice,
103
+ decision_type, confidence_level, dashboard, analysis_summary,
104
+ key_points, risk_warning
105
+
106
+ Important: ``decision_type`` must stay within the existing enum
107
+ ``buy|hold|sell``. Express stronger conviction via ``confidence_level``,
108
+ ``sentiment_score``, and the natural-language fields instead of inventing
109
+ new decision_type values.
110
+
111
+ The nested ``dashboard`` object must include ``phase_decision`` with these
112
+ keys: ``phase_context``, ``action_window``, ``immediate_action``,
113
+ ``watch_conditions``, ``next_check_time``, ``confidence_reason``,
114
+ ``data_limitations``. For intraday/lunch-break/near-close phases, describe the
115
+ current action, watch conditions, and next check point. For pre-market,
116
+ non-trading, or unknown phases, do not invent today's intraday movement. If
117
+ quote, daily bars, or technical data is stale, fallback, missing, fetch_failed,
118
+ partial, or estimated, ``confidence_level`` must not be High/高 and the
119
+ limitation must be reflected in ``confidence_reason`` or ``data_limitations``.
120
+ """
121
+ if report_language == "en":
122
+ return prompt + """
123
+
124
+ ## Output Language
125
+ - Keep every JSON key unchanged.
126
+ - `decision_type` must remain `buy|hold|sell`.
127
+ - Write all human-readable JSON values in English.
128
+ """
129
+ return prompt + """
130
+
131
+ ## 输出语言
132
+ - 所有 JSON 键名保持不变。
133
+ - `decision_type` 必须保持为 `buy|hold|sell`。
134
+ - 所有面向用户的人类可读文本值必须使用中文。
135
+ """
136
+
137
+ def build_user_message(self, ctx: AgentContext) -> str:
138
+ if self._is_chat_mode(ctx):
139
+ parts = [
140
+ "# User Question",
141
+ ctx.query,
142
+ "",
143
+ f"Stock: {ctx.stock_code} ({ctx.stock_name})" if ctx.stock_name else f"Stock: {ctx.stock_code}",
144
+ "",
145
+ ]
146
+ else:
147
+ parts = [
148
+ f"# Synthesis Request for {ctx.stock_code}",
149
+ f"Stock: {ctx.stock_code} ({ctx.stock_name})" if ctx.stock_name else f"Stock: {ctx.stock_code}",
150
+ "",
151
+ ]
152
+
153
+ # Feed prior opinions
154
+ if ctx.opinions:
155
+ parts.append("## Agent Opinions")
156
+ for op in ctx.opinions:
157
+ parts.append(f"\n### {op.agent_name}")
158
+ parts.append(f"Signal: {op.signal} | Confidence: {op.confidence:.2f}")
159
+ parts.append(f"Reasoning: {op.reasoning}")
160
+ if op.key_levels:
161
+ parts.append(f"Key levels: {json.dumps(op.key_levels)}")
162
+ if op.raw_data:
163
+ extra_keys = {k: v for k, v in op.raw_data.items()
164
+ if k not in ("signal", "confidence", "reasoning", "key_levels")}
165
+ if extra_keys:
166
+ parts.append(f"Extra data: {json.dumps(extra_keys, ensure_ascii=False, default=str)}")
167
+ parts.append("")
168
+
169
+ # Feed risk flags
170
+ if ctx.risk_flags:
171
+ parts.append("## Risk Flags")
172
+ for rf in ctx.risk_flags:
173
+ parts.append(f"- [{rf.get('severity', 'medium')}] {rf.get('category', '')}: {rf.get('description', '')}")
174
+ parts.append("")
175
+
176
+ # Skill meta
177
+ requested_skills = ctx.meta.get("skills_requested") or ctx.meta.get("strategies_requested")
178
+ if requested_skills:
179
+ parts.append(f"## Skills: {', '.join(requested_skills)}")
180
+ parts.append("")
181
+
182
+ if self._is_chat_mode(ctx):
183
+ parts.append(
184
+ "Answer the user in natural language using the evidence above. "
185
+ "Do not output JSON unless the user explicitly requests structured data."
186
+ )
187
+ else:
188
+ parts.append("Synthesise the above into the Decision Dashboard JSON.")
189
+ return "\n".join(parts)
190
+
191
+ def post_process(self, ctx: AgentContext, raw_text: str) -> Optional[AgentOpinion]:
192
+ """Store the parsed dashboard in ctx.meta; also return an opinion."""
193
+ if self._is_chat_mode(ctx):
194
+ text = (raw_text or "").strip()
195
+ if not text:
196
+ return None
197
+
198
+ ctx.set_data("final_response_text", text)
199
+ prior = next((op for op in reversed(ctx.opinions) if op.agent_name != self.agent_name), None)
200
+ return AgentOpinion(
201
+ agent_name=self.agent_name,
202
+ signal=prior.signal if prior is not None else "hold",
203
+ confidence=prior.confidence if prior is not None else 0.5,
204
+ reasoning=text,
205
+ raw_data={"response_mode": "chat"},
206
+ )
207
+
208
+ from src.agent.runner import parse_dashboard_json
209
+
210
+ dashboard = parse_dashboard_json(raw_text)
211
+ if dashboard:
212
+ dashboard["decision_type"] = normalize_decision_signal(
213
+ dashboard.get("decision_type", "hold")
214
+ )
215
+ ctx.set_data("final_dashboard", dashboard)
216
+ try:
217
+ _raw_score = dashboard.get("sentiment_score", 50) or 50
218
+ _score = float(_raw_score)
219
+ except (TypeError, ValueError):
220
+ _score = 50.0
221
+ return AgentOpinion(
222
+ agent_name=self.agent_name,
223
+ signal=dashboard.get("decision_type", "hold"),
224
+ confidence=min(1.0, _score / 100.0),
225
+ reasoning=dashboard.get("analysis_summary", ""),
226
+ raw_data=dashboard,
227
+ )
228
+ else:
229
+ # Even if JSON parsing fails, store the raw text for downstream use
230
+ ctx.set_data("final_dashboard_raw", raw_text)
231
+ logger.warning("[DecisionAgent] failed to parse dashboard JSON")
232
+ return None