This commit is contained in:
sam 2025-09-28 18:57:05 +08:00
parent 8b3c6fe690
commit 0a2742b869
5 changed files with 268 additions and 20 deletions

View File

@ -6,9 +6,9 @@
## 架构总览
- **数据与存储层**`app/ingest` 封装 TuShare/RSS 拉数与限频处理,`app/data/schema.py` 初始化 SQLite 表结构,所有模块通过 `app/utils/db.py``db_session` 访问 `app/data/llm_quant.db`
- **数据与存储层**`app/ingest` 封装 TuShare/RSS 拉数与限频处理,`app/data/schema.py` 初始化 SQLite 表结构,所有模块通过 `app/utils/db.py``db_session` 访问 `app/data/llm_quant.db`,数据抽象层由 `app/utils/data_access.py``DataBroker` 统一提供字段查询与时间序列切片
- **工具与配置层**`app/utils` 聚合配置、日志、交易日历及 provider 管理,`app/utils/config.py` 定义 LLM/部门/代理权重等全局设置。
- **特征与策略层**`app/features` 负责信号构建(当前为占位实现),`app/agents` 实现六类规则型代理与部门级 LLM 协同,`app/backtest/engine.py` 运行多智能体博弈并将结果写入 `agent_utils`
- **特征与策略层**`app/features` 负责信号构建(当前为占位实现),`app/agents` 实现六类规则型代理与部门级 LLM 协同,`app/backtest/engine.py` 通过 `DataBroker` 装配特征/上下文后运行多智能体博弈并将结果写入 `agent_utils`
- **LLM 与协作层**`app/llm` 提供统一的模型调用与 Prompt 构建,支持 single/majority/leader 策略,部门输出再与规则代理共同决策。
- **可视化层**`app/ui/streamlit_app.py` 提供今日计划、回测、设置、自检四大页签,实时读取 `agent_utils`、`run_log` 追踪决策链路。
@ -38,6 +38,8 @@
3. **双阶段 LLM 工作流**:第一阶段让 LLM 输出结构化 `data_requests`,服务端取数后将摘要回填,第二阶段再生成最终行动与解释,形成闭环。
4. **审计与前端联动**:把角色提示、数据请求与执行摘要写入 `agent_utils` 附加字段,使 Streamlit 能完整呈现“角色 → 请求 → 决策”的链条。
目前部门 LLM 已支持通过在 JSON 中返回 `data_requests` 触发追加查询:系统会使用 `DataBroker` 验证字段后补充最近数据窗口,再带着查询结果进入下一轮提示,从而形成“请求→取数→复议”的闭环。
上述调整可在单个部门先行做 PoC验证闭环能力后再推广至全部角色。
## 核心技术原理

View File

