Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
d6c2bd9
feat(data): 统一多市场财经新闻与披露
mirror29 Jul 30, 2026
6556743
feat(orchestration): 暴露全球市场新闻工具
mirror29 Jul 30, 2026
828892c
feat(research): 接入多市场新闻证据
mirror29 Jul 30, 2026
0c1e435
docs(data): 记录多市场新闻能力
mirror29 Jul 30, 2026
facdda2
fix(paper): 决策时间线优先展示最新记录
mirror29 Jul 31, 2026
a099984
fix(news): 修复审查发现的数据边界问题
mirror29 Jul 31, 2026
9f2d33b
fix(research): 隔离外部新闻提示边界
mirror29 Jul 31, 2026
8b8ca0a
fix(data): 显式返回新闻来源无覆盖
mirror29 Jul 31, 2026
cedda41
fix(news): 标注历史快照覆盖边界
mirror29 Jul 31, 2026
8e955fd
fix(orchestration): 对齐新闻覆盖与信任契约
mirror29 Jul 31, 2026
481adba
feat(dashboard): 完善最近订单导航
mirror29 Jul 31, 2026
decc9e2
fix(news): 收口跨层覆盖与错误语义
mirror29 Jul 31, 2026
1a3a40c
test(data): 固定腾讯历史窗口时钟
mirror29 Aug 3, 2026
8858569
test(paper): 对齐决策时间线倒序契约
mirror29 Aug 3, 2026
e4a74f6
fix(data): 收紧新闻查询与来源契约
mirror29 Aug 3, 2026
10e29f9
fix(research): 显式标注新闻来源故障
mirror29 Aug 3, 2026
b0d21a8
fix(dashboard): 展示新闻来源失败状态
mirror29 Aug 3, 2026
50f61e8
fix(research): 补齐加密新闻故障语义
mirror29 Aug 3, 2026
d234f16
fix(news): 收口发布前覆盖与迁移边界
mirror29 Aug 3, 2026
b8eec3c
test(data): 对齐市场代理限流契约
mirror29 Aug 3, 2026
c38b239
fix(dashboard): 区分新闻失败与空状态
mirror29 Aug 3, 2026
25e1cdd
fix(orchestration): 统一市场新闻错误契约
mirror29 Aug 3, 2026
5e7e543
fix(data): 完整校验新闻市场身份
mirror29 Aug 3, 2026
0d6b33b
fix(news): 收口发布前时点与来源边界
mirror29 Aug 4, 2026
5186af8
fix(news): 限制来源长尾并恢复索引重试
mirror29 Aug 4, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -235,6 +235,7 @@ Where each capability stands today. Live module inventory and the end-to-end dec
| ✅ Shipped | Scheduler / cron agent mode | D-9 | `scheduler_jobs` + advisory lock + `/api/scheduler/*` management plane |
| ✅ Shipped | RiskGuard per-account isolation | D-9.1a | `RiskGuardFactory` removes cross-account state bleed |
| ✅ Shipped | Multi-market data sources — web search + financial fundamentals | D-10 | zero-key DDGS web search · `baostock` is the logical A-share venue: Tencent HTTPS bars/ticker + Baostock fundamentals/calendar/constituents; yfinance covers global markets including HK · analyst integration + fallback · per-market lookbackDays |
| ✅ Shipped | Unified multi-market financial news | D-12 | `GET /news` + `data.get_news` · Eastmoney / SEC / HKEX / Crypto feeds · current-snapshot `as_of` filtering with explicit incomplete-history coverage · observable provider failures · explicit index/ETF proxy labels for market-level stock news |
| ✅ Shipped | Risk engine — all 5 rules live in HTTP path | D-9 closed | `closed_trades` writes from HTTP order flow; `RoutingCalendar` for US equity + crypto; all trade-based rules trigger on real data |
| ✅ Shipped | `askUserChoice` — `ask` permission path | D-11 (issue #2) | pending-permission flow resolves the `ask` state (no longer a workaround) |
| ✅ Shipped | `permissions.yaml` configuration | D-11 (issue #4) | `config/permissions.default.yaml` + `yaml_loader.ts` replace the hard-coded `defaults.ts` |
Expand Down
1 change: 1 addition & 0 deletions README.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -233,6 +233,7 @@ Inalpha 把*调度*和*算力*分开:agent runtime 负责扇出网格、聚合
| ✅ 已上线 | Scheduler / cron agent 模式 | D-9 | `scheduler_jobs` + advisory lock + `/api/scheduler/*` 管理面 |
| ✅ 已上线 | RiskGuard 账户级隔离 | D-9.1a | `RiskGuardFactory` 去除跨账户状态串联 |
| ✅ 已上线 | 多市场数据源 — web 搜索 + 财报基本面 | D-10 | DDGS 零 key web search · `baostock` 是 A 股逻辑 venue:腾讯 HTTPS 行情/最新价 + Baostock 基本面/日历/成分;yfinance 覆盖全球(含港股)· analyst 接入 + 兜底 · lookbackDays 按市场分化 |
| ✅ 已上线 | 统一多市场财经新闻 | D-12 | `GET /news` + `data.get_news` · 东财 / SEC / HKEX / Crypto feed · 当前来源快照上的 `as_of` 截止过滤并显式标注历史覆盖不足 · provider 故障可观察 · 股票市场级新闻明确标注指数/ETF 代理口径 |
| ✅ 已上线 | 风控引擎 — 5 条规则全在 HTTP 路径激活 | D-9 收口 | `closed_trades` 由 HTTP 订单流写入;`RoutingCalendar` 覆盖美股 + crypto;trade-based 规则全在真实数据上触发 |
| ✅ 已上线 | `askUserChoice` — `ask` 权限路径 | D-11(issue #2) | pending-permission 流程把 `ask` 状态从 workaround 救回(已收口) |
| ✅ 已上线 | `permissions.yaml` 配置化 | D-11(issue #4) | `config/permissions.default.yaml` + `yaml_loader.ts` 替代 `defaults.ts` 硬编码 |
Expand Down
12 changes: 12 additions & 0 deletions apps/dashboard/messages/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -473,6 +473,18 @@
"toolResult": "output",
"errorGeneric": "Agent reply failed. Please retry — likely an upstream LLM error or dropped stream.",
"errorDismiss": "Dismiss",
"marketNews": {
"partial": "Some sources failed.",
"snapshotOnly": "The historical window is not fully covered.",
"empty": "No news data available.",
"providerFailure": "{provider}: {status}",
"status": {
"timeout": "timed out",
"rate_limited": "rate limited",
"upstream_error": "upstream error",
"unsupported": "unsupported scope"
}
},
"context": {
"viewing": "Viewing",
"dismiss": "Don't include this page",
Expand Down
12 changes: 12 additions & 0 deletions apps/dashboard/messages/zh.json
Original file line number Diff line number Diff line change
Expand Up @@ -473,6 +473,18 @@
"toolResult": "输出",
"errorGeneric": "Agent 回复失败,请重试 —— 多半是上游 LLM 报错或连接中断。",
"errorDismiss": "关闭",
"marketNews": {
"partial": "部分来源失败。",
"snapshotOnly": "历史窗口覆盖不完整。",
"empty": "暂无可用新闻数据。",
"providerFailure": "{provider}:{status}",
"status": {
"timeout": "请求超时",
"rate_limited": "触发限流",
"upstream_error": "上游错误",
"unsupported": "不支持该范围"
}
},
"context": {
"viewing": "正在看",
"dismiss": "不带本页上下文",
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
"use client";

import { useTranslations } from "next-intl";

import { cn } from "@/lib/cn";

import { shortTimestamp } from "./format";
Expand All @@ -22,19 +24,68 @@ interface NewsItem {
summary?: string;
published_at?: string | null;
related_codes?: string[];
symbols?: string[];
}

export function isMarketNews(v: unknown): v is { items: NewsItem[] } {
if (!isObj(v) || !Array.isArray(v.items) || v.items.length === 0) return false;
interface NewsProviderStatus {
provider: string;
status: string;
coverage?: string;
}

interface MarketNewsPayload {
items: NewsItem[];
providers?: NewsProviderStatus[];
is_partial?: boolean;
coverage_complete?: boolean;
error?: string;
}

export function isMarketNews(v: unknown): v is MarketNewsPayload {
if (!isObj(v) || typeof v.error === "string" || !Array.isArray(v.items)) return false;
const hasNewsState =
Array.isArray(v.providers) ||
typeof v.is_partial === "boolean" ||
typeof v.coverage_complete === "boolean";
if (v.items.length === 0) return hasNewsState;
return v.items.every((it) => isObj(it) && typeof it.title === "string");
}

const NEWS_CAP = 8;

export function MarketNewsView({ v }: { v: { items: NewsItem[] } }) {
const NEWS_PROVIDER_FAILURES = new Set([
"timeout",
"rate_limited",
"upstream_error",
"unsupported",
]);

export function MarketNewsView({ v }: { v: MarketNewsPayload }) {
const t = useTranslations("chat.marketNews");
const items = v.items.slice(0, NEWS_CAP);
const unavailable = (v.providers ?? []).filter((provider) =>
NEWS_PROVIDER_FAILURES.has(provider.status),
);
return (
<div className="flex flex-col">
{(v.is_partial || v.coverage_complete === false || unavailable.length > 0) && (
<div className="mb-1.5 rounded-sm border border-gold/30 bg-gold/5 px-2 py-1 font-mono text-[9px] leading-relaxed text-gold/90">
{v.is_partial && t("partial")}
{v.coverage_complete === false && ` ${t("snapshotOnly")}`}
{unavailable.length > 0 &&
` ${unavailable
.map((provider) =>
t("providerFailure", {
provider: provider.provider,
status: t(`status.${provider.status}`),
}),
)
.join(" · ")}`}
</div>
)}
{items.length === 0 && (
<div className="font-mono text-[10px] text-fg-muted/60">{t("empty")}</div>
)}
{items.map((n, i) => (
<div key={i} className="border-b border-border-subtle/50 py-1.5 first:pt-0 last:border-b-0">
<div className="flex items-baseline gap-2">
Expand Down
1 change: 1 addition & 0 deletions apps/dashboard/src/components/chat/tool-views/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,7 @@ export function resolveToolView(toolName: string, v: unknown): ReactNode | null
case "factor_evaluate_candidate":
case "factor_custom_score":
return isCustomFactor(v) ? <CustomFactorView c={v} /> : null;
case "data_get_news":
case "data_get_market_news":
return isMarketNews(v) ? <MarketNewsView v={v} /> : null;
case "data_get_market_sectors":
Expand Down
32 changes: 27 additions & 5 deletions apps/dashboard/src/components/overview/OrdersTable.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,14 @@
import { useLocale, useTranslations } from "next-intl";

import type { OrderRecord } from "@/lib/types";
import { useRouter } from "@/i18n/navigation";
import { cn } from "@/lib/cn";
import { sortOrdersNewestFirst } from "@/lib/orders";
import {
fmtDateTime,
fmtNum,
fmtQty,
fmtSigned,
fmtTime,
instrumentLabel,
pnlColor,
} from "@/lib/format";
Expand All @@ -27,6 +29,8 @@ export function OrdersTable({
const t = useTranslations("overview.orders");
const tc = useTranslations("common");
const locale = useLocale();
const router = useRouter();
const sortedOrders = sortOrdersNewestFirst(orders);

return (
// h-full:总览里与策略池并排,grid stretch 下两卡等高。
Expand Down Expand Up @@ -59,13 +63,31 @@ export function OrdersTable({
</TableHeadRow>
</thead>
<tbody>
{orders.map((o) => (
{sortedOrders.map((o) => (
<tr
key={o.client_order_id}
className="border-t border-border-subtle/60 hover:bg-bg-elev/30"
className={cn(
"border-t border-border-subtle/60 transition-colors hover:bg-bg-elev/30",
o.strategy_run_id && "cursor-pointer focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-cyan",
)}
onClick={() => {
if (o.strategy_run_id) router.push(`/runners/${o.strategy_run_id}`);
}}
onKeyDown={(event) => {
if (!o.strategy_run_id || (event.key !== "Enter" && event.key !== " ")) return;
event.preventDefault();
router.push(`/runners/${o.strategy_run_id}`);
}}
tabIndex={o.strategy_run_id ? 0 : undefined}
role={o.strategy_run_id ? "link" : undefined}
aria-label={
o.strategy_run_id
? `${fmtDateTime(o.ts_event, locale)} · ${instrumentLabel(o.symbol, o.venue)}`
: undefined
}
>
<Td mono muted>
{fmtTime(o.ts_event, locale)}
<Td mono muted className="whitespace-nowrap">
{fmtDateTime(o.ts_event, locale)}
</Td>
<Td>
<span className="font-medium text-fg">
Expand Down
42 changes: 42 additions & 0 deletions apps/dashboard/src/lib/orders.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { describe, expect, it } from "vitest";

import type { OrderRecord } from "./types";
import { sortOrdersNewestFirst } from "./orders";

function order(clientOrderId: string, tsEvent: string): OrderRecord {
return {
client_order_id: clientOrderId,
venue: "binance",
symbol: "BTC/USDT",
side: "BUY",
type: "MARKET",
quantity: 1,
price: null,
status: "FILLED",
filled_quantity: 1,
avg_fill_price: 100,
fee: 0,
notional: 100,
realized_pnl: 0,
ts_event: tsEvent,
ts_init: tsEvent,
trade_plan_id: null,
strategy_run_id: "00000000-0000-0000-0000-000000000001",
};
}

describe("sortOrdersNewestFirst", () => {
it("sorts by event time descending without mutating the payload", () => {
const oldest = order("order-a", "2026-07-29T08:00:00Z");
const newest = order("order-b", "2026-07-31T08:00:00Z");
const middle = order("order-c", "2026-07-30T08:00:00Z");
const input = [oldest, newest, middle];

expect(sortOrdersNewestFirst(input).map((value) => value.client_order_id)).toEqual([
"order-b",
"order-c",
"order-a",
]);
expect(input).toEqual([oldest, newest, middle]);
});
});
9 changes: 9 additions & 0 deletions apps/dashboard/src/lib/orders.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import type { OrderRecord } from "@/lib/types";

/** 返回新数组,按订单事件时间与稳定 ID 倒序。 */
export function sortOrdersNewestFirst(orders: OrderRecord[]): OrderRecord[] {
return [...orders].sort((left, right) => {
const byTime = Date.parse(right.ts_event) - Date.parse(left.ts_event);
return byTime || right.client_order_id.localeCompare(left.client_order_id);
});
}
2 changes: 2 additions & 0 deletions apps/dashboard/src/lib/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,8 @@ export interface OrderRecord {
ts_event: string;
ts_init: string;
trade_plan_id: string | null;
/** 模拟盘产生的订单关联对应 run;手工单为 null。 */
strategy_run_id: string | null;
}

/** 运行日志级别 —— 与后端 live_runner 写入一致。 */
Expand Down
10 changes: 10 additions & 0 deletions docs/04-current-state.md
Original file line number Diff line number Diff line change
Expand Up @@ -199,6 +199,16 @@ D-9 把决策护栏(Plan/Exec + 风控 + 沙盒)做扎实后,D-10 在**数
`lookbackDays`(crypto 1h/4h ≈30d;A股/港股/日股 akshare 1d ≈180d;美股/全球指数
yfinance 1d ≈90d),避免"30 天 K 线<20 根交易日"无统计意义;research client
超时放宽到 300s 给 A股慢路径留余量。
- **统一多市场新闻**:`GET /news` 与 `data.get_news` 统一 `market_news` / `media` /
`disclosure` 契约,首批接入东财、SEC、HKEX 与 Crypto 专业 feed;股票市场级快讯
用代表指数/ETF 的 Yahoo 新闻代理并在条目中保留代理 ticker,不冒充完整新闻线。
`as_of` / `since` 只在当前 provider 快照上做截止过滤,不是历史新闻库;历史窗口用
`coverage_complete=false` 与逐 provider `coverage=snapshot_only` 显式标注覆盖不足,
不能把空结果解读为“当时没有新闻”。响应另保留逐 provider 状态、`is_partial` 与
来源等级;无 provider 覆盖的 market/symbol/kinds/language 组合返
`422 NEWS_SCOPE_NOT_SUPPORTED`。旧 `venue + symbol` 请求继续兼容。
`SentimentAnalyst` 与 `MacroAnalyst` 已消费结构化新闻,外部内容以不可执行证据块
送入 LLM,来源不可用时再显式降级 web 搜索。
- **金融时效**:web/news 与基本面均无 API key 依赖;analyst 数据源不可用时降级到
LLM-only 并标低 confidence,不静默用过时数据。

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
"""为订单查询增加模拟盘 run 反向引用索引。

订单与模拟盘通过 strategy_run_decisions.order_id 软关联。组合总览需要从最近订单跳转到
对应 run;部分索引避免订单列表对不断增长的决策表做全表扫描。
"""
from __future__ import annotations

from alembic import op

revision: str = "0037"
down_revision: str | None = "0036"
branch_labels: str | tuple[str, ...] | None = None
depends_on: str | tuple[str, ...] | None = None


def upgrade() -> None:
"""给非空 order_id 并发建立反向引用索引,避免阻塞持续写入。"""
with op.get_context().autocommit_block():
op.execute(
"DROP INDEX CONCURRENTLY IF EXISTS strategy_run_decisions_order_idx"
)
op.execute(
"CREATE INDEX CONCURRENTLY strategy_run_decisions_order_idx "
"ON strategy_run_decisions (order_id) WHERE order_id IS NOT NULL"
)


def downgrade() -> None:
"""并发删除订单反向引用索引。"""
with op.get_context().autocommit_block():
op.execute(
"DROP INDEX CONCURRENTLY IF EXISTS strategy_run_decisions_order_idx"
)
3 changes: 2 additions & 1 deletion packages/orchestration/skills/cn-equity-research/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,8 @@ fail 就 stop,不跨层补救**。同一结论要多源交叉验证,不信
| 行业板块涨跌幅榜(顺风 / 逆风) | `data.get_market_sectors` |
| 当日强势股 + 题材标签(主线确认) | `data.get_market_movers` |
| 沪深港通资金流向(市场级资金面) | `data.get_market_moneyflow` |
| 个股新闻 / 公告 / 研报线索 | `web.search_news`、`web.search` |
| 个股新闻 / 官方公告 | `data.get_news`(先看 provider / coverage;返回 URL 再 `web.fetch`) |
| 研报线索或 `data.get_news` 无覆盖 / 故障降级 | `web.search_news`、`web.search` |
| **核一手来源原文**(公告 / 研报 / 新闻正文 + 日期) | `web.fetch` |
| 临时计算(PEG / 消化年限等公式) | `sandbox.run_code`(或心算后写明算式) |
| 深度多视角研究(决定要不要建仓级别) | `research.deep_dive`(本 skill 之后的下一步) |
Expand Down
6 changes: 4 additions & 2 deletions packages/orchestration/skills/earnings-analysis/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,13 +20,15 @@ metadata:
**训练记忆里的"最新财报"几乎必然过时。**开始前四步强制:

1. 确认 as_of(runtime_facts 里的真实今天)
2. `web.search_news` / `web.search` 搜该公司**最新**财报发布——禁止直接用记忆里的季度数据
2. 先用 `data.search_symbol` 解析标的,再用 `data.get_news` 查最新官方披露;无覆盖或
provider 故障时才降级 `web.search_news` / `web.search`——禁止直接用记忆里的季度数据
3. 核对发布日期距 as_of 是否在 3 个月内;不在就换关键词再搜,或明确告诉用户"目标季度尚未发布/已过时"
4. 关键来源(财报原文 / 业绩说明会记录 / 公司公告页)用 `web.fetch` 读正文,记录 published_at

| 步骤 | 工具 |
|---|---|
| 找最新财报发布 / 业绩会 / 共识预期报道 | `web.search_news`、`web.search` |
| 找最新官方财报披露 | `data.get_news`(disclosure;检查 provider / coverage) |
| 找业绩会 / 共识预期报道,或结构化源降级 | `web.search_news`、`web.search` |
| 读财报原文 / transcript / 公告正文 | `web.fetch` |
| 公司名 → 代码 | `data.search_symbol` |
| 核验财务指标(毛利 / 现金流 / 周转) | `data.get_fundamentals` |
Expand Down
Loading
Loading