This commit is contained in:
sam 2025-09-28 09:39:48 +08:00
parent 3f3af5404f
commit 1a99b72c60
10 changed files with 884 additions and 33 deletions

View File

@ -23,6 +23,7 @@
- **可视化与解释**`app/ui/streamlit_app.py` 提供四大页签(今日计划、回测与复盘、数据与设置、自检测试),结合 Plotly 图形展示和 `app/llm` 提示卡片生成器,支撑人机协作分析。 - **可视化与解释**`app/ui/streamlit_app.py` 提供四大页签(今日计划、回测与复盘、数据与设置、自检测试),结合 Plotly 图形展示和 `app/llm` 提示卡片生成器,支撑人机协作分析。
- **统一日志与持久化**SQLite 统一存储行情、回测与日志,配合 `DatabaseLogHandler` 在 UI/抓数流程中输出结构化运行轨迹,支持快速追踪与复盘。 - **统一日志与持久化**SQLite 统一存储行情、回测与日志,配合 `DatabaseLogHandler` 在 UI/抓数流程中输出结构化运行轨迹,支持快速追踪与复盘。
- **跨市场数据扩展**`app/ingest/tushare.py` 追加指数、ETF/公募基金、期货、外汇、港股与美股的增量拉取逻辑,确保多资产因子与宏观代理所需的行情基础数据齐备。 - **跨市场数据扩展**`app/ingest/tushare.py` 追加指数、ETF/公募基金、期货、外汇、港股与美股的增量拉取逻辑,确保多资产因子与宏观代理所需的行情基础数据齐备。
- **部门化多模型协作**`app/agents/departments.py` 封装部门级 LLM 调度,`app/llm/client.py` 支持 single/majority/leader 策略,部门结论在 `app/agents/game.py` 与六类基础代理共同博弈,并持久化至 `agent_utils` 供 UI 展示。
## LLM + 多智能体最佳实践 ## LLM + 多智能体最佳实践
@ -85,10 +86,35 @@ Streamlit `自检测试` 页签提供:
## 下一步 ## 下一步
1. 在 `app/features``app/backtest` 中完善信号计算、事件驱动撮合与绩效指标输出。 1. 在 `app/features``app/backtest` 中完善信号计算、事件驱动撮合与绩效指标输出。
2. 将代理效用写入 SQLite 的 `agent_utils``alloc_log` 表,驱动 UI 决策解释 2. 丰富 `DepartmentContext`(行情快照、风险指标),让部门评估拥有更完整的上下文
3. 使用轻量情感分析与热度计算填充 `news`、`heat_daily` 与热点指数。 3. 使用轻量情感分析与热度计算填充 `news`、`heat_daily` 与热点指数。
4. 接入本地小模型或 API 完成人类可读的策略建议卡片,形成端到端体验 4. 在 Streamlit 今日计划页增加“重新评估”与日志追踪能力,串联实时调度链路
## License ## License
本项目采用定制的 “LLM Quant Framework License v1.0”。个人使用、修改与分发需保留出处,任何商业用途须事先与版权方协商并签署付费协议。详情参见仓库根目录的 `LICENSE` 文件。 本项目采用定制的 “LLM Quant Framework License v1.0”。个人使用、修改与分发需保留出处,任何商业用途须事先与版权方协商并签署付费协议。详情参见仓库根目录的 `LICENSE` 文件。
## 多智能体 LLM 投资流程
- **部门化结构**:动量、价值、新闻、流动性、宏观、风险等代理视作独立业务部门,利用项目现有的数据/特征处理流程向每个部门提供上下文。
- **多 LLM 协作**:每个部门内部可配置多家 LLM 提供商(如 DeepSeek、OpenAI、文心等作为智能体助手分别生成分析意见和风险提示可通过多数投票、仲裁等策略确定部门结论。
- **部门输出**:统一返回部门行动(买入/卖出/持有)、信心水平以及核心理由 (context + LLM 摘要),当前实现会将摘要、风险提示与票权写入 `agent_utils`
- **跨部门协调**:沿用 `app/agents/game.py` 的纳什谈判/投票结构,将各部门的结论与六类基础代理共同建模,必要时触发冲突检测并标记复核。
- **日志与可视化**Streamlit 今日计划页读取 `agent_utils` 展示部门意见、投票细节与全局行动,可快速核查部门分歧与置信度。
## 实施步骤
1. **配置扩展** (`app/utils/config.py` + `config.json`) ✅
- 部门支持 primary/ensemble、策略single/majority/leader、权重并可在 Streamlit 中编辑主要字段。
2. **部门管控器**
- `app/agents/departments.py` 提供 `DepartmentAgent`/`DepartmentManager`,封装 Prompt 构建、多模型协商及异常回退。
3. **集成决策链**
- `app/agents/game.py` 将部门评分嵌入纳什谈判/加权投票,并对冲突设置复核标记;`app/backtest/engine.py` 将结果落库。
4. **UI 与日志**(进行中)
- 今日计划页展示部门意见、票权与全局策略,后续补充一键重评估、日志钻取。
5. **测试与验证**(待补充)
- 需完善部门上下文构造与多模型调用的单元/集成测试,结合回测指标对比多 LLM 策略收益差异。

View File

@ -1,9 +1,9 @@
"""Agent abstractions for the multi-agent decision engine.""" """Agent abstractions for the multi-agent decision engine."""
from __future__ import annotations from __future__ import annotations
from dataclasses import dataclass from dataclasses import dataclass, field
from enum import Enum from enum import Enum
from typing import Dict, Mapping from typing import Any, Dict, Mapping
class AgentAction(str, Enum): class AgentAction(str, Enum):
@ -19,6 +19,8 @@ class AgentContext:
ts_code: str ts_code: str
trade_date: str trade_date: str
features: Mapping[str, float] features: Mapping[str, float]
market_snapshot: Mapping[str, Any] = field(default_factory=dict)
raw: Mapping[str, Any] = field(default_factory=dict)
class Agent: class Agent:

167
app/agents/departments.py Normal file
View File

@ -0,0 +1,167 @@
"""Department-level LLM agents coordinating multi-model decisions."""
from __future__ import annotations
import json
from dataclasses import dataclass, field
from typing import Any, Dict, List, Mapping
from app.agents.base import AgentAction
from app.llm.client import run_llm_with_config
from app.llm.prompts import department_prompt
from app.utils.config import DepartmentSettings
from app.utils.logging import get_logger
LOGGER = get_logger(__name__)
LOG_EXTRA = {"stage": "department"}
@dataclass
class DepartmentContext:
"""Structured data fed into a department for decision making."""
ts_code: str
trade_date: str
features: Mapping[str, Any] = field(default_factory=dict)
market_snapshot: Mapping[str, Any] = field(default_factory=dict)
raw: Mapping[str, Any] = field(default_factory=dict)
@dataclass
class DepartmentDecision:
"""Result produced by a department agent."""
department: str
action: AgentAction
confidence: float
summary: str
raw_response: str
signals: List[str] = field(default_factory=list)
risks: List[str] = field(default_factory=list)
def to_dict(self) -> Dict[str, Any]:
return {
"department": self.department,
"action": self.action.value,
"confidence": self.confidence,
"summary": self.summary,
"signals": self.signals,
"risks": self.risks,
"raw_response": self.raw_response,
}
class DepartmentAgent:
"""Wraps LLM ensemble logic for a single analytical department."""
def __init__(self, settings: DepartmentSettings) -> None:
self.settings = settings
def analyze(self, context: DepartmentContext) -> DepartmentDecision:
prompt = department_prompt(self.settings, context)
system_prompt = (
"你是一个多智能体量化投研系统中的分部决策者,需要根据提供的结构化信息给出买卖意见。"
)
try:
response = run_llm_with_config(self.settings.llm, prompt, system=system_prompt)
except Exception as exc: # noqa: BLE001
LOGGER.exception("部门 %s 调用 LLM 失败:%s", self.settings.code, exc, extra=LOG_EXTRA)
return DepartmentDecision(
department=self.settings.code,
action=AgentAction.HOLD,
confidence=0.0,
summary=f"LLM 调用失败:{exc}",
raw_response=str(exc),
)
decision_data = _parse_department_response(response)
action = _normalize_action(decision_data.get("action"))
confidence = _clamp_float(decision_data.get("confidence"), default=0.5)
summary = decision_data.get("summary") or decision_data.get("reason") or ""
signals = decision_data.get("signals") or decision_data.get("rationale") or []
if isinstance(signals, str):
signals = [signals]
risks = decision_data.get("risks") or decision_data.get("warnings") or []
if isinstance(risks, str):
risks = [risks]
decision = DepartmentDecision(
department=self.settings.code,
action=action,
confidence=confidence,
summary=summary or "未提供摘要",
signals=[str(sig) for sig in signals if sig],
risks=[str(risk) for risk in risks if risk],
raw_response=response,
)
LOGGER.debug(
"部门 %s 决策action=%s confidence=%.2f",
self.settings.code,
decision.action.value,
decision.confidence,
extra=LOG_EXTRA,
)
return decision
class DepartmentManager:
"""Orchestrates all departments defined in configuration."""
def __init__(self, departments: Mapping[str, DepartmentSettings]) -> None:
self.agents: Dict[str, DepartmentAgent] = {
code: DepartmentAgent(settings)
for code, settings in departments.items()
}
def evaluate(self, context: DepartmentContext) -> Dict[str, DepartmentDecision]:
results: Dict[str, DepartmentDecision] = {}
for code, agent in self.agents.items():
results[code] = agent.analyze(context)
return results
def _parse_department_response(text: str) -> Dict[str, Any]:
"""Extract a JSON object from the LLM response if possible."""
cleaned = text.strip()
candidate = None
if cleaned.startswith("{") and cleaned.endswith("}"):
candidate = cleaned
else:
start = cleaned.find("{")
end = cleaned.rfind("}")
if start != -1 and end != -1 and end > start:
candidate = cleaned[start : end + 1]
if candidate:
try:
return json.loads(candidate)
except json.JSONDecodeError:
LOGGER.debug("部门响应 JSON 解析失败,返回原始文本", extra=LOG_EXTRA)
return {"summary": cleaned}
def _normalize_action(value: Any) -> AgentAction:
if isinstance(value, str):
upper = value.strip().upper()
mapping = {
"BUY": AgentAction.BUY_M,
"BUY_S": AgentAction.BUY_S,
"BUY_M": AgentAction.BUY_M,
"BUY_L": AgentAction.BUY_L,
"SELL": AgentAction.SELL,
"HOLD": AgentAction.HOLD,
}
if upper in mapping:
return mapping[upper]
if "SELL" in upper:
return AgentAction.SELL
if "BUY" in upper:
return AgentAction.BUY_M
return AgentAction.HOLD
def _clamp_float(value: Any, default: float = 0.5) -> float:
try:
num = float(value)
except (TypeError, ValueError):
return default
return max(0.0, min(1.0, num))

View File

@ -1,11 +1,12 @@
"""Multi-agent decision game implementation.""" """Multi-agent decision game implementation."""
from __future__ import annotations from __future__ import annotations
from dataclasses import dataclass from dataclasses import dataclass, field
from math import exp, log from math import log
from typing import Dict, Iterable, List, Mapping, Tuple from typing import Dict, Iterable, List, Mapping, Optional, Tuple
from .base import Agent, AgentAction, AgentContext, UtilityMatrix from .base import Agent, AgentAction, AgentContext, UtilityMatrix
from .departments import DepartmentContext, DepartmentDecision, DepartmentManager
from .registry import weight_map from .registry import weight_map
@ -29,6 +30,9 @@ class Decision:
target_weight: float target_weight: float
feasible_actions: List[AgentAction] feasible_actions: List[AgentAction]
utilities: UtilityMatrix utilities: UtilityMatrix
department_decisions: Dict[str, DepartmentDecision] = field(default_factory=dict)
department_votes: Dict[str, float] = field(default_factory=dict)
requires_review: bool = False
def compute_utilities(agents: Iterable[Agent], context: AgentContext) -> UtilityMatrix: def compute_utilities(agents: Iterable[Agent], context: AgentContext) -> UtilityMatrix:
@ -105,16 +109,56 @@ def target_weight_for_action(action: AgentAction) -> float:
return mapping[action] return mapping[action]
def decide(context: AgentContext, agents: Iterable[Agent], weights: Mapping[str, float], method: str = "nash") -> Decision: def decide(
context: AgentContext,
agents: Iterable[Agent],
weights: Mapping[str, float],
method: str = "nash",
department_manager: Optional[DepartmentManager] = None,
department_context: Optional[DepartmentContext] = None,
) -> Decision:
agent_list = list(agents) agent_list = list(agents)
norm_weights = weight_map(dict(weights))
utilities = compute_utilities(agent_list, context) utilities = compute_utilities(agent_list, context)
feas_actions = feasible_actions(agent_list, context) feas_actions = feasible_actions(agent_list, context)
if not feas_actions: if not feas_actions:
return Decision(AgentAction.HOLD, 0.0, 0.0, [], utilities) return Decision(
action=AgentAction.HOLD,
confidence=0.0,
target_weight=0.0,
feasible_actions=[],
utilities=utilities,
)
raw_weights = dict(weights)
department_decisions: Dict[str, DepartmentDecision] = {}
department_votes: Dict[str, float] = {}
if department_manager:
dept_context = department_context
if dept_context is None:
dept_context = DepartmentContext(
ts_code=context.ts_code,
trade_date=context.trade_date,
features=dict(context.features),
market_snapshot=dict(getattr(context, "market_snapshot", {}) or {}),
raw=dict(getattr(context, "raw", {}) or {}),
)
department_decisions = department_manager.evaluate(dept_context)
for code, decision in department_decisions.items():
agent_key = f"dept_{code}"
dept_agent = department_manager.agents.get(code)
weight = dept_agent.settings.weight if dept_agent else 1.0
raw_weights[agent_key] = weight
scores = _department_scores(decision)
for action in ACTIONS:
utilities.setdefault(action, {})[agent_key] = scores[action]
bucket = _department_vote_bucket(decision.action)
if bucket:
department_votes[bucket] = department_votes.get(bucket, 0.0) + weight * decision.confidence
filtered_utilities = {action: utilities[action] for action in feas_actions} filtered_utilities = {action: utilities[action] for action in feas_actions}
hold_scores = utilities.get(AgentAction.HOLD, {}) hold_scores = utilities.get(AgentAction.HOLD, {})
norm_weights = weight_map(raw_weights)
if method == "vote": if method == "vote":
action, confidence = vote(filtered_utilities, norm_weights) action, confidence = vote(filtered_utilities, norm_weights)
@ -124,4 +168,66 @@ def decide(context: AgentContext, agents: Iterable[Agent], weights: Mapping[str,
action, confidence = vote(filtered_utilities, norm_weights) action, confidence = vote(filtered_utilities, norm_weights)
weight = target_weight_for_action(action) weight = target_weight_for_action(action)
return Decision(action, confidence, weight, feas_actions, utilities) requires_review = _department_conflict_flag(department_votes)
return Decision(
action=action,
confidence=confidence,
target_weight=weight,
feasible_actions=feas_actions,
utilities=utilities,
department_decisions=department_decisions,
department_votes=department_votes,
requires_review=requires_review,
)
def _department_scores(decision: DepartmentDecision) -> Dict[AgentAction, float]:
conf = _clamp(decision.confidence)
scores: Dict[AgentAction, float] = {action: 0.2 for action in ACTIONS}
if decision.action is AgentAction.SELL:
scores[AgentAction.SELL] = 0.7 + 0.3 * conf
scores[AgentAction.HOLD] = 0.4 * (1 - conf)
scores[AgentAction.BUY_S] = 0.2 * (1 - conf)
scores[AgentAction.BUY_M] = 0.15 * (1 - conf)
scores[AgentAction.BUY_L] = 0.1 * (1 - conf)
elif decision.action in {AgentAction.BUY_S, AgentAction.BUY_M, AgentAction.BUY_L}:
for action in (AgentAction.BUY_S, AgentAction.BUY_M, AgentAction.BUY_L):
if action is decision.action:
scores[action] = 0.6 + 0.4 * conf
else:
scores[action] = 0.3 + 0.3 * conf
scores[AgentAction.HOLD] = 0.3 * (1 - conf) + 0.25
scores[AgentAction.SELL] = 0.15 * (1 - conf)
else: # HOLD 或未知
scores[AgentAction.HOLD] = 0.6 + 0.4 * conf
scores[AgentAction.SELL] = 0.3 * (1 - conf)
scores[AgentAction.BUY_S] = 0.3 * (1 - conf)
scores[AgentAction.BUY_M] = 0.3 * (1 - conf)
scores[AgentAction.BUY_L] = 0.3 * (1 - conf)
return {action: _clamp(score) for action, score in scores.items()}
def _department_vote_bucket(action: AgentAction) -> str:
if action is AgentAction.SELL:
return "sell"
if action in {AgentAction.BUY_S, AgentAction.BUY_M, AgentAction.BUY_L}:
return "buy"
if action is AgentAction.HOLD:
return "hold"
return ""
def _department_conflict_flag(votes: Mapping[str, float]) -> bool:
if not votes:
return False
total = sum(votes.values())
if total <= 0:
return True
top = max(votes.values())
if top < total * 0.45:
return True
if len(votes) > 1:
sorted_votes = sorted(votes.values(), reverse=True)
if len(sorted_votes) >= 2 and (sorted_votes[0] - sorted_votes[1]) < total * 0.1:
return True
return False

View File

@ -1,14 +1,22 @@
"""Backtest engine skeleton for daily bar simulation.""" """Backtest engine skeleton for daily bar simulation."""
from __future__ import annotations from __future__ import annotations
import json
from dataclasses import dataclass, field from dataclasses import dataclass, field
from datetime import date from datetime import date
from typing import Dict, Iterable, List, Mapping from typing import Dict, Iterable, List, Mapping
from app.agents.base import AgentContext from app.agents.base import AgentContext
from app.agents.departments import DepartmentManager
from app.agents.game import Decision, decide from app.agents.game import Decision, decide
from app.agents.registry import default_agents from app.agents.registry import default_agents
from app.utils.config import get_config
from app.utils.db import db_session from app.utils.db import db_session
from app.utils.logging import get_logger
LOGGER = get_logger(__name__)
LOG_EXTRA = {"stage": "backtest"}
@dataclass @dataclass
@ -40,7 +48,15 @@ class BacktestEngine:
def __init__(self, cfg: BtConfig) -> None: def __init__(self, cfg: BtConfig) -> None:
self.cfg = cfg self.cfg = cfg
self.agents = default_agents() self.agents = default_agents()
app_cfg = get_config()
weight_config = app_cfg.agent_weights.as_dict() if app_cfg.agent_weights else {}
if weight_config:
self.weights = weight_config
else:
self.weights = {agent.name: 1.0 for agent in self.agents} self.weights = {agent.name: 1.0 for agent in self.agents}
self.department_manager = (
DepartmentManager(app_cfg.departments) if app_cfg.departments else None
)
def load_market_data(self, trade_date: date) -> Mapping[str, Dict[str, float]]: def load_market_data(self, trade_date: date) -> Mapping[str, Dict[str, float]]:
"""Load per-stock feature vectors. Replace with real data access.""" """Load per-stock feature vectors. Replace with real data access."""
@ -53,7 +69,13 @@ class BacktestEngine:
decisions: List[Decision] = [] decisions: List[Decision] = []
for ts_code, features in feature_map.items(): for ts_code, features in feature_map.items():
context = AgentContext(ts_code=ts_code, trade_date=trade_date.isoformat(), features=features) context = AgentContext(ts_code=ts_code, trade_date=trade_date.isoformat(), features=features)
decision = decide(context, self.agents, self.weights, method=self.cfg.method) decision = decide(
context,
self.agents,
self.weights,
method=self.cfg.method,
department_manager=self.department_manager,
)
decisions.append(decision) decisions.append(decision)
self.record_agent_state(context, decision) self.record_agent_state(context, decision)
# TODO: translate decisions into fills, holdings, and NAV updates. # TODO: translate decisions into fills, holdings, and NAV updates.
@ -66,9 +88,88 @@ class BacktestEngine:
"ts_code": context.ts_code, "ts_code": context.ts_code,
"action": decision.action.value, "action": decision.action.value,
"confidence": decision.confidence, "confidence": decision.confidence,
"department_votes": decision.department_votes,
"requires_review": decision.requires_review,
"departments": {
code: dept.to_dict()
for code, dept in decision.department_decisions.items()
},
} }
combined_weights = dict(self.weights)
if self.department_manager:
for code, agent in self.department_manager.agents.items():
key = f"dept_{code}"
combined_weights[key] = agent.settings.weight
feasible_json = json.dumps(
[action.value for action in decision.feasible_actions],
ensure_ascii=False,
)
rows = []
for agent_name, weight in combined_weights.items():
action_scores = {
action.value: float(decision.utilities.get(action, {}).get(agent_name, 0.0))
for action in decision.utilities.keys()
}
best_action = decision.action.value
if action_scores:
best_action = max(action_scores.items(), key=lambda item: item[1])[0]
metadata: Dict[str, object] = {}
if agent_name.startswith("dept_"):
dept_code = agent_name.split("dept_", 1)[-1]
dept_decision = decision.department_decisions.get(dept_code)
if dept_decision:
metadata = {
"_summary": dept_decision.summary,
"_signals": dept_decision.signals,
"_risks": dept_decision.risks,
"_confidence": dept_decision.confidence,
}
payload_json = {**action_scores, **metadata}
rows.append(
(
context.trade_date,
context.ts_code,
agent_name,
best_action,
json.dumps(payload_json, ensure_ascii=False),
feasible_json,
float(weight),
)
)
global_payload = {
"_confidence": decision.confidence,
"_target_weight": decision.target_weight,
"_department_votes": decision.department_votes,
"_requires_review": decision.requires_review,
}
rows.append(
(
context.trade_date,
context.ts_code,
"global",
decision.action.value,
json.dumps(global_payload, ensure_ascii=False),
feasible_json,
1.0,
)
)
try:
with db_session() as conn:
conn.executemany(
"""
INSERT OR REPLACE INTO agent_utils
(trade_date, ts_code, agent, action, utils, feasible, weight)
VALUES (?, ?, ?, ?, ?, ?, ?)
""",
rows,
)
except Exception:
LOGGER.exception("写入 agent_utils 失败", extra=LOG_EXTRA)
_ = payload _ = payload
# Implementation should persist into agent_utils and bt_trades. # TODO: persist payload into bt_trades / audit tables when schema is ready.
def run(self) -> BacktestResult: def run(self) -> BacktestResult:
state = PortfolioState() state = PortfolioState()

View File

@ -8,7 +8,13 @@ from typing import Dict, Iterable, List, Optional
import requests import requests
from app.utils.config import DEFAULT_LLM_BASE_URLS, DEFAULT_LLM_MODELS, LLMEndpoint, get_config from app.utils.config import (
DEFAULT_LLM_BASE_URLS,
DEFAULT_LLM_MODELS,
LLMConfig,
LLMEndpoint,
get_config,
)
from app.utils.logging import get_logger from app.utils.logging import get_logger
LOGGER = get_logger(__name__) LOGGER = get_logger(__name__)
@ -140,15 +146,13 @@ def _normalize_response(text: str) -> str:
def run_llm(prompt: str, *, system: Optional[str] = None) -> str: def run_llm(prompt: str, *, system: Optional[str] = None) -> str:
"""Execute the configured LLM strategy with the given prompt.""" """Execute the globally configured LLM strategy with the given prompt."""
settings = get_config().llm settings = get_config().llm
if settings.strategy == "majority": return run_llm_with_config(settings, prompt, system=system)
return _run_majority_vote(settings, prompt, system)
return _call_endpoint(settings.primary, prompt, system)
def _run_majority_vote(config, prompt: str, system: Optional[str]) -> str: def _run_majority_vote(config: LLMConfig, prompt: str, system: Optional[str]) -> str:
endpoints: List[LLMEndpoint] = [config.primary] + list(config.ensemble) endpoints: List[LLMEndpoint] = [config.primary] + list(config.ensemble)
responses: List[Dict[str, str]] = [] responses: List[Dict[str, str]] = []
failures: List[str] = [] failures: List[str] = []
@ -199,6 +203,72 @@ def _run_majority_vote(config, prompt: str, system: Optional[str]) -> str:
return responses[0]["raw"] return responses[0]["raw"]
def _run_leader_follow(config: LLMConfig, prompt: str, system: Optional[str]) -> str:
advisors: List[Dict[str, str]] = []
for endpoint in config.ensemble:
try:
raw = _call_endpoint(endpoint, prompt, system)
advisors.append(
{
"provider": endpoint.provider,
"model": endpoint.model or "",
"raw": raw,
}
)
except Exception as exc: # noqa: BLE001
LOGGER.warning(
"顾问模型调用失败:%s:%s -> %s",
endpoint.provider,
endpoint.model,
exc,
extra=LOG_EXTRA,
)
if not advisors:
LOGGER.info("领导者策略顾问为空,回退至主模型", extra=LOG_EXTRA)
return _call_endpoint(config.primary, prompt, system)
advisor_chunks = []
for idx, record in enumerate(advisors, start=1):
snippet = record["raw"].strip()
if len(snippet) > 1200:
snippet = snippet[:1200] + "..."
advisor_chunks.append(
f"顾问#{idx} ({record['provider']}:{record['model']}):\n{snippet}"
)
advisor_section = "\n\n".join(advisor_chunks)
leader_prompt = (
"【顾问模型意见】\n"
f"{advisor_section}\n\n"
"请在充分参考顾问模型观点的基础上,保持原始指令的输出格式进行最终回答。\n\n"
f"{prompt}"
)
LOGGER.info(
"领导者策略触发:顾问数量=%s",
len(advisors),
extra=LOG_EXTRA,
)
return _call_endpoint(config.primary, leader_prompt, system)
def run_llm_with_config(
config: LLMConfig,
prompt: str,
*,
system: Optional[str] = None,
) -> str:
"""Execute an LLM request using the provided configuration block."""
strategy = (config.strategy or "single").lower()
if strategy == "leader-follower":
strategy = "leader"
if strategy == "majority":
return _run_majority_vote(config, prompt, system)
if strategy == "leader":
return _run_leader_follow(config, prompt, system)
return _call_endpoint(config.primary, prompt, system)
def llm_config_snapshot() -> Dict[str, object]: def llm_config_snapshot() -> Dict[str, object]:
"""Return a sanitized snapshot of current LLM configuration for debugging.""" """Return a sanitized snapshot of current LLM configuration for debugging."""

View File

@ -1,7 +1,11 @@
"""Prompt templates for natural language outputs.""" """Prompt templates for natural language outputs."""
from __future__ import annotations from __future__ import annotations
from typing import Dict from typing import Dict, TYPE_CHECKING
if TYPE_CHECKING: # pragma: no cover
from app.utils.config import DepartmentSettings
from app.agents.departments import DepartmentContext
def plan_prompt(data: Dict) -> str: def plan_prompt(data: Dict) -> str:
@ -9,3 +13,38 @@ def plan_prompt(data: Dict) -> str:
_ = data _ = data
return "你是一个投资助理,请根据提供的数据给出三条要点和两条风险提示。" return "你是一个投资助理,请根据提供的数据给出三条要点和两条风险提示。"
def department_prompt(settings: "DepartmentSettings", context: "DepartmentContext") -> str:
"""Compose a structured prompt for department-level LLM ensemble."""
feature_lines = "\n".join(
f"- {key}: {value}" for key, value in sorted(context.features.items())
)
market_lines = "\n".join(
f"- {key}: {value}" for key, value in sorted(context.market_snapshot.items())
)
instructions = f"""
部门名称{settings.title}
股票代码{context.ts_code}
交易日{context.trade_date}
核心特征
{feature_lines or '- (无)'}
市场背景
{market_lines or '- (无)'}
请基于以上数据给出该部门对当前股票的操作建议输出必须是 JSON字段如下
{{
"action": "BUY|BUY_S|BUY_M|BUY_L|SELL|HOLD",
"confidence": 0-1 之间的小数表示信心
"summary": "一句话概括理由",
"signals": ["详细要点", "..."],
"risks": ["风险点", "..."]
}}
请严格返回单个 JSON 对象不要添加额外文本
"""
return instructions.strip()

View File

@ -5,12 +5,14 @@ import sys
from dataclasses import asdict from dataclasses import asdict
from datetime import date, timedelta from datetime import date, timedelta
from pathlib import Path from pathlib import Path
from typing import List from typing import Dict, List
ROOT = Path(__file__).resolve().parents[2] ROOT = Path(__file__).resolve().parents[2]
if str(ROOT) not in sys.path: if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT)) sys.path.insert(0, str(ROOT))
import json
import pandas as pd import pandas as pd
import plotly.express as px import plotly.express as px
import plotly.graph_objects as go import plotly.graph_objects as go
@ -21,11 +23,12 @@ from app.data.schema import initialize_database
from app.ingest.checker import run_boot_check from app.ingest.checker import run_boot_check
from app.ingest.tushare import FetchJob, run_ingestion from app.ingest.tushare import FetchJob, run_ingestion
from app.llm.client import llm_config_snapshot, run_llm from app.llm.client import llm_config_snapshot, run_llm
from app.llm.explain import make_human_card
from app.utils.config import ( from app.utils.config import (
ALLOWED_LLM_STRATEGIES,
DEFAULT_LLM_BASE_URLS, DEFAULT_LLM_BASE_URLS,
DEFAULT_LLM_MODEL_OPTIONS, DEFAULT_LLM_MODEL_OPTIONS,
DEFAULT_LLM_MODELS, DEFAULT_LLM_MODELS,
DepartmentSettings,
LLMEndpoint, LLMEndpoint,
get_config, get_config,
save_config, save_config,
@ -102,10 +105,153 @@ def _load_daily_frame(ts_code: str, start: date, end: date) -> pd.DataFrame:
def render_today_plan() -> None: def render_today_plan() -> None:
LOGGER.info("渲染今日计划页面", extra=LOG_EXTRA) LOGGER.info("渲染今日计划页面", extra=LOG_EXTRA)
st.header("今日计划") st.header("今日计划")
st.write("待接入候选池筛选与多智能体决策结果。") try:
sample = make_human_card("000001.SZ", "2025-01-01", {"decisions": []}) with db_session(read_only=True) as conn:
LOGGER.debug("示例卡片内容:%s", sample, extra=LOG_EXTRA) date_rows = conn.execute(
st.json(sample) """
SELECT DISTINCT trade_date
FROM agent_utils
ORDER BY trade_date DESC
LIMIT 30
"""
).fetchall()
except Exception: # noqa: BLE001
LOGGER.exception("加载 agent_utils 失败", extra=LOG_EXTRA)
st.warning("暂未写入部门/代理决策,请先运行回测或策略评估流程。")
return
trade_dates = [row["trade_date"] for row in date_rows]
if not trade_dates:
st.info("暂无决策记录,完成一次回测后即可在此查看部门意见与投票结果。")
return
trade_date = st.selectbox("交易日", trade_dates, index=0)
with db_session(read_only=True) as conn:
code_rows = conn.execute(
"""
SELECT DISTINCT ts_code
FROM agent_utils
WHERE trade_date = ?
ORDER BY ts_code
""",
(trade_date,),
).fetchall()
symbols = [row["ts_code"] for row in code_rows]
if not symbols:
st.info("所选交易日暂无 agent_utils 记录。")
return
ts_code = st.selectbox("标的", symbols, index=0)
with db_session(read_only=True) as conn:
rows = conn.execute(
"""
SELECT agent, action, utils, feasible, weight
FROM agent_utils
WHERE trade_date = ? AND ts_code = ?
ORDER BY CASE WHEN agent = 'global' THEN 1 ELSE 0 END, agent
""",
(trade_date, ts_code),
).fetchall()
if not rows:
st.info("未查询到详细决策记录,稍后再试。")
return
try:
feasible_actions = json.loads(rows[0]["feasible"] or "[]")
except (KeyError, TypeError, json.JSONDecodeError):
feasible_actions = []
global_info = None
dept_records: List[Dict[str, object]] = []
agent_records: List[Dict[str, object]] = []
for item in rows:
agent_name = item["agent"]
action = item["action"]
weight = float(item["weight"] or 0.0)
try:
utils = json.loads(item["utils"] or "{}")
except json.JSONDecodeError:
utils = {}
if agent_name == "global":
global_info = {
"action": action,
"confidence": float(utils.get("_confidence", 0.0)),
"target_weight": float(utils.get("_target_weight", 0.0)),
"department_votes": utils.get("_department_votes", {}),
"requires_review": bool(utils.get("_requires_review", False)),
}
continue
if agent_name.startswith("dept_"):
code = agent_name.split("dept_", 1)[-1]
signals = utils.get("_signals", [])
risks = utils.get("_risks", [])
dept_records.append(
{
"部门": code,
"行动": action,
"信心": float(utils.get("_confidence", 0.0)),
"权重": weight,
"摘要": utils.get("_summary", ""),
"核心信号": "".join(signals) if isinstance(signals, list) else signals,
"风险提示": "".join(risks) if isinstance(risks, list) else risks,
}
)
else:
score_map = {
key: float(val)
for key, val in utils.items()
if not str(key).startswith("_")
}
agent_records.append(
{
"代理": agent_name,
"建议动作": action,
"权重": weight,
"SELL": score_map.get("SELL", 0.0),
"HOLD": score_map.get("HOLD", 0.0),
"BUY_S": score_map.get("BUY_S", 0.0),
"BUY_M": score_map.get("BUY_M", 0.0),
"BUY_L": score_map.get("BUY_L", 0.0),
}
)
if feasible_actions:
st.caption(f"可行操作集合:{', '.join(feasible_actions)}")
st.subheader("全局策略")
if global_info:
col1, col2, col3 = st.columns(3)
col1.metric("最终行动", global_info["action"])
col2.metric("信心", f"{global_info['confidence']:.2f}")
col3.metric("目标权重", f"{global_info['target_weight']:+.2%}")
if global_info["department_votes"]:
st.json(global_info["department_votes"])
if global_info["requires_review"]:
st.warning("部门分歧较大,已标记为需人工复核。")
else:
st.info("暂未写入全局策略摘要。")
st.subheader("部门意见")
if dept_records:
dept_df = pd.DataFrame(dept_records)
st.dataframe(dept_df, use_container_width=True, hide_index=True)
else:
st.info("暂无部门记录。")
st.subheader("代理评分")
if agent_records:
agent_df = pd.DataFrame(agent_records)
st.dataframe(agent_df, use_container_width=True, hide_index=True)
else:
st.info("暂无基础代理评分。")
st.caption("以上内容来源于 agent_utils 表,可通过回测或实时评估自动更新。")
def render_backtest() -> None: def render_backtest() -> None:
@ -281,7 +427,7 @@ def render_settings() -> None:
step=5, step=5,
) )
strategy_options = ["single", "majority"] strategy_options = ["single", "majority", "leader"]
try: try:
strategy_index = strategy_options.index(llm_cfg.strategy) strategy_index = strategy_options.index(llm_cfg.strategy)
except ValueError: except ValueError:
@ -393,6 +539,116 @@ def render_settings() -> None:
st.success("LLM 设置已保存,仅在当前会话生效。") st.success("LLM 设置已保存,仅在当前会话生效。")
st.json(llm_config_snapshot()) st.json(llm_config_snapshot())
st.divider()
st.subheader("部门配置")
dept_settings = cfg.departments or {}
dept_rows = [
{
"code": code,
"title": dept.title,
"description": dept.description,
"weight": float(dept.weight),
"strategy": dept.llm.strategy,
"primary_provider": (dept.llm.primary.provider or "ollama"),
"primary_model": dept.llm.primary.model or "",
"ensemble_size": len(dept.llm.ensemble),
}
for code, dept in sorted(dept_settings.items())
]
if not dept_rows:
st.info("当前未配置部门,可在 config.json 中添加。")
dept_rows = []
dept_editor = st.data_editor(
dept_rows,
num_rows="fixed",
key="department_editor",
use_container_width=True,
hide_index=True,
column_config={
"code": st.column_config.TextColumn("编码", disabled=True),
"title": st.column_config.TextColumn("名称"),
"description": st.column_config.TextColumn("说明"),
"weight": st.column_config.NumberColumn("权重", min_value=0.0, max_value=10.0, step=0.1),
"strategy": st.column_config.SelectboxColumn(
"策略",
options=sorted(ALLOWED_LLM_STRATEGIES),
help="single=单模型, majority=多数投票, leader=顾问-决策者模式",
),
"primary_provider": st.column_config.SelectboxColumn(
"主模型 Provider",
options=sorted(DEFAULT_LLM_MODEL_OPTIONS.keys()),
),
"primary_model": st.column_config.TextColumn("主模型名称"),
"ensemble_size": st.column_config.NumberColumn(
"协作模型数量",
disabled=True,
help="在 config.json 中编辑 ensemble 详情",
),
},
)
if hasattr(dept_editor, "to_dict"):
dept_rows = dept_editor.to_dict("records")
else:
dept_rows = dept_editor
col_reset, col_save = st.columns([1, 1])
if col_save.button("保存部门配置"):
updated_departments: Dict[str, DepartmentSettings] = {}
for row in dept_rows:
code = row.get("code")
if not code:
continue
existing = dept_settings.get(code) or DepartmentSettings(code=code, title=code)
existing.title = row.get("title") or existing.title
existing.description = row.get("description") or ""
try:
existing.weight = max(0.0, float(row.get("weight", existing.weight)))
except (TypeError, ValueError):
existing.weight = existing.weight
strategy_val = (row.get("strategy") or existing.llm.strategy).lower()
if strategy_val in ALLOWED_LLM_STRATEGIES:
existing.llm.strategy = strategy_val
provider_before = existing.llm.primary.provider or ""
provider_val = (row.get("primary_provider") or provider_before or "ollama").lower()
existing.llm.primary.provider = provider_val
model_val = (row.get("primary_model") or "").strip()
if model_val:
existing.llm.primary.model = model_val
else:
existing.llm.primary.model = DEFAULT_LLM_MODELS.get(provider_val, existing.llm.primary.model)
if provider_before != provider_val:
default_base = DEFAULT_LLM_BASE_URLS.get(provider_val)
existing.llm.primary.base_url = default_base or existing.llm.primary.base_url
existing.llm.primary.__post_init__()
updated_departments[code] = existing
if updated_departments:
cfg.departments = updated_departments
save_config()
st.success("部门配置已更新。")
else:
st.warning("未能解析部门配置输入。")
if col_reset.button("恢复默认部门"):
from app.utils.config import _default_departments
cfg.departments = _default_departments()
save_config()
st.success("已恢复默认部门配置。")
st.experimental_rerun()
st.caption("部门协作模型ensemble请在 config.json 中手动编辑UI 将在后续版本补充。")
def render_tests() -> None: def render_tests() -> None:
LOGGER.info("渲染自检页面", extra=LOG_EXTRA) LOGGER.info("渲染自检页面", extra=LOG_EXTRA)

