Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
3 changes: 2 additions & 1 deletion src/domain/market-data/client/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -105,6 +105,7 @@ export interface CommodityClientLike {
}

export interface EconomyClientLike {
getCalendar(params?: Record<string, unknown>): Promise<EconomicCalendarData[]>
fredSearch(params: Record<string, unknown>): Promise<FredSearchData[]>
fredSeries(params: Record<string, unknown>): Promise<FredSeriesData[]>
fredRegional(params: Record<string, unknown>): Promise<FredRegionalData[]>
Expand Down
41 changes: 35 additions & 6 deletions src/domain/market-data/reference/service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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> = {}): 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,
Expand Down Expand Up @@ -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') } }),
Expand All @@ -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({
Expand All @@ -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({
Expand All @@ -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()
Expand Down
38 changes: 28 additions & 10 deletions src/domain/market-data/reference/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,25 +85,33 @@ export function createReferenceData(deps: ReferenceDataDeps): ReferenceDataServi

const calendarCached = cachedBoard(TTL.calendar, async (): Promise<CalendarBoard> => {
const hub = await viaHub<CalendarBoard>('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)
const window = { start: isoDay(start), end: isoDay(end) }
// 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 = <T>(r: PromiseSettledResult<T[]>) => (r.status === 'fulfilled' ? r.value : [])
// Partial failures stay loud too: a suspended/limited FMP tier can
Expand All @@ -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),
Expand Down Expand Up @@ -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 = <T>(r: PromiseSettledResult<T[]>) => (r.status === 'fulfilled' ? r.value : [])
const errors: NonNullable<CalendarBoard['errors']> = {}
const note = (key: keyof NonNullable<CalendarBoard['errors']>, r: PromiseSettledResult<unknown>) => {
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),
Expand Down
6 changes: 4 additions & 2 deletions src/domain/market-data/reference/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -60,6 +61,7 @@ export interface MoversBoard {
// ==================== Calendar board ====================

export interface CalendarBoard {
economicEvents: EconomicCalendarData[]
earnings: CalendarEarningsData[]
ipos: CalendarIpoData[]
dividends: CalendarDividendData[]
Expand All @@ -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<Record<'earnings' | 'ipos' | 'dividends', string>>
errors?: Partial<Record<'economicEvents' | 'earnings' | 'ipos' | 'dividends', string>>
meta: ReferenceMeta
}

Expand Down Expand Up @@ -109,7 +111,7 @@ export interface MacroBoard {
* the clients directly for now and converge here as the contract grows. */
export interface ReferenceDataService {
movers(): Promise<MoversBoard>
/** 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<CalendarBoard>
/** Curated macro regime dashboard (rates, labor, inflation, oil, dollar).
Expand Down
1 change: 1 addition & 0 deletions src/tool/economy.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => []),
Expand Down
2 changes: 1 addition & 1 deletion src/tool/reference-board.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
6 changes: 6 additions & 0 deletions src/webui/routes/reference.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,11 @@ function mkCtx(overrides?: Partial<ReferenceDataService>): 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' },
Expand Down Expand Up @@ -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')
Expand Down
2 changes: 1 addition & 1 deletion src/webui/routes/reference.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
19 changes: 18 additions & 1 deletion ui/src/api/reference.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Record<'earnings' | 'ipos' | 'dividends', string>>
errors?: Partial<Record<'economicEvents' | 'earnings' | 'ipos' | 'dividends', string>>
meta: ReferenceMeta
}

Expand Down
6 changes: 3 additions & 3 deletions ui/src/components/workspace/AgentLaunchControls.spec.tsx
Original file line number Diff line number Diff line change
@@ -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'

Expand Down Expand Up @@ -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}')
Expand Down
12 changes: 12 additions & 0 deletions ui/src/demo/handlers/market.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 },
Expand Down
19 changes: 17 additions & 2 deletions ui/src/i18n/locales/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand All @@ -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',
Expand Down
Loading