* feat: rebuild market structure context * fix(review-feedback-1981): apps/dsa-web/src/components/report/MarketStructureCard.tsx 新增用户可见界面,但 * fix(review-feedback-1981): apps/dsa-web/src/components/report/MarketStructureCard.tsx 新增用户可见界面,但 * fix(review-feedback-1981): Scope in-flight ranking keys to each fetcher manager * fix(review-feedback-1981): 修正无证据情况下的个股层级判定,并按仓库规范补充可访问的 Web 页面截图、更新 PR 描述事实 * fix(review-feedback-1981): apps/dsa-web/src/components/report/MarketStructureCard.tsx 新增用户可见界面,但 * fix(review-feedback-1981): 按 AGENTS.md 为新增市场结构卡片补充可直接查看的截图,并同步 PR 描述中的 diff 与 CI 事实 * fix(review-feedback-1981): apps/dsa-web/src/components/report/MarketStructureCard.tsx 新增用户可见界面,但 * fix(review-feedback-1981): 按仓库规范补充可直接审查的市场结构卡片截图,并同步 PR 描述中的 diff 与 CI 事实 * fix(review-feedback-1981): apps/dsa-web/e2e/market-structure-card-visual.spec.ts:276 在 * fix(review-feedback-1981): apps/dsa-web/e2e/market-structure-card-visual.spec.ts 将本 PR * fix(review-feedback-1981): 同步当前 Head 的 diff/CI 结论,并按仓库规范在 PR 描述或评论中附可访问的市场结构卡片截图 * fix(review-feedback-1981): apps/dsa-web/e2e/market-structure-card-visual.spec.ts:261 新增 async 空对象解构 * fix(review-feedback-1981): 按仓库规范在 PR 描述或评论中附市场结构卡片的可访问截图,并同步当前 diff 统计和 CI 结果 * fix(review-feedback-1981): 消除当前运行结果对历史持久化成功的依赖,补充对应回归测试,并补齐可访问的 UI 截图及更新失真的 PR 描述 * fix(review-feedback-1981): src/utils/data processing.py 提取榜单时丢弃了上游 boards / concept boards 的 * fix(review-feedback-1981): 修正 PR 描述并补齐可访问的视觉证据 * fix(review-feedback-1981): 关闭市场结构前置数据获取可能阻塞主分析的问题,并同步修正 PR 描述、范围清单和视觉证据 * fix(review-feedback-1981): 将 PR 描述、完整范围、当前 CI 证据、视觉证据及回滚表述同步到最新 Head * fix(review-feedback-1981): PR 描述与最新 Head 不一致:完整 diff 包含 38 个文件及 src/services/analysis * fix(review-feedback-1981): 按最新 Head 同步 PR 描述,并补充可访问的市场结构卡片截图证据 * fix(review-feedback-1981): 统一即时响应与历史响应的 details.raw result 契约并补最终 API 回归测试,同时更新 PR 描述和可访问的视觉证据 * fix(review-feedback-1981): 将 PR 描述、完整范围、CI 结果及可访问的 UI 截图同步到最新 Head,消除描述与实际改动的实质性不一致 * fix(review-feedback-1981): 同步 PR 描述与最新 Head,并补充可访问的 Web 页面截图 * fix(review-feedback-1981): 同步 PR 描述并按仓库规范附上可访问的 Web 页面截图 * fix(review-feedback-1981): 代码结论 :不可。已按 origin/main...76b5b596fdff 的完整 38 文件 diff 重新建立基线。上次 6 个 * fix(review-feedback-1981): 代码结论 :不可。最新修复已正确收窄无成分股、无龙头证据的原生榜单路径:该路径现在返回 edge/partial,不再直接输出
19 KiB
DecisionSignal 决策信号专题
本页收口 #1390 P7,说明 DSA 如何把个股分析、Agent、告警和组合风险中的 AI 建议沉淀为可查询、可反馈、可后验评估的 DecisionSignal 资产。它是报告之上的结构化索引,不替代 Markdown 报告、operation_advice、三态 decision_type、告警规则或真实交易系统。
能力边界
DecisionSignal只记录建议、证据摘要、风险、观察条件、生命周期和来源,不执行下单或调仓。- 写入失败、提取失败、告警信号关联失败和通知发送失败都不阻断主分析、告警触发或报告保存。
- #1756 只将
decision_profile字段化并修正 server-side filter、去重、续期和 active 失效语义;不新增环境变量、config registry 项或.env.example内容。 - 当前没有
DECISION_SIGNAL_*开关;信号功能的关闭或回滚通过 revert 对应代码完成。
字段与枚举
核心字段由 api/v1/schemas/decision_signals.py 定义,主要包括:
- 身份与来源:
stock_code、stock_name、market、source_type、source_agent、source_report_id、trace_id、decision_profile、trigger_source。 - 建议语义:
action、action_label、confidence、score、horizon、market_phase、plan_quality、status。 - 计划与解释:
entry_low、entry_high、stop_loss、target_price、invalidation、watch_conditions、reason、risk_summary、catalyst_summary。 - 证据与质量:
evidence、data_quality_summary、metadata。 - 生命周期:
expires_at、created_at、updated_at。
枚举取值:
| 字段 | 取值 |
|---|---|
market |
cn、hk、us、jp、kr、tw |
source_type |
analysis、agent、alert、market_review、manual |
market_phase |
premarket、intraday、lunch_break、closing_auction、postmarket、non_trading、unknown |
action |
buy、add、hold、reduce、sell、watch、avoid、alert |
horizon |
intraday、1d、3d、5d、10d、swing、long |
decision_profile |
conservative、balanced、aggressive;数据库 NULL 表示 legacy / unknown |
plan_quality |
complete、partial、minimal、unknown |
status |
active、expired、invalidated、closed、archived |
Web 展示必须把这些 wire value 映射为当前 UI 语言的用户可读标签;API 响应继续保留原始枚举值。
Canonical 评分与 action 口径
个股分析、技术评分 fallback、报告展示 fallback 与 DecisionSignal 提取共用 decision-scale-v1 口径。decision_type 只保留 buy|hold|sell 兼容统计;更细的可执行语义以八态 action 为准。
- 用户侧可见面存在两类字段:
operation_advice保留文本口径(如“持有观察”),action作为统一 8 态决策口径(如hold/watch/reduce)用于风控、回测与列表展示。新生成或最终保存前重算的个股报告应优先让两者保持一致;历史记录或兼容载荷仍出现语义冲突时,默认以action为列表、回测、DecisionSignal 等结构化展示的优先字段,operation_advice仅作说明文本保留。
| score | signal key | action |
legacy decision_type |
语义 |
|---|---|---|---|---|
| 80-100 | strong_buy |
buy |
buy |
强烈买入,高胜率机会,可执行买入/加仓计划 |
| 60-79 | buy |
buy |
buy |
偏积极机会,允许少量待确认项 |
| 40-59 | watch |
watch |
hold |
信号分歧或确认不足,等待触发条件 |
| 20-39 | reduce |
reduce |
sell |
风险明显抬升,优先降低暴露 |
| 0-19 | sell |
sell |
sell |
趋势或风险显著恶化,优先退出 |
如果 score >= 60 但最终 action 是 hold/watch,或 score < 40 但最终 action 仍是 hold/watch,必须有明确 guardrail 解释,例如 dashboard.decision_stability.reason、dashboard.decision_score_calibration.guardrail_reason 或 metadata.guardrail_reason。风控降级会保留 raw_score、adjusted_score、raw_action、final_action 和原因;没有明确原因的中性动作在 DecisionSignal 提取时会按 canonical score 对齐为 buy/reduce/sell。
生命周期、去重与状态
src/services/decision_signal_service.py 是信号生命周期的主入口:
horizon和expires_at显式传入时优先。- 未传
horizon时,alert或盘前/盘中/午间休市/集合竞价阶段默认intraday,盘后、非交易时段、未知阶段或缺少阶段时默认3d。 intraday过期时间优先读取低敏metadata.market_phase_summary.minutes_to_close/minutes_to_open;缺失时按市场 fallback TTL。expired、invalidated、closed、archived不能通过PATCH /status直接恢复为active。- 同源去重优先使用
(source_report_id, source_type, market, stock_code, decision_profile, action, horizon, market_phase);没有 report 但有trace_id时使用 trace 维度。 decision_profile参与信号身份:NULL只与NULL匹配,非空 profile 只与相同 profile 匹配。Exact dedup、relaxed dedup、horizon/phase fill、expired refresh、active invalidation 和 stale backfill invalidation 都遵循该 same-profile 语义。- 新的相反 active 信号只会把同 profile 的旧 active 信号标记为
invalidated,并把失效来源写入 metadata。不同非NULLprofile 可并存,即使 action 相反。 - Expired duplicate refresh 不会改写
decision_profile,只能刷新同 profile 记录。
API
当前公开接口由 api/v1/endpoints/decision_signals.py 和 docs/architecture/api_spec.json 描述:
POST /api/v1/decision-signals:创建或按同源键去重,返回{ item, created }。GET /api/v1/decision-signals:分页查询,支持市场、股票、动作、阶段、decision_profile、来源、状态、时间范围和持仓过滤。省略或传空decision_profile不加 profile 条件,返回所有 profile;decision_profile=unknown查询NULL行;合法 profile 精确匹配。GET /api/v1/decision-signals/{signal_id}:查询单条。PATCH /api/v1/decision-signals/{signal_id}/status:更新状态和可选 metadata。GET /api/v1/decision-signals/latest/{stock_code}:查询股票最新 active 信号。POST /api/v1/decision-signals/outcomes/run:显式触发后验评估。GET /api/v1/decision-signals/outcomes、GET /api/v1/decision-signals/outcomes/stats、GET /api/v1/decision-signals/{signal_id}/outcomes:查询后验结果与统计。GET/PUT /api/v1/decision-signals/{signal_id}/feedback:查询或写入 useful / not useful 反馈。POST /api/v1/decision-signals/reassess:基于来源历史报告预览不同决策风格下的信号,不写库。
这些接口继承现有 /api/v1/* 管理员鉴权;ADMIN_AUTH_ENABLED=true 时需要有效管理员会话 Cookie。
Reassess preview
reassess 第一版只做 preview,不创建或更新 DecisionSignal。
请求只支持:
{
"source_report_id": 123,
"decision_profile": "aggressive",
"persist": false
}
契约边界:
source_report_id是唯一事实来源,重评估只读取对应持久化历史报告快照。- 不支持
signal_id,也不接受客户端提交action、score、confidence、价格、metadata 或 guardrail 结果;额外字段会被请求校验拒绝。 persist=true当前固定返回 HTTP 400,错误码为unsupported_operation。保存重评估结果留给 #1757。- 重评估不会静默抓取实时行情,也不会用当前市场数据补齐历史快照。
- 历史报告不存在、非个股报告或快照缺少结构化决策输入时,分别返回明确错误。
- data quality 会归一为
high、medium、low、poor、unknown,guardrail 只使用归一化后的等级。 guardrail_result是机器审计数据,记录 raw/final action、是否通过、violations 和 adjustments;warnings是用户可读摘要,测试和客户端逻辑应优先依赖稳定code。- blocked preview 仍是 HTTP 200,UI 必须突出
blocked_reason,不能把它当作普通可执行信号。 - aggressive 不是模型采样温度语义,也不会自动生成三套 profile 信号。
Web 展示
Web 入口位于 /decision-signals:
- 默认查询
status=active。 - 页面顶部提供页面级“当前股票”主路径,独立于高级列表筛选。用户提交主股票、选择自动补全候选或点击候选 chip 后,latest active 与时间线共用同一个已应用股票上下文;只修改输入草稿不会触发 latest 或时间线查询。
- 当前股票候选优先展示最近分析过的股票;如果没有历史候选,或历史候选加载失败,则降级展示股票索引中 active 且 popularity 较高的热门股票。候选只作为手动点击入口,页面加载时不会自动提交查询;历史和股票索引都不可用时仅显示无候选降级文案。
- 当前股票上下文会显示已应用的代码、名称和可推导市场,并提供清空入口。清空会让 latest 与时间线回到引导态,不影响高级列表筛选或列表来源详情抽屉。
- 支持按市场、股票代码、动作、市场阶段、来源、来源报告 ID 和状态进行高级列表筛选;这些筛选不等同于当前股票上下文,也不会污染 latest active 查询。
- 单支股票信号时间线复用现有
GET /api/v1/decision-signalslist API,不新增 timeline endpoint。时间线必须先应用非空当前股票后才会查询;没有当前股票时只显示引导态,不拉取 market-only 或 global timeline。 - 时间线只支持
30d、90d、180d三个时间范围,默认90d;每次最多请求 100 条。若返回total > items.length,Web 会显示“仅展示最近 100 条信号,请缩小时间范围”,避免静默展示不完整轨迹。 - 时间线筛选保留独立的 market、range、status 表单和查询按钮。选择新当前股票时,如果能推导市场,只在这一次初始化时间线 market;用户之后可以手动改 market,查询以按钮提交时的表单快照为准。
- 时间线 status filter 只支持
all与active:all不传status,active传status=active。P1 不提供 terminal status filter,也不做前端 terminal 过滤。 - 时间线支持 profile filter,复用 list API 的 server-side
decision_profile查询;unknown只用于筛选和展示 legacyNULL行。普通高级列表不新增 profile filter。 - 信号表现统计保持全局已复盘 outcome 口径,不等于当前可见信号数量,也不随当前股票或高级列表筛选变化;当已复盘样本数为 0 时,Web 显示零样本空状态而不是一组
0/-指标。 - Web 展示优先读取正式
decision_profile字段,只有字段缺失时才回退 legacy metadata;历史缺失或非法 profile 的信号显示为unknown,不会误标为balanced。 - market filter 在 API / 服务层与 Web 前端均已支持
cn/hk/us/jp/kr/tw;jp/kr/tw的前端本地化标签均已补齐,tw信号可经 API 正常写入、按market=tw查询,并可在 Web DecisionSignal 页面通过市场筛选项选择台股(tw);告警(大盘红绿灯)市场支持cn/hk/us/jp/kr。 - 详情抽屉展示动作、状态、评分、置信度、周期、计划质量、市场阶段、价格计划、风险、观察条件、证据、数据质量和 metadata。
- 详情抽屉或已有来源报告 ID 的页面上下文可以发起 reassess preview;没有可用来源报告 ID 时入口禁用。Preview 不加入列表、latest 或时间线,也不提供保存按钮。
- Web 只能把信号标记为
closed、invalidated或archived,不提供 terminal 状态恢复为 active。 - 历史报告详情不再内嵌展示报告绑定的
source_type=analysis信号,也不会因打开报告详情触发source_report_id信号查询;需要查看报告来源信号时统一进入/decision-signals页面按来源报告 ID 精确筛选,或打开/decision-signals?sourceReportId=<recordId>deep link。该筛选和 deep link 都会使用source_type=analysis + source_report_id的精确查询,以保留旧报告的 best-effort 懒回填入口。 - 持仓页异步查询每个唯一持仓的 latest active 信号,单只查询失败只显示降级提示,不阻断组合快照或其他持仓信号。
所有用户可见枚举必须使用 i18n 标签;技术 ID、股票代码、API 字段名、env key、URL 示例可以保留英文。
Decision profile identity
#1756 后 decision_profile 是 decision_signals 的正式 nullable 字段,同时 metadata 保留兼容字段:
-
decision_profile=balanced -
profile_source=auto_default:普通新分析生成路径。 -
profile_source=backfill_defaulted:历史报告 lazy backfill 路径。 -
profile_policy_version=decision-profile-v1 -
signal_generation_version=legacy-report-extractor-v1 -
decision_signal_metadata_version=decision-signal-metadata-v1 -
新写入时,顶层合法
decision_profile优先;顶层显式null、空值或非法值直接拒绝。顶层缺失时才 fallback 合法metadata.decision_profile;二者都缺失或 metadata profile 非法时默认写入balanced。 -
新写入会同步
metadata.decision_profile为正式字段值,避免双源冲突;metadata 省略或显式null均按无 metadata 处理,object 会浅复制,非 object 会被拒绝。 -
PATCH metadata 省略时保留原值,显式
null时清空为 SQLNULL,object 时整包替换。正式 profile 非NULL时会覆盖 metadata 中的冲突值;正式 profile 为 legacyNULL时会移除请求 object 中的 profile key,且不会提升正式字段。 -
自动失效写入同样遵循正式字段权威语义:正式 profile 非
NULL时同步 metadata profile;legacyNULL时只追加失效信息,保留原 legacy metadata,不注入或删除 profile。 -
Legacy / unknown 只用数据库
NULL表示。profile_policy_version只表示默认 profile metadata contract version,不代表已经实现独立 profile policy engine、scoring engine 或多 profile 生成。P1/P2 不写入scoring_version或scoring_breakdown;这些字段如需引入,应由后续 reassess / scoring issue 定义。 -
Lazy backfill 语义:省略 profile 保留旧的
source_type=analysis + source_report_id懒回填;decision_profile=balanced可生成 balanced 回填;decision_profile=unknown、conservative、aggressive不自动创建行。
市场结构 metadata
普通个股分析和 Agent 个股分析如果携带 market_structure_context,自动提取 DecisionSignal 时会把以下低敏字段追加到 metadata:
market_structure_versionmarket_theme_versionstock_market_position_versionmarket_structure_statusprimary_themetheme_phasestock_rolemarket_structure_risk_tags
这些字段只用于解释信号所处题材背景,不参与 action、score、horizon、同源去重键或生命周期计算。它们也不是题材龙头证明;当 market_structure_risk_tags 或缺失证据显示成分股、leader stocks 不完整时,客户端和后验分析应按降级题材证据处理。
快照字段中的 provider / dataset 来自市场结构抽取链路元数据,属于运行后持久化证据,不参与 LLM provider/model 路由、base URL 解析、.env 写回或配置迁移;可核验范围见 src/schemas/market_structure.py。
告警、通知与组合风险
- 股票级真实告警触发会优先关联同标的 latest active 信号,并把低敏
decision_signal_summary写入alert_triggers.diagnostics。 - 没有 active 信号时,告警 worker 只创建最小
source_type=alert/action=alert信号。 - 告警信号的
trace_id=alert-rule-<hash>只用于同源重试的 best-effort 去重,不覆盖 active 信号本体。 - 通知只引用公开摘要字段:
action、horizon、reason、watch_conditions、risk_summary、source_report_id。 - 通知不得输出 signal
metadata、evidence、raw diagnostics、webhook URL、token 或 cookie。 GET /api/v1/portfolio/risk的decision_signal_risk只统计当前持仓中的 activesell/reduce/alert信号,查询失败时 fail-open。
更多告警和通知细节见 docs/alerts.md 与 docs/notifications.md。
后验评估与反馈
P5 通过 sidecar 表保存用户反馈和后验结果,不扩展 decision_signals 主表:
decision_signal_feedback保存每个信号最新的useful|not_useful反馈、可选原因/备注和来源。decision_signal_outcomes按(signal_id, horizon, engine_version)幂等保存后验评估结果。- 当前
engine_version=decision-signal-v1。 - 后验评估只支持日线可验证的
1d/3d/5d/10d;intraday/swing/long、非方向动作、缺价和 forward bars 不足会写入eval_status=unable与明确unable_reason。 - 评估时冻结 action、market、market_phase、source_type、source_agent、plan_quality、data_quality_level、holding_state 等统计维度,历史统计不依赖后续 live join。
脱敏与低敏边界
信号写入和状态更新使用 src/utils/sanitize.py 中的 sanitize_decision_signal_text() 与 sanitize_decision_signal_payload():
- 文本字段、JSON 字段和展示型短文本写入前会脱敏。
- 覆盖敏感 key、Bearer、Authorization/Cookie header 或赋值、token-like 字符串、webhook URL、URL userinfo,以及带敏感 query/fragment 参数的 URL。
- 普通证据 URL 会保留,保证来源可追溯。
trace_id是同源去重身份字段;如果包含会被脱敏的 credential,API 会拒绝请求,而不是保存被 redaction 破坏后的身份值。- Web 的 JSON 展示只显示后端已脱敏数据,不应重新拼接 raw diagnostics 或配置值。
P7 的全局验收是确认信号池、通知摘要和 Web 展示不泄露 token、cookie、webhook URL、API key、邮箱密码等敏感信息。
迁移与回滚
#1756 对 SQLite 执行非破坏性 migration。
迁移说明:
- 升级后无需新增
.env、.env.example或 Web 设置项。 - Existing SQLite 只在缺列时
ALTER TABLE ADD COLUMN decision_profile,不会 drop/rebuilddecision_signals,也不会删除旧 index。 - Migration 会幂等创建 profile-aware indexes,并 row-by-row 防御解析
metadata_json:仅合法metadata.decision_profile回填到正式字段;invalid JSON、非 object 或非法 profile 保持NULL。启动日志会记录 backfilled、invalid JSON、non-object、invalid profile 和 skipped existing profile 统计,这些统计只用于诊断,不阻断启动。 - 旧历史报告不会批量回填。只有显式调用信号列表接口或在 Web AI 建议页按来源报告 ID 触发精确查询
source_type=analysis + source_report_id且无命中时,才会 best-effort 懒回填。 - 已存在的
decision_signals、feedback 和 outcome 数据保持兼容。
回滚说明:
- 当前没有
DECISION_SIGNAL_*开关;关闭信号提取/写入的回滚方式是 revert 相关代码。 - 回滚后,普通报告保存、告警触发、通知发送和组合风险主流程仍按既有路径运行。
- 回滚不会自动删除历史
decision_signals、decision_signal_feedback或decision_signal_outcomes数据;如需清理,应由维护者单独制定数据清理策略。