View File

@ -95,6 +95,9 @@ DEFAULT_LLM_TIMEOUTS: Dict[str, float] = {
for provider, info in DEFAULT_LLM_MODEL_OPTIONS.items() for provider, info in DEFAULT_LLM_MODEL_OPTIONS.items()
} }
ALLOWED_LLM_STRATEGIES = {"single", "majority", "leader"}
LLM_STRATEGY_ALIASES = {"leader-follower": "leader"}
@dataclass @dataclass
class LLMEndpoint: class LLMEndpoint:
@ -125,10 +128,36 @@ class LLMConfig:
primary: LLMEndpoint = field(default_factory=LLMEndpoint) primary: LLMEndpoint = field(default_factory=LLMEndpoint)
ensemble: List[LLMEndpoint] = field(default_factory=list) ensemble: List[LLMEndpoint] = field(default_factory=list)
strategy: str = "single" # Options: single, majority strategy: str = "single" # Options: single, majority, leader
majority_threshold: int = 3 majority_threshold: int = 3
@dataclass
class DepartmentSettings:
"""Configuration for a single decision department."""
code: str
title: str
description: str = ""
weight: float = 1.0
llm: LLMConfig = field(default_factory=LLMConfig)
def _default_departments() -> Dict[str, DepartmentSettings]:
presets = [
("momentum", "动量策略部门"),
("value", "价值评估部门"),
("news", "新闻情绪部门"),
("liquidity", "流动性评估部门"),
("macro", "宏观研究部门"),
("risk", "风险控制部门"),
]
return {
code: DepartmentSettings(code=code, title=title)
for code, title in presets
}
@dataclass @dataclass
class AppConfig: class AppConfig:
"""User configurable settings persisted in a simple structure.""" """User configurable settings persisted in a simple structure."""
@ -140,6 +169,7 @@ class AppConfig:
agent_weights: AgentWeights = field(default_factory=AgentWeights) agent_weights: AgentWeights = field(default_factory=AgentWeights)
force_refresh: bool = False force_refresh: bool = False
llm: LLMConfig = field(default_factory=LLMConfig) llm: LLMConfig = field(default_factory=LLMConfig)
departments: Dict[str, DepartmentSettings] = field(default_factory=_default_departments)
CONFIG = AppConfig() CONFIG = AppConfig()
@ -197,14 +227,53 @@ def _load_from_file(cfg: AppConfig) -> None:
if isinstance(item, dict) if isinstance(item, dict)
] ]
strategy = llm_payload.get("strategy") strategy_raw = llm_payload.get("strategy")
if strategy in {"single", "majority"}: if isinstance(strategy_raw, str):
cfg.llm.strategy = strategy normalized = LLM_STRATEGY_ALIASES.get(strategy_raw, strategy_raw)
if normalized in ALLOWED_LLM_STRATEGIES:
cfg.llm.strategy = normalized
majority = llm_payload.get("majority_threshold") majority = llm_payload.get("majority_threshold")
if isinstance(majority, int) and majority > 0: if isinstance(majority, int) and majority > 0:
cfg.llm.majority_threshold = majority cfg.llm.majority_threshold = majority
departments_payload = payload.get("departments")
if isinstance(departments_payload, dict):
new_departments: Dict[str, DepartmentSettings] = {}
for code, data in departments_payload.items():
if not isinstance(data, dict):
continue
title = data.get("title") or code
description = data.get("description") or ""
weight = float(data.get("weight", 1.0))
llm_data = data.get("llm")
llm_cfg = LLMConfig()
if isinstance(llm_data, dict):
if isinstance(llm_data.get("primary"), dict):
llm_cfg.primary = _dict_to_endpoint(llm_data["primary"])
llm_cfg.ensemble = [
_dict_to_endpoint(item)
for item in llm_data.get("ensemble", [])
if isinstance(item, dict)
]
strategy_raw = llm_data.get("strategy")
if isinstance(strategy_raw, str):
normalized = LLM_STRATEGY_ALIASES.get(strategy_raw, strategy_raw)
if normalized in ALLOWED_LLM_STRATEGIES:
llm_cfg.strategy = normalized
majority_raw = llm_data.get("majority_threshold")
if isinstance(majority_raw, int) and majority_raw > 0:
llm_cfg.majority_threshold = majority_raw
new_departments[code] = DepartmentSettings(
code=code,
title=title,
description=description,
weight=weight,
llm=llm_cfg,
)
if new_departments:
cfg.departments = new_departments
def save_config(cfg: AppConfig | None = None) -> None: def save_config(cfg: AppConfig | None = None) -> None:
cfg = cfg or CONFIG cfg = cfg or CONFIG
@ -214,11 +283,25 @@ def save_config(cfg: AppConfig | None = None) -> None:
"force_refresh": cfg.force_refresh, "force_refresh": cfg.force_refresh,
"decision_method": cfg.decision_method, "decision_method": cfg.decision_method,
"llm": { "llm": {
"strategy": cfg.llm.strategy, "strategy": cfg.llm.strategy if cfg.llm.strategy in ALLOWED_LLM_STRATEGIES else "single",
"majority_threshold": cfg.llm.majority_threshold, "majority_threshold": cfg.llm.majority_threshold,
"primary": _endpoint_to_dict(cfg.llm.primary), "primary": _endpoint_to_dict(cfg.llm.primary),
"ensemble": [_endpoint_to_dict(ep) for ep in cfg.llm.ensemble], "ensemble": [_endpoint_to_dict(ep) for ep in cfg.llm.ensemble],
}, },
"departments": {
code: {
"title": dept.title,
"description": dept.description,
"weight": dept.weight,
"llm": {
"strategy": dept.llm.strategy if dept.llm.strategy in ALLOWED_LLM_STRATEGIES else "single",
"majority_threshold": dept.llm.majority_threshold,
"primary": _endpoint_to_dict(dept.llm.primary),
"ensemble": [_endpoint_to_dict(ep) for ep in dept.llm.ensemble],
},
}
for code, dept in cfg.departments.items()
},
} }
try: try:
path.parent.mkdir(parents=True, exist_ok=True) path.parent.mkdir(parents=True, exist_ok=True)

View File

@ -3,3 +3,4 @@ plotly>=5.18
streamlit>=1.30 streamlit>=1.30
tushare>=1.2 tushare>=1.2
requests>=2.31 requests>=2.31
python-box>=7.0