diff --git a/src/domain/market-data/client/types.ts b/src/domain/market-data/client/types.ts index 0866f484c..6cfe4a60f 100644 --- a/src/domain/market-data/client/types.ts +++ b/src/domain/market-data/client/types.ts @@ -29,7 +29,7 @@ import type { // Commodity CommoditySpotPriceData, PetroleumStatusReportData, ShortTermEnergyOutlookData, // Economy (FRED + BLS + OECD) - FredSearchData, FredSeriesData, FredRegionalData, + EconomicCalendarData, FredSearchData, FredSeriesData, FredRegionalData, BlsSearchData, BlsSeriesData, ConsumerPriceIndexData, CountryInterestRatesData, CompositeLeadingIndicatorData, PortInfoData, PortVolumeData, ChokepointInfoData, ChokepointVolumeData, @@ -105,6 +105,7 @@ export interface CommodityClientLike { } export interface EconomyClientLike { + getCalendar(params?: Record): Promise fredSearch(params: Record): Promise fredSeries(params: Record): Promise fredRegional(params: Record): Promise diff --git a/src/domain/market-data/reference/service.spec.ts b/src/domain/market-data/reference/service.spec.ts index c17ae7fce..3e8cf7717 100644 --- a/src/domain/market-data/reference/service.spec.ts +++ b/src/domain/market-data/reference/service.spec.ts @@ -2,7 +2,19 @@ import { describe, it, expect } from 'vitest' import { createReferenceData } from './service.js' import type { EconomyClientLike, EquityClientLike } from '../client/types.js' -const ECONOMY_STUB = { fredSeries: async () => [] } as unknown as EconomyClientLike +const ECONOMIC_EVENT = { + date: '2026-06-12 12:30:00', country: 'US', category: 'Employment', event: 'Nonfarm Payrolls', + importance: 'High', source: 'BLS', currency: 'USD', unit: 'K', consensus: 150, + previous: 139, revised: null, actual: null, +} + +function mkEconomyClient(overrides: Partial = {}): EconomyClientLike { + return { + getCalendar: async () => [ECONOMIC_EVENT], + fredSeries: async () => [], + ...overrides, + } as unknown as EconomyClientLike +} const ROW = { symbol: 'NVDA', name: 'NVIDIA', price: 1000, change: 50, percent_change: 0.052, volume: 1e8, @@ -32,7 +44,7 @@ const INDEX_STUB = {} as never describe('reference service', () => { it('movers: one list failing does not kill the board', async () => { const ref = createReferenceData({ - economyClient: ECONOMY_STUB, + economyClient: mkEconomyClient(), derivativesClient: DERIVATIVES_STUB, indexClient: INDEX_STUB, equityClient: mkEquityClient({ getLosers: async () => { throw new Error('boom') } }), @@ -46,7 +58,7 @@ describe('reference service', () => { it('calendar: partial upstream failure is annotated per list, not silent', async () => { const ref = createReferenceData({ - economyClient: ECONOMY_STUB, + economyClient: mkEconomyClient(), derivativesClient: DERIVATIVES_STUB, indexClient: INDEX_STUB, equityClient: mkEquityClient({ @@ -59,12 +71,29 @@ describe('reference service', () => { expect(board.ipos).toEqual([]) expect(board.errors?.ipos).toMatch(/403/) expect(board.errors?.earnings).toBeUndefined() + expect(board.economicEvents).toEqual([ECONOMIC_EVENT]) + }) + + it('calendar: macro events can fail independently without hiding company events', async () => { + const ref = createReferenceData({ + economyClient: mkEconomyClient({ + getCalendar: async () => { throw new Error('Economic calendar unavailable') }, + }), + derivativesClient: DERIVATIVES_STUB, + indexClient: INDEX_STUB, + equityClient: mkEquityClient({}), + equityProvider: 'yfinance', + }) + const board = await ref.calendar() + expect(board.economicEvents).toEqual([]) + expect(board.earnings).toHaveLength(1) + expect(board.errors?.economicEvents).toMatch(/unavailable/) }) - it('calendar: all three failing throws loud (missing/invalid key)', async () => { + it('calendar: all four lists failing throws loud (missing/invalid key)', async () => { const dead = async () => { throw new Error('FMP API key required') } const ref = createReferenceData({ - economyClient: ECONOMY_STUB, + economyClient: mkEconomyClient({ getCalendar: dead }), derivativesClient: DERIVATIVES_STUB, indexClient: INDEX_STUB, equityClient: mkEquityClient({ @@ -76,7 +105,7 @@ describe('reference service', () => { }) it('calendar: window defaults to 14 days from today', async () => { - const ref = createReferenceData({ economyClient: ECONOMY_STUB, + const ref = createReferenceData({ economyClient: mkEconomyClient(), derivativesClient: DERIVATIVES_STUB, indexClient: INDEX_STUB, equityClient: mkEquityClient({}), equityProvider: 'yfinance' }) const board = await ref.calendar() diff --git a/src/domain/market-data/reference/service.ts b/src/domain/market-data/reference/service.ts index c4c3089c3..16655460a 100644 --- a/src/domain/market-data/reference/service.ts +++ b/src/domain/market-data/reference/service.ts @@ -85,7 +85,9 @@ export function createReferenceData(deps: ReferenceDataDeps): ReferenceDataServi const calendarCached = cachedBoard(TTL.calendar, async (): Promise => { const hub = await viaHub('calendar') - if (hub) return hub + // Older hosted deployments predate the macro-events field. Keep the + // OpenAlice-owned response contract stable while the hub rolls forward. + if (hub) return { ...hub, economicEvents: hub.economicEvents ?? [] } const days = CALENDAR_DAYS const start = new Date() const end = new Date(start.getTime() + days * 24 * 60 * 60 * 1000) @@ -93,17 +95,23 @@ export function createReferenceData(deps: ReferenceDataDeps): ReferenceDataServi // Calendars are FMP-only in the provider catalog — explicit, same as // the equityGetEarningsCalendar tool. const params = { provider: 'fmp', start_date: window.start, end_date: window.end } - const [earnings, ipos, dividends] = await Promise.allSettled([ + const [economicEvents, earnings, ipos, dividends] = await Promise.allSettled([ + deps.economyClient.getCalendar(params), deps.equityClient.getCalendarEarnings(params), deps.equityClient.getCalendarIpo(params), deps.equityClient.getCalendarDividend(params), ]) - // All three down = the key is missing/invalid — fail loud with the + // All four down = the key is missing/invalid — fail loud with the // upstream message instead of rendering a silently empty board. - if (earnings.status === 'rejected' && ipos.status === 'rejected' && dividends.status === 'rejected') { - throw earnings.reason instanceof Error - ? earnings.reason - : new Error(String(earnings.reason)) + if ( + economicEvents.status === 'rejected' + && earnings.status === 'rejected' + && ipos.status === 'rejected' + && dividends.status === 'rejected' + ) { + throw economicEvents.reason instanceof Error + ? economicEvents.reason + : new Error(String(economicEvents.reason)) } const rows = (r: PromiseSettledResult) => (r.status === 'fulfilled' ? r.value : []) // Partial failures stay loud too: a suspended/limited FMP tier can @@ -114,10 +122,12 @@ export function createReferenceData(deps: ReferenceDataDeps): ReferenceDataServi errors[key] = r.reason instanceof Error ? r.reason.message : String(r.reason) } } + note('economicEvents', economicEvents) note('earnings', earnings) note('ipos', ipos) note('dividends', dividends) return { + economicEvents: rows(economicEvents), earnings: rows(earnings), ipos: rows(ipos), dividends: rows(dividends), @@ -168,23 +178,31 @@ async function uncachedCalendar(deps: ReferenceDataDeps, days: number): Promise< const end = new Date(start.getTime() + days * 24 * 60 * 60 * 1000) const window = { start: isoDay(start), end: isoDay(end) } const params = { provider: 'fmp', start_date: window.start, end_date: window.end } - const [earnings, ipos, dividends] = await Promise.allSettled([ + const [economicEvents, earnings, ipos, dividends] = await Promise.allSettled([ + deps.economyClient.getCalendar(params), deps.equityClient.getCalendarEarnings(params), deps.equityClient.getCalendarIpo(params), deps.equityClient.getCalendarDividend(params), ]) - if (earnings.status === 'rejected' && ipos.status === 'rejected' && dividends.status === 'rejected') { - throw earnings.reason instanceof Error ? earnings.reason : new Error(String(earnings.reason)) + if ( + economicEvents.status === 'rejected' + && earnings.status === 'rejected' + && ipos.status === 'rejected' + && dividends.status === 'rejected' + ) { + throw economicEvents.reason instanceof Error ? economicEvents.reason : new Error(String(economicEvents.reason)) } const rows = (r: PromiseSettledResult) => (r.status === 'fulfilled' ? r.value : []) const errors: NonNullable = {} const note = (key: keyof NonNullable, r: PromiseSettledResult) => { if (r.status === 'rejected') errors[key] = r.reason instanceof Error ? r.reason.message : String(r.reason) } + note('economicEvents', economicEvents) note('earnings', earnings) note('ipos', ipos) note('dividends', dividends) return { + economicEvents: rows(economicEvents), earnings: rows(earnings), ipos: rows(ipos), dividends: rows(dividends), diff --git a/src/domain/market-data/reference/types.ts b/src/domain/market-data/reference/types.ts index b24549433..78aa84913 100644 --- a/src/domain/market-data/reference/types.ts +++ b/src/domain/market-data/reference/types.ts @@ -15,6 +15,7 @@ import type { EquityDiscoveryData, CalendarEarningsData, CalendarIpoData, CalendarDividendData, + EconomicCalendarData, } from '@traderalice/opentypebb' // Type-only circular imports (these modules import ReferenceMeta) — fine in TS. import type { TermStructureBoard } from './term-structure.js' @@ -60,6 +61,7 @@ export interface MoversBoard { // ==================== Calendar board ==================== export interface CalendarBoard { + economicEvents: EconomicCalendarData[] earnings: CalendarEarningsData[] ipos: CalendarIpoData[] dividends: CalendarDividendData[] @@ -68,7 +70,7 @@ export interface CalendarBoard { /** Per-list upstream failures. A list can fail (e.g. FMP tier/suspension * rejects one endpoint) while siblings succeed — surface it loudly * instead of rendering a silently empty list. */ - errors?: Partial> + errors?: Partial> meta: ReferenceMeta } @@ -109,7 +111,7 @@ export interface MacroBoard { * the clients directly for now and converge here as the contract grows. */ export interface ReferenceDataService { movers(): Promise - /** Upcoming earnings / IPOs / ex-dividend dates. Requires an FMP key — + /** Upcoming macro events / earnings / IPOs / ex-dividend dates. Requires an FMP key — * fails loud with an actionable message when it's missing. */ calendar(opts?: { days?: number }): Promise /** Curated macro regime dashboard (rates, labor, inflation, oil, dollar). diff --git a/src/tool/economy.spec.ts b/src/tool/economy.spec.ts index 0a36d55f9..45bdb0527 100644 --- a/src/tool/economy.spec.ts +++ b/src/tool/economy.spec.ts @@ -15,6 +15,7 @@ import { createEconomyTools } from './economy.js' function makeMockEconomyClient(): EconomyClientLike { return { + getCalendar: vi.fn(async () => []), fredSearch: vi.fn(async () => []), fredSeries: vi.fn(async () => []), fredRegional: vi.fn(async () => []), diff --git a/src/tool/reference-board.ts b/src/tool/reference-board.ts index aabb72646..dc9c85c45 100644 --- a/src/tool/reference-board.ts +++ b/src/tool/reference-board.ts @@ -21,7 +21,7 @@ export function createReferenceBoardTools(reference: ReferenceDataService) { Available boards: - movers: gainers/losers/most-active + value/growth/size screener lists (intraday) -- calendar: upcoming earnings, IPOs and ex-dividend dates (14-day window by default) +- calendar: upcoming macro events, earnings, IPOs and ex-dividend dates (14-day window by default) - macro: 14 US macro series cards — rates, labor, CPI YoY, oil, dollar, M2, sentiment (FRED) - valuation: S&P 500 PE / Shiller CAPE / earnings yield / dividend yield (multpl) - term-structure: BTC/ETH futures curve with annualized basis vs perpetual (Deribit) diff --git a/src/webui/routes/reference.spec.ts b/src/webui/routes/reference.spec.ts index 5fded898b..b475f21a4 100644 --- a/src/webui/routes/reference.spec.ts +++ b/src/webui/routes/reference.spec.ts @@ -16,6 +16,11 @@ function mkCtx(overrides?: Partial): EngineContext { meta: { provider: 'yfinance', asOf: '2026-06-10T00:00:00.000Z' }, }), calendar: async () => ({ + economicEvents: [{ + date: '2026-06-12 12:30:00', country: 'US', category: 'Employment', event: 'Nonfarm Payrolls', + importance: 'High', source: 'BLS', currency: 'USD', unit: 'K', consensus: 150, + previous: 139, revised: null, actual: null, + }], earnings: [{ report_date: '2026-06-12', symbol: 'AAPL', name: 'Apple', eps_previous: 1.2, eps_consensus: 1.4 }], ipos: [], dividends: [], window: { start: '2026-06-10', end: '2026-06-24' }, @@ -69,6 +74,7 @@ describe('reference routes', () => { it('GET /calendar returns the board with the window', async () => { const res = await createReferenceRoutes(mkCtx()).request('/calendar') const body = await res.json() + expect(body.economicEvents[0].event).toBe('Nonfarm Payrolls') expect(body.earnings[0].symbol).toBe('AAPL') expect(body.window.start).toBe('2026-06-10') expect(body.meta.provider).toBe('fmp') diff --git a/src/webui/routes/reference.ts b/src/webui/routes/reference.ts index 884ed7a79..9c03e8695 100644 --- a/src/webui/routes/reference.ts +++ b/src/webui/routes/reference.ts @@ -23,7 +23,7 @@ export function createReferenceRoutes(ctx: EngineContext): Hono { } }) - // GET /api/reference/calendar?days= → earnings / IPO / ex-dividend board + // GET /api/reference/calendar?days= → macro events / earnings / IPO / ex-dividend board app.get('/calendar', async (c) => { const daysRaw = c.req.query('days') const days = daysRaw ? Math.max(1, Math.min(60, Number(daysRaw) || 14)) : undefined diff --git a/ui/src/api/reference.ts b/ui/src/api/reference.ts index e5ae5b74e..1b4f3043f 100644 --- a/ui/src/api/reference.ts +++ b/ui/src/api/reference.ts @@ -72,13 +72,30 @@ export interface DividendEvent { payment_date: string | null } +export interface EconomicEvent { + date: string | null + country: string | null + category: string | null + event: string | null + importance: string | null + source: string | null + currency: string | null + unit: string | null + consensus: string | number | null + previous: string | number | null + revised: string | number | null + actual: string | number | null +} + export interface CalendarBoard { + /** Optional while older hosted TraderHub deployments roll onto this contract. */ + economicEvents?: EconomicEvent[] earnings: EarningsEvent[] ipos: IpoEvent[] dividends: DividendEvent[] window: { start: string; end: string } /** Per-list upstream failures (e.g. FMP tier rejects one endpoint). */ - errors?: Partial> + errors?: Partial> meta: ReferenceMeta } diff --git a/ui/src/components/workspace/AgentLaunchControls.spec.tsx b/ui/src/components/workspace/AgentLaunchControls.spec.tsx index 1be4f6932..9729820a1 100644 --- a/ui/src/components/workspace/AgentLaunchControls.spec.tsx +++ b/ui/src/components/workspace/AgentLaunchControls.spec.tsx @@ -1,6 +1,6 @@ // @vitest-environment jsdom -import { cleanup, fireEvent, render, screen } from '@testing-library/react' +import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' @@ -195,14 +195,14 @@ describe('AgentLaunchSelectors keyboard menus', () => { trigger.focus() await user.keyboard('{ArrowUp}') expect(screen.getByText(i18n.t('chatLanding.credentialMenuTitle', { runtime: 'OpenCode' }))).toBeTruthy() - expect(document.activeElement).toBe(screen.getByRole('menuitem', { name: /Backup/ })) + await waitFor(() => expect(document.activeElement).toBe(screen.getByRole('menuitem', { name: /Backup/ }))) await user.keyboard('{Escape}') expect(screen.queryByRole('menu')).toBeNull() expect(document.activeElement).toBe(trigger) await user.keyboard('{ArrowDown}') - expect(document.activeElement).toBe(screen.getByRole('menuitem', { name: /OpenCode account/ })) + await waitFor(() => expect(document.activeElement).toBe(screen.getByRole('menuitem', { name: /OpenCode account/ }))) await user.keyboard('{End}') expect(document.activeElement).toBe(screen.getByRole('menuitem', { name: /Backup/ })) await user.keyboard('{Enter}') diff --git a/ui/src/demo/handlers/market.ts b/ui/src/demo/handlers/market.ts index 01cca6f4a..d0af5fea5 100644 --- a/ui/src/demo/handlers/market.ts +++ b/ui/src/demo/handlers/market.ts @@ -209,6 +209,18 @@ const demoMovers: MoversBoard = { } const demoCalendar: CalendarBoard = { + economicEvents: [ + { + date: '2026-06-10 12:30:00', country: 'US', category: 'Inflation', event: 'Consumer Price Index', + importance: 'High', source: 'BLS', currency: 'USD', unit: '%', consensus: 2.5, + previous: 2.6, revised: null, actual: 2.4, + }, + { + date: '2026-06-17 18:00:00', country: 'US', category: 'Central Bank', event: 'FOMC Rate Decision', + importance: 'High', source: 'Federal Reserve', currency: 'USD', unit: '%', consensus: 4.25, + previous: 4.25, revised: null, actual: null, + }, + ], earnings: [ { report_date: '2026-06-11', symbol: 'ORCL', name: 'Oracle Corporation', eps_previous: 1.41, eps_consensus: 1.65 }, { report_date: '2026-06-12', symbol: 'ADBE', name: 'Adobe Inc.', eps_previous: 4.48, eps_consensus: 4.97 }, diff --git a/ui/src/i18n/locales/en.ts b/ui/src/i18n/locales/en.ts index 6b6a20e51..e92ed55a6 100644 --- a/ui/src/i18n/locales/en.ts +++ b/ui/src/i18n/locales/en.ts @@ -1775,12 +1775,13 @@ export const en = { colVolume: 'Volume', colDollarVolume: '$ Volume', boardCalendar: 'Calendar', - calendarSubtitle: 'Upcoming earnings, IPOs and ex-dividend dates.', + calendarSubtitle: 'Upcoming macro releases, earnings, IPOs and ex-dividend dates.', calendarSlowLoading: 'Still fetching the calendar — first load can take a while.', calendarSearch: 'Search calendar events', - calendarSearchPlaceholder: 'Filter by symbol, company, date, or venue…', + calendarSearchPlaceholder: 'Filter by event, symbol, country, date, or venue…', calendarShowing: 'Showing {{visible}} of {{total}} events', calendarShowMore: 'Show {{count}} more events', + calEconomic: 'Macro events', calEarnings: 'Earnings', calIpos: 'IPOs', calDividends: 'Dividends', @@ -1791,6 +1792,20 @@ export const en = { colExDate: 'Ex-date', colDivAmount: 'Amount', colPayDate: 'Pay date', + calendarCountry: 'Country', + calendarAllCountries: 'All countries', + calendarImportance: 'Importance', + calendarAllImportance: 'All importance levels', + importanceHigh: 'High', + importanceMedium: 'Medium', + importanceLow: 'Low', + importanceUnknown: 'Unrated', + colLocalTime: 'Local time', + colEvent: 'Event', + colImportance: 'Importance', + colPrevious: 'Previous', + colConsensus: 'Consensus', + colActual: 'Actual', boardMacro: 'Macro', macroSubtitle: 'Rates, labor, inflation, oil and the dollar — the regime inputs.', macroFedFunds: 'Fed Funds Rate', diff --git a/ui/src/i18n/locales/ja.ts b/ui/src/i18n/locales/ja.ts index 21ff6220c..1bc229eb4 100644 --- a/ui/src/i18n/locales/ja.ts +++ b/ui/src/i18n/locales/ja.ts @@ -1743,12 +1743,13 @@ export const ja: Resources = { colVolume: '出来高', colDollarVolume: '売買代金', boardCalendar: 'カレンダー', - calendarSubtitle: '今後の決算・IPO・権利落ち日。', + calendarSubtitle: '今後のマクロ統計・決算・IPO・権利落ち日。', calendarSlowLoading: 'カレンダーを取得中です。初回読み込みには少し時間がかかる場合があります。', calendarSearch: 'カレンダーイベントを検索', - calendarSearchPlaceholder: 'シンボル、会社名、日付、取引所で絞り込む…', + calendarSearchPlaceholder: 'イベント、シンボル、国、日付、取引所で絞り込む…', calendarShowing: '{{total}} 件中 {{visible}} 件を表示', calendarShowMore: 'さらに {{count}} 件のイベントを表示', + calEconomic: 'マクロ指標', calEarnings: '決算', calIpos: 'IPO', calDividends: '配当', @@ -1759,6 +1760,20 @@ export const ja: Resources = { colExDate: '権利落ち日', colDivAmount: '金額', colPayDate: '支払日', + calendarCountry: '国', + calendarAllCountries: 'すべての国', + calendarImportance: '重要度', + calendarAllImportance: 'すべての重要度', + importanceHigh: '高', + importanceMedium: '中', + importanceLow: '低', + importanceUnknown: '未評価', + colLocalTime: '現地時間', + colEvent: 'イベント', + colImportance: '重要度', + colPrevious: '前回', + colConsensus: '予想', + colActual: '結果', boardMacro: 'マクロ', macroSubtitle: '金利・雇用・インフレ・原油・ドル — 市場レジームの入力。', macroFedFunds: 'FF金利', diff --git a/ui/src/i18n/locales/zh-Hant.ts b/ui/src/i18n/locales/zh-Hant.ts index ebbafd6c0..fc7308601 100644 --- a/ui/src/i18n/locales/zh-Hant.ts +++ b/ui/src/i18n/locales/zh-Hant.ts @@ -1750,12 +1750,13 @@ export const zhHant: Resources = { colVolume: '成交量', colDollarVolume: '成交額', boardCalendar: '財經日曆', - calendarSubtitle: '近期財報、IPO 與除息日。', + calendarSubtitle: '近期宏觀數據、財報、IPO 與除息日。', calendarSlowLoading: '財經日曆仍在載入,首次開啟可能需要久一點。', calendarSearch: '搜尋日曆事件', - calendarSearchPlaceholder: '依代碼、公司、日期或交易所篩選…', + calendarSearchPlaceholder: '依事件、代碼、國家、日期或交易所篩選…', calendarShowing: '正在顯示 {{visible}} / {{total}} 個事件', calendarShowMore: '再顯示 {{count}} 個事件', + calEconomic: '宏觀事件', calEarnings: '財報', calIpos: 'IPO', calDividends: '股息', @@ -1766,6 +1767,20 @@ export const zhHant: Resources = { colExDate: '除息日', colDivAmount: '金額', colPayDate: '派息日', + calendarCountry: '國家', + calendarAllCountries: '全部國家', + calendarImportance: '重要性', + calendarAllImportance: '全部重要性', + importanceHigh: '高', + importanceMedium: '中', + importanceLow: '低', + importanceUnknown: '未評級', + colLocalTime: '本地時間', + colEvent: '事件', + colImportance: '重要性', + colPrevious: '前值', + colConsensus: '預期', + colActual: '實際值', boardMacro: '宏觀', macroSubtitle: '利率、就業、通膨、油價與美元——市場 regime 的輸入項。', macroFedFunds: '聯邦基金利率', diff --git a/ui/src/i18n/locales/zh.ts b/ui/src/i18n/locales/zh.ts index 66dbcef33..e574dff4a 100644 --- a/ui/src/i18n/locales/zh.ts +++ b/ui/src/i18n/locales/zh.ts @@ -1742,12 +1742,13 @@ export const zh: Resources = { colVolume: '成交量', colDollarVolume: '成交额', boardCalendar: '财经日历', - calendarSubtitle: '近期财报、IPO 与除息日。', + calendarSubtitle: '近期宏观数据、财报、IPO 与除息日。', calendarSlowLoading: '财经日历仍在加载,首次打开可能需要久一点。', calendarSearch: '搜索日历事件', - calendarSearchPlaceholder: '按代码、公司、日期或交易所筛选…', + calendarSearchPlaceholder: '按事件、代码、国家、日期或交易所筛选…', calendarShowing: '正在显示 {{visible}} / {{total}} 个事件', calendarShowMore: '再显示 {{count}} 个事件', + calEconomic: '宏观事件', calEarnings: '财报', calIpos: 'IPO', calDividends: '分红', @@ -1758,6 +1759,20 @@ export const zh: Resources = { colExDate: '除息日', colDivAmount: '金额', colPayDate: '派息日', + calendarCountry: '国家', + calendarAllCountries: '全部国家', + calendarImportance: '重要性', + calendarAllImportance: '全部重要性', + importanceHigh: '高', + importanceMedium: '中', + importanceLow: '低', + importanceUnknown: '未评级', + colLocalTime: '本地时间', + colEvent: '事件', + colImportance: '重要性', + colPrevious: '前值', + colConsensus: '预期', + colActual: '实际值', boardMacro: '宏观', macroSubtitle: '利率、就业、通胀、油价与美元——市场 regime 的输入项。', macroFedFunds: '联邦基金利率', diff --git a/ui/src/office/OfficeBuilding.spec.tsx b/ui/src/office/OfficeBuilding.spec.tsx index 5d2ea9ae5..acead888c 100644 --- a/ui/src/office/OfficeBuilding.spec.tsx +++ b/ui/src/office/OfficeBuilding.spec.tsx @@ -1038,7 +1038,7 @@ describe('OfficeBuilding', () => { expect(screen.queryByRole('menuitemradio', { name: 'Live map' })).toBeNull() expect(screen.queryByRole('menuitemradio', { name: 'All groups' })).toBeNull() expect(screen.getByLabelText('Current floor view: Live map').textContent).toContain('Current') - expect(document.activeElement).toBe(screen.getByRole('menuitem', { name: 'Activity log' })) + await waitFor(() => expect(document.activeElement).toBe(screen.getByRole('menuitem', { name: 'Activity log' }))) await userEvent.keyboard('{Escape}') const predictionSign = screen.getByRole('button', { name: /Enter prediction workspace/ }) expect(predictionSign.textContent).toContain('0/0 awake') @@ -1926,7 +1926,7 @@ describe('OfficeBuilding', () => { expect(controlsLegend.textContent).toContain('Enter/SpaceInteract') expect(controlsLegend.textContent).toContain('EscMenu / cancel') expect(controlsLegend.querySelectorAll('kbd')).toHaveLength(6) - expect(document.activeElement).toBe(screen.getByRole('menuitemradio', { name: 'Live map' })) + await waitFor(() => expect(document.activeElement).toBe(screen.getByRole('menuitemradio', { name: 'Live map' }))) await userEvent.keyboard('{ArrowDown}') expect(document.activeElement).toBe(screen.getByRole('menuitemradio', { name: 'All groups' })) await userEvent.keyboard('{Enter}') @@ -1938,7 +1938,7 @@ describe('OfficeBuilding', () => { expect(screen.getByRole('menuitemradio', { name: 'All groups' }) .querySelector('.oa-office-pause-menu__selection')?.src) .toContain('/office/hud/journal-cursor-v1.png') - expect(document.activeElement).toBe(screen.getByRole('menuitemradio', { name: 'Live map' })) + await waitFor(() => expect(document.activeElement).toBe(screen.getByRole('menuitemradio', { name: 'Live map' }))) await userEvent.keyboard('{ArrowDown}') expect(document.activeElement).toBe(screen.getByRole('menuitemradio', { name: 'All groups' })) await userEvent.keyboard('{ArrowUp}') diff --git a/ui/src/pages/MarketBoardPage.spec.tsx b/ui/src/pages/MarketBoardPage.spec.tsx index b7981271c..f31c41e0b 100644 --- a/ui/src/pages/MarketBoardPage.spec.tsx +++ b/ui/src/pages/MarketBoardPage.spec.tsx @@ -236,6 +236,23 @@ describe('MarketBoardPage', () => { mocks.boardData = { meta: {}, window: { start: '2026-08-04', end: '2026-08-13' }, + economicEvents: [ + { + date: '2026-08-07 12:30:00', country: 'US', currency: 'USD', category: 'Employment', + event: 'Nonfarm Payrolls', importance: 'High', source: 'BLS', unit: 'K', + previous: 120, consensus: 135, revised: null, actual: null, + }, + { + date: '2026-08-08 06:00:00', country: 'DE', currency: 'EUR', category: 'Industry', + event: 'Industrial Production', importance: 'Medium', source: 'Destatis', unit: '%', + previous: -0.5, consensus: 0.2, revised: null, actual: 0.4, + }, + { + date: '2026-08-09 12:30:00', country: 'US', currency: 'USD', category: 'Inflation', + event: 'Consumer Price Index', importance: 'High', source: 'BLS', unit: '%', + previous: 2.6, consensus: 2.5, revised: null, actual: null, + }, + ], earnings: Array.from({ length: 120 }, (_, index) => ({ report_date: index < 60 ? '2026-08-04' : '2026-08-05', symbol: `E${String(index).padStart(3, '0')}`, @@ -257,6 +274,17 @@ describe('MarketBoardPage', () => { />, ) + expect(screen.getByRole('button', { name: 'Macro events (3)' }).getAttribute('aria-pressed')).toBe('true') + expect(screen.getByRole('columnheader', { name: 'Local time' })).toBeTruthy() + expect(screen.getAllByText('Nonfarm Payrolls')).toHaveLength(2) + expect(screen.getAllByText('High').length).toBeGreaterThan(0) + await user.selectOptions(screen.getByRole('combobox', { name: 'Importance' }), 'high') + await waitFor(() => expect(screen.getByText('Showing 2 of 2 events')).toBeTruthy()) + await user.selectOptions(screen.getByRole('combobox', { name: 'Country' }), 'DE') + expect(screen.getByText('No matches')).toBeTruthy() + expect(screen.getByRole('combobox', { name: 'Country' })).toBeTruthy() + + await user.click(screen.getByRole('button', { name: 'Earnings (120)' })) expect(screen.getByRole('button', { name: 'Earnings (120)' }).getAttribute('aria-pressed')).toBe('true') expect(screen.getByText('Showing 50 of 120 events')).toBeTruthy() expect(within(screen.getByTestId('calendar-mobile')).getAllByRole('button')).toHaveLength(50) diff --git a/ui/src/pages/MarketBoardPage.tsx b/ui/src/pages/MarketBoardPage.tsx index 55fdde245..8d7ff078d 100644 --- a/ui/src/pages/MarketBoardPage.tsx +++ b/ui/src/pages/MarketBoardPage.tsx @@ -13,7 +13,7 @@ import { MeasuredChartFrame } from '../components/MeasuredChartFrame' import { referenceApi, type MoversBoard, type MoverRow, type ReferenceMeta, type CalendarBoard, - type EarningsEvent, type IpoEvent, type DividendEvent, + type EarningsEvent, type IpoEvent, type DividendEvent, type EconomicEvent, type MacroBoard, type MacroSeriesCard, type TermStructureBoard, type TermCurve, type GlobalMacroBoard, type GlobalMacroCell, type ShippingBoard, type ShippingCurve, type FedBoard, @@ -157,26 +157,36 @@ function MoversTable({ rows }: { rows: MoverRow[] }) { // ==================== Calendar ==================== -type CalendarList = 'earnings' | 'ipos' | 'dividends' +type CalendarList = 'economic' | 'earnings' | 'ipos' | 'dividends' +type ImportanceFilter = 'all' | 'high' | 'medium' | 'low' const CALENDAR_PAGE_SIZE = 50 function CalendarBoardView() { - const { t } = useTranslation() + const { t, i18n } = useTranslation() const { data, updatedAt, loading, slow, error, retry } = useReferenceBoard(referenceApi.calendar, 30 * 60 * 1000) - const [list, setList] = useState('earnings') + const [list, setList] = useState('economic') const [query, setQuery] = useState('') + const [countryFilter, setCountryFilter] = useState('') + const [importanceFilter, setImportanceFilter] = useState('all') const [visibleCount, setVisibleCount] = useState(CALENDAR_PAGE_SIZE) const sortedRows = useMemo(() => ({ + economic: data ? [...(data.economicEvents ?? [])].sort(compareEconomicEvents) : [], earnings: data ? [...data.earnings].sort((a, b) => a.report_date.localeCompare(b.report_date)) : [], ipos: data ? [...data.ipos].sort((a, b) => (a.ipo_date ?? '').localeCompare(b.ipo_date ?? '')) : [], dividends: data ? [...data.dividends].sort((a, b) => a.ex_dividend_date.localeCompare(b.ex_dividend_date)) : [], }), [data]) const filteredRows = useMemo(() => { const normalized = query.trim().toLocaleLowerCase() - if (!normalized) return sortedRows const includesQuery = (...parts: unknown[]) => parts.some((part) => String(part ?? '').toLocaleLowerCase().includes(normalized)) return { + economic: sortedRows.economic.filter((row) => + (!countryFilter || row.country === countryFilter) + && (importanceFilter === 'all' || normalizeImportance(row.importance) === importanceFilter) + && (!normalized || includesQuery( + row.date, row.country, row.currency, row.category, row.event, + row.importance, row.source, row.previous, row.consensus, row.actual, + ))), earnings: sortedRows.earnings.filter((row) => includesQuery(row.report_date, row.symbol, row.name)), ipos: sortedRows.ipos.filter((row) => @@ -184,9 +194,15 @@ function CalendarBoardView() { dividends: sortedRows.dividends.filter((row) => includesQuery(row.ex_dividend_date, row.payment_date, row.symbol, row.name)), } - }, [query, sortedRows]) + }, [countryFilter, importanceFilter, query, sortedRows]) + const countries = useMemo(() => [...new Set( + sortedRows.economic.map((row) => row.country).filter((country): country is string => Boolean(country)), + )].sort(), [sortedRows.economic]) + const locale = i18n.resolvedLanguage ?? i18n.language const activeRows = filteredRows[list] + const sourceRowCount = sortedRows[list].length const activeVisibleCount = Math.min(visibleCount, activeRows.length) + const activeError = data?.errors?.[calendarErrorKey(list)] useEffect(() => { setVisibleCount(CALENDAR_PAGE_SIZE) @@ -206,17 +222,17 @@ function CalendarBoardView() { />
- {(['earnings', 'ipos', 'dividends'] as const).map((k) => ( + {(['economic', 'earnings', 'ipos', 'dividends'] as const).map((k) => ( ))} @@ -249,11 +265,11 @@ function CalendarBoardView() {
)} {/* Per-list upstream failure — loud, with the provider's own message. */} - {data?.errors?.[list] && ( -
{data.errors[list]}
+ {activeError && ( +
{activeError}
)} - {data && data[list].length > 0 && !data.errors?.[list] && ( + {data && sourceRowCount > 0 && !activeError && (
+ {list === 'economic' && ( +
+ + + + +
+ )} {activeRows.length > 0 && (
{t('market.calendarShowing', { @@ -283,9 +325,12 @@ function CalendarBoardView() {
)} - {data && activeRows.length === 0 && !loading && !data.errors?.[list] && ( + {data && activeRows.length === 0 && !loading && !activeError && (
{t('market.noMatches')}
)} + {data && list === 'economic' && filteredRows.economic.length > 0 && ( + + )} {data && list === 'earnings' && filteredRows.earnings.length > 0 && ( )} @@ -314,12 +359,149 @@ function CalendarBoardView() { function calendarLabelKey(k: CalendarList) { switch (k) { + case 'economic': return 'market.calEconomic' as const case 'earnings': return 'market.calEarnings' as const case 'ipos': return 'market.calIpos' as const case 'dividends': return 'market.calDividends' as const } } +function calendarRowCount(data: CalendarBoard | null, list: CalendarList): number { + if (!data) return 0 + return list === 'economic' ? (data.economicEvents?.length ?? 0) : data[list].length +} + +function calendarErrorKey(list: CalendarList): keyof NonNullable { + return list === 'economic' ? 'economicEvents' : list +} + +function economicEventTimestamp(value: string | null): number { + if (!value) return Number.POSITIVE_INFINITY + const normalized = /^\d{4}-\d{2}-\d{2} \d{2}:\d{2}(?::\d{2})?$/.test(value) + ? `${value.replace(' ', 'T')}Z` + : value + const timestamp = new Date(normalized).getTime() + return Number.isNaN(timestamp) ? Number.POSITIVE_INFINITY : timestamp +} + +function compareEconomicEvents(a: EconomicEvent, b: EconomicEvent): number { + return economicEventTimestamp(a.date) - economicEventTimestamp(b.date) +} + +function normalizeImportance(value: string | null): Exclude | 'other' { + const normalized = value?.trim().toLocaleLowerCase() ?? '' + if (normalized.includes('high')) return 'high' + if (normalized.includes('medium') || normalized.includes('moderate')) return 'medium' + if (normalized.includes('low')) return 'low' + return 'other' +} + +function formatEconomicEventDate(value: string | null, locale: string): string { + const timestamp = economicEventTimestamp(value) + if (!Number.isFinite(timestamp)) return value ?? '—' + return new Intl.DateTimeFormat(locale, { + year: 'numeric', + month: 'short', + day: 'numeric', + hour: '2-digit', + minute: '2-digit', + hour12: false, + timeZoneName: 'short', + }).format(timestamp) +} + +function formatEconomicValue(value: string | number | null, unit: string | null): string { + if (value === null || value === '') return '—' + const text = String(value) + return unit && !text.includes(unit) ? `${text} ${unit}` : text +} + +function ImportanceBadge({ value }: { value: string | null }) { + const { t } = useTranslation() + const importance = normalizeImportance(value) + const label = importance === 'high' + ? t('market.importanceHigh') + : importance === 'medium' + ? t('market.importanceMedium') + : importance === 'low' + ? t('market.importanceLow') + : value || t('market.importanceUnknown') + const className = importance === 'high' + ? 'border-destructive/30 bg-destructive/10 text-destructive' + : importance === 'medium' + ? 'border-warning/30 bg-warning/10 text-warning' + : 'border-border bg-muted/60 text-muted-foreground' + return ( + + {label} + + ) +} + +function EconomicEventsTable({ rows, locale }: { rows: EconomicEvent[]; locale: string }) { + const { t } = useTranslation() + return ( + <> +
+ {rows.map((row, index) => ( +
+
+
+

{row.event ?? row.category ?? '—'}

+

+ {[row.country, row.currency, row.category].filter(Boolean).join(' · ') || '—'} +

+
+ +
+ +
+ {[ + [t('market.colPrevious'), formatEconomicValue(row.previous, row.unit)], + [t('market.colConsensus'), formatEconomicValue(row.consensus, row.unit)], + [t('market.colActual'), formatEconomicValue(row.actual, row.unit)], + ].map(([label, value]) => ( +
+
{label}
+
{value}
+
+ ))} +
+
+ ))} +
+ + {rows.map((row, index) => ( + + + + + +
{row.event ?? row.category ?? '—'}
+ {row.category && row.category !== row.event &&
{row.category}
} + + + {[row.country, row.currency].filter(Boolean).join(' · ') || '—'} + + + {formatEconomicValue(row.previous, row.unit)} + {formatEconomicValue(row.consensus, row.unit)} + {formatEconomicValue(row.actual, row.unit)} + + ))} +
+ + ) +} + function useOpenEquity() { const openOrFocus = useWorkspace((s) => s.openOrFocus) return (symbol: string | null) => {