@ -3,18 +3,25 @@ from __future__ import annotations
import json
from dataclasses import dataclass, field
from typing import Any, Callable, Dict, List, Mapping, Optional
from typing import Any, Callable, Dict, List, Mapping, Optional, Sequence, Tuple
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 AppConfig, DepartmentSettings, LLMConfig
from app.utils.logging import get_logger
from app.utils.data_access import DataBroker, parse_field_path
LOGGER = get_logger(__name__)
LOG_EXTRA = {"stage": "department"}
@dataclass
class DataRequest:
field: str
window: int = 1
@dataclass
class DepartmentContext:
"""Structured data fed into a department for decision making."""
@ -37,6 +44,8 @@ class DepartmentDecision:
raw_response: str
signals: List[str] = field(default_factory=list)
risks: List[str] = field(default_factory=list)
supplements: List[Dict[str, Any]] = field(default_factory=list)
dialogue: List[str] = field(default_factory=list)
def to_dict(self) -> Dict[str, Any]:
return {
@ -47,6 +56,8 @@ class DepartmentDecision:
"signals": self.signals,
"risks": self.risks,
"raw_response": self.raw_response,
"supplements": self.supplements,
"dialogue": self.dialogue,
}
@ -60,6 +71,8 @@ class DepartmentAgent:
) -> None:
self.settings = settings
self._resolver = resolver
self._broker = DataBroker()
self._max_rounds = 3
def _get_llm_config(self) -> LLMConfig:
if self._resolver:
@ -67,24 +80,79 @@ class DepartmentAgent:
return self.settings.llm
def analyze(self, context: DepartmentContext) -> DepartmentDecision:
prompt = department_prompt(self.settings, context)
mutable_context = _ensure_mutable_context(context)
system_prompt = (
"你是一个多智能体量化投研系统中的分部决策者,需要根据提供的结构化信息给出买卖意见。"
)
llm_cfg = self._get_llm_config()
try:
response = run_llm_with_config(llm_cfg, 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),
)
supplement_chunks: List[str] = []
transcript: List[str] = []
delivered_requests = {
(field, 1)
for field in (mutable_context.raw.get("scope_values") or {}).keys()
}
response = ""
decision_data: Dict[str, Any] = {}
for round_idx in range(self._max_rounds):
supplement_text = "\n\n".join(chunk for chunk in supplement_chunks if chunk)
prompt = department_prompt(self.settings, mutable_context, supplements=supplement_text)
try:
response = run_llm_with_config(llm_cfg, 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),
)
transcript.append(response)
decision_data = _parse_department_response(response)
data_requests = _parse_data_requests(decision_data)
filtered_requests = [
req
for req in data_requests
if (req.field, req.window) not in delivered_requests
]
if filtered_requests and round_idx < self._max_rounds - 1:
lines, payload, delivered = self._fulfill_data_requests(
mutable_context, filtered_requests
)
if payload:
supplement_chunks.append(
f"回合 {round_idx + 1} 追加数据:\n" + "\n".join(lines)
)
mutable_context.raw.setdefault("supplement_data", []).extend(payload)
mutable_context.raw.setdefault("supplement_rounds", []).append(
{
"round": round_idx + 1,
"requests": [req.__dict__ for req in filtered_requests],
"data": payload,
}
)
delivered_requests.update(delivered)
decision_data.pop("data_requests", None)
continue
LOGGER.debug(
"部门 %s 数据请求无结果:%s",
self.settings.code,
filtered_requests,
extra=LOG_EXTRA,
)
decision_data.pop("data_requests", None)
break
mutable_context.raw["supplement_transcript"] = list(transcript)
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 ""
@ -102,7 +170,9 @@ class DepartmentAgent:
summary=summary or "未提供摘要",
signals=[str(sig) for sig in signals if sig],
risks=[str(risk) for risk in risks if risk],
raw_response=response,
raw_response="\n\n".join(transcript) if transcript else response,
supplements=list(mutable_context.raw.get("supplement_data", [])),
dialogue=list(transcript),
)
LOGGER.debug(
"部门 %s 决策action=%s confidence=%.2f",
@ -113,6 +183,124 @@ class DepartmentAgent:
)
return decision
@staticmethod
def _normalize_trade_date(value: str) -> str:
if not isinstance(value, str):
return str(value)
return value.replace("-", "")
def _fulfill_data_requests(
self,
context: DepartmentContext,
requests: Sequence[DataRequest],
) -> Tuple[List[str], List[Dict[str, Any]], set[Tuple[str, int]]]:
lines: List[str] = []
payload: List[Dict[str, Any]] = []
delivered: set[Tuple[str, int]] = set()
ts_code = context.ts_code
trade_date = self._normalize_trade_date(context.trade_date)
latest_fields: List[str] = []
series_requests: List[Tuple[DataRequest, Tuple[str, str]]] = []
for req in requests:
field = req.field.strip()
if not field:
continue
if req.window <= 1:
if field not in latest_fields:
latest_fields.append(field)
delivered.add((field, 1))
continue
parsed = parse_field_path(field)
if not parsed:
lines.append(f"- {field}: 字段不合法,已忽略")
continue
series_requests.append((req, parsed))
delivered.add((field, req.window))
if latest_fields:
latest_values = self._broker.fetch_latest(ts_code, trade_date, latest_fields)
for field in latest_fields:
value = latest_values.get(field)
if value is None:
lines.append(f"- {field}: (数据缺失)")
else:
lines.append(f"- {field}: {value}")
payload.append({"field": field, "window": 1, "values": value})
for req, parsed in series_requests:
table, column = parsed
series = self._broker.fetch_series(
table,
column,
ts_code,
trade_date,
window=req.window,
)
if series:
preview = ", ".join(
f"{dt}:{val:.4f}"
for dt, val in series[: min(len(series), 5)]
)
lines.append(
f"- {req.field} (window={req.window}): {preview}"
)
else:
lines.append(
f"- {req.field} (window={req.window}): (数据缺失)"
)
payload.append({"field": req.field, "window": req.window, "values": series})
return lines, payload, delivered
def _ensure_mutable_context(context: DepartmentContext) -> DepartmentContext:
if not isinstance(context.features, dict):
context.features = dict(context.features or {})
if not isinstance(context.market_snapshot, dict):
context.market_snapshot = dict(context.market_snapshot or {})
raw = dict(context.raw or {})
scope_values = raw.get("scope_values")
if scope_values is not None and not isinstance(scope_values, dict):
raw["scope_values"] = dict(scope_values)
context.raw = raw
return context
def _parse_data_requests(payload: Mapping[str, Any]) -> List[DataRequest]:
raw_requests = payload.get("data_requests")
requests: List[DataRequest] = []
if not isinstance(raw_requests, list):
return requests
seen: set[Tuple[str, int]] = set()
for item in raw_requests:
field = ""
window = 1
if isinstance(item, str):
field = item.strip()
elif isinstance(item, Mapping):
candidate = item.get("field")
if candidate is None:
continue
field = str(candidate).strip()
try:
window = int(item.get("window", 1))
except (TypeError, ValueError):
window = 1
else:
continue
if not field:
continue
window = max(1, window)
key = (field, window)
if key in seen:
continue
seen.add(key)
requests.append(DataRequest(field=field, window=window))
return requests
class DepartmentManager:
"""Orchestrates all departments defined in configuration."""
@ -127,7 +315,17 @@ class DepartmentManager:
def evaluate(self, context: DepartmentContext) -> Dict[str, DepartmentDecision]:
results: Dict[str, DepartmentDecision] = {}
for code, agent in self.agents.items():
results[code] = agent.analyze(context)
raw_base = dict(context.raw or {})
if "scope_values" in raw_base:
raw_base["scope_values"] = dict(raw_base.get("scope_values") or {})
dept_context = DepartmentContext(
ts_code=context.ts_code,
trade_date=context.trade_date,
features=dict(context.features or {}),
market_snapshot=dict(context.market_snapshot or {}),
raw=raw_base,
)
results[code] = agent.analyze(dept_context)
return results
def _resolve_llm(self, settings: DepartmentSettings) -> LLMConfig:

