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.
- __init__.py +0 -0
- agent/__init__.py +53 -0
- agent/agents/__init__.py +23 -0
- agent/agents/base_agent.py +286 -0
- agent/agents/decision_agent.py +232 -0
- agent/agents/intel_agent.py +118 -0
- agent/agents/portfolio_agent.py +154 -0
- agent/agents/risk_agent.py +128 -0
- agent/agents/technical_agent.py +103 -0
- agent/chat_context.py +483 -0
- agent/conversation.py +113 -0
- agent/events.py +563 -0
- agent/executor.py +819 -0
- agent/factory.py +397 -0
- agent/llm_adapter.py +829 -0
- agent/memory.py +312 -0
- agent/orchestrator.py +1614 -0
- agent/protocols.py +236 -0
- agent/provider_trace.py +267 -0
- agent/research.py +483 -0
- agent/runner.py +839 -0
- agent/skills/__init__.py +62 -0
- agent/skills/aggregator.py +160 -0
- agent/skills/base.py +481 -0
- agent/skills/defaults.py +316 -0
- agent/skills/router.py +164 -0
- agent/skills/skill_agent.py +118 -0
- agent/stock_scope.py +244 -0
- agent/strategies/__init__.py +19 -0
- agent/strategies/aggregator.py +5 -0
- agent/strategies/router.py +5 -0
- agent/strategies/strategy_agent.py +5 -0
- agent/tools/__init__.py +11 -0
- agent/tools/analysis_tools.py +520 -0
- agent/tools/backtest_tools.py +245 -0
- agent/tools/data_tools.py +694 -0
- agent/tools/market_tools.py +108 -0
- agent/tools/pool_tools.py +202 -0
- agent/tools/registry.py +266 -0
- agent/tools/search_tools.py +211 -0
- analysis_context_pack_overview.py +302 -0
- analysis_context_pack_prompt.py +519 -0
- analyzer.py +3828 -0
- auth.py +500 -0
- config.py +2842 -0
- core/backtest_engine.py +683 -0
- core/config_manager.py +214 -0
- core/config_registry.py +4379 -0
- core/market_profile.py +76 -0
- core/market_review.py +466 -0
- core/market_review_lock.py +227 -0
- core/market_review_runtime.py +85 -0
- core/market_strategy.py +172 -0
- core/pipeline.py +2536 -0
- core/trading_calendar.py +556 -0
- data/__init__.py +8 -0
- data/stock_index_loader.py +238 -0
- data/stock_mapping.py +139 -0
- dsa_server-0.1.0.dist-info/METADATA +243 -0
- dsa_server-0.1.0.dist-info/RECORD +143 -0
- dsa_server-0.1.0.dist-info/WHEEL +5 -0
- dsa_server-0.1.0.dist-info/entry_points.txt +3 -0
- dsa_server-0.1.0.dist-info/licenses/LICENSE +21 -0
- dsa_server-0.1.0.dist-info/top_level.txt +30 -0
- embedding_service.py +124 -0
- enums.py +50 -0
- feishu_doc.py +165 -0
- formatters.py +1077 -0
- llm/__init__.py +2 -0
- llm/errors.py +151 -0
- llm/generation_params.py +439 -0
- logging_config.py +197 -0
- market_analyzer.py +1487 -0
- market_context.py +124 -0
- market_phase_prompt.py +215 -0
- market_phase_summary.py +264 -0
- patches/__init__.py +0 -0
- patches/eastmoney_patch.py +182 -0
- phase_decision_guardrail.py +377 -0
- report_language.py +828 -0
- repositories/__init__.py +26 -0
- repositories/alert_repo.py +327 -0
- repositories/analysis_repo.py +130 -0
- repositories/backtest_repo.py +439 -0
- repositories/data_quality_repo.py +109 -0
- repositories/decision_signal_repo.py +322 -0
- repositories/portfolio_repo.py +1152 -0
- repositories/stock_metadata_repo.py +95 -0
- repositories/stock_pool_repo.py +234 -0
- repositories/stock_repo.py +161 -0
- scheduler.py +356 -0
- schemas/__init__.py +30 -0
- schemas/analysis_context_pack.py +128 -0
- schemas/decision_action.py +382 -0
- schemas/market_light.py +43 -0
- schemas/report_schema.py +175 -0
- search_service.py +3995 -0
- services/__init__.py +40 -0
- services/agent_model_service.py +138 -0
- services/alert_indicators.py +514 -0
- services/alert_service.py +1329 -0
- services/alert_worker.py +738 -0
- services/alphasift_service.py +1621 -0
- services/analysis_context_builder.py +783 -0
- services/analysis_service.py +248 -0
- services/analyzer_service.py +133 -0
- services/backtest_service.py +822 -0
- services/data_import_service.py +73 -0
- services/data_quality_service.py +81 -0
- services/decision_signal_service.py +547 -0
- services/history_comparison_service.py +96 -0
- services/history_loader.py +174 -0
- services/history_retention_service.py +319 -0
- services/history_service.py +1188 -0
- services/image_stock_extractor.py +340 -0
- services/import_parser.py +252 -0
- services/market_light_alerts.py +362 -0
- services/market_light_service.py +129 -0
- services/name_to_code_resolver.py +231 -0
- services/notification_diagnostics.py +8 -0
- services/portfolio_alerts.py +616 -0
- services/portfolio_import_service.py +448 -0
- services/portfolio_risk_service.py +441 -0
- services/portfolio_service.py +1610 -0
- services/report_renderer.py +211 -0
- services/run_diagnostics.py +973 -0
- services/run_flow.py +1263 -0
- services/social_sentiment_service.py +343 -0
- services/stock_code_utils.py +102 -0
- services/stock_index_remote_service.py +265 -0
- services/stock_metadata_service.py +91 -0
- services/stock_pool_service.py +104 -0
- services/stock_service.py +186 -0
- services/system_config_service.py +3950 -0
- services/task_queue.py +954 -0
- services/task_service.py +244 -0
- services/vector_search_service.py +427 -0
- stock_analyzer.py +848 -0
- storage.py +2977 -0
- utils/__init__.py +1 -0
- utils/analysis_metadata.py +10 -0
- utils/data_processing.py +259 -0
- 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
|
+
]
|
agent/agents/__init__.py
ADDED
|
@@ -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
|