View File

@ -170,6 +170,14 @@ class BacktestEngine:
if down_limit and latest_close:
limit_down = latest_close <= down_limit * 1.001
is_suspended = self.data_broker.fetch_flags(
"suspend",
ts_code,
trade_date_str,
"suspend_date <= ? AND (resume_date IS NULL OR resume_date > ?)",
(trade_date_str, trade_date_str),
)
features = {
"mom_20": mom20,
"mom_60": mom60,
@ -185,7 +193,7 @@ class BacktestEngine:
scope_values.get("index.performance_peers", 0.0),
),
"risk_penalty": min(1.0, volat20 * 5.0),
"is_suspended": False,
"is_suspended": is_suspended,
"limit_up": limit_up,
"limit_down": limit_down,
"position_limit": False,
@ -205,6 +213,7 @@ class BacktestEngine:
"scope_values": scope_values,
"close_series": closes,
"turnover_series": turnover_series,
"required_fields": self.required_fields,
}
feature_map[ts_code] = {
@ -285,6 +294,10 @@ class BacktestEngine:
"_risks": dept_decision.risks,
"_confidence": dept_decision.confidence,
}
if dept_decision.supplements:
metadata["_supplements"] = dept_decision.supplements
if dept_decision.dialogue:
metadata["_dialogue"] = dept_decision.dialogue
payload_json = {**action_scores, **metadata}
rows.append(
(
@ -303,6 +316,19 @@ class BacktestEngine:
"_target_weight": decision.target_weight,
"_department_votes": decision.department_votes,
"_requires_review": decision.requires_review,
"_scope_values": context.raw.get("scope_values", {}),
"_close_series": context.raw.get("close_series", []),
"_turnover_series": context.raw.get("turnover_series", []),
"_department_supplements": {
code: dept.supplements
for code, dept in decision.department_decisions.items()
if dept.supplements
},
"_department_dialogue": {
code: dept.dialogue
for code, dept in decision.department_decisions.items()
if dept.dialogue
},
}
rows.append(
(

View File

@ -15,7 +15,11 @@ def plan_prompt(data: Dict) -> str:
return "你是一个投资助理,请根据提供的数据给出三条要点和两条风险提示。"
def department_prompt(settings: "DepartmentSettings", context: "DepartmentContext") -> str:
def department_prompt(
settings: "DepartmentSettings",
context: "DepartmentContext",
supplements: str = "",
) -> str:
"""Compose a structured prompt for department-level LLM ensemble."""
feature_lines = "\n".join(
@ -27,6 +31,7 @@ def department_prompt(settings: "DepartmentSettings", context: "DepartmentContex
scope_lines = "\n".join(f"- {item}" for item in settings.data_scope)
role_description = settings.description.strip()
role_instruction = settings.prompt.strip()
supplement_block = supplements.strip()
instructions = f"""
部门名称{settings.title}
@ -45,6 +50,9 @@ def department_prompt(settings: "DepartmentSettings", context: "DepartmentContex
市场背景
{market_lines or '- (无)'}
追加数据
{supplement_block or '- 当前无追加数据'}
请基于以上数据给出该部门对当前股票的操作建议输出必须是 JSON字段如下
{{
"action": "BUY|BUY_S|BUY_M|BUY_L|SELL|HOLD",
@ -54,6 +62,14 @@ def department_prompt(settings: "DepartmentSettings", context: "DepartmentContex
"risks": ["风险点", "..."]
}}
如需额外数据请在同一 JSON 中添加可选字段 `"data_requests"`其取值为数组例如
"data_requests": [
{{"field": "daily.close", "window": 5}},
{{"field": "daily_basic.pe"}}
]
其中 `field` 必须属于可用数据范围或明确说明新增需求`window` 表示希望返回的最近数据点数量省略时默认为 1
如果不需要更多数据请不要返回 `data_requests`
请严格返回单个 JSON 对象不要添加额外文本
"""
return instructions.strip()

View File

@ -32,6 +32,12 @@ def _safe_split(path: str) -> Tuple[str, str] | None:
return table, column
def parse_field_path(path: str) -> Tuple[str, str] | None:
"""Validate and split a `table.column` field expression."""
return _safe_split(path)
@dataclass
class DataBroker:
"""Lightweight data access helper for agent/LLM consumption."""