Skip to content
Merged
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
66 changes: 66 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -220,3 +220,69 @@ work meaningfully shifts the PR's framing>

This keeps the PR description as a faithful audit trail across sessions,
and lets reviewers see who-did-what without trawling the commit log alone.

### Default vs. isolated branch — when to deviate from `dev`

The default for any session is **work on `dev`** and let the rolling
PR carry it to master. The exception is **invasive, long-running work
that shouldn't share a branch with parallel sessions** — typically a
refactor of shared types / cross-cutting infrastructure that, while
in-flight, would force every other session to rebase against churn
they don't have context for.

Examples worth isolating: changing a base interface every broker
implements; renaming or restructuring a module everyone imports from;
multi-day schema migrations.
Examples that stay on `dev`: any feature, any local fix, anything
scoped to one subsystem.

When isolation is the right call:

```bash
# Branch from master (clean baseline, dev's churn won't bleed in)
git fetch origin
git checkout master
git pull origin master
git checkout -b refactor/<short-name>

# During the refactor, periodically rebase against master so the
# eventual merge stays small. Skip dev — its session-by-session
# churn is intentionally not part of the baseline you're testing
# against.
git fetch origin
git rebase origin/master

# When done, PR straight to master (NOT dev). The refactor is its own
# coherent unit, reviewed end-to-end.
git push -u origin refactor/<short-name>
gh pr create --base master --head refactor/<short-name> ...
```

**After the refactor merges**, dev needs to absorb the new master so
in-flight sessions land on the new baseline:

```bash
git checkout dev
git pull origin dev
git fetch origin
git merge origin/master
git push origin dev
```

In-flight rolling-PR work then sees the refactor in their next pull
and rebases naturally. Their diffs against the refreshed `dev` may
need real fix-ups (that's the cost of an invasive refactor — and
the reason you isolated it in the first place).

**Decision rule for the next session that starts work:** if `master`
is currently ahead of `dev` (because a refactor branch just landed
there), do `git checkout dev && git merge origin/master` *before*
starting any new feature work. Otherwise your new commits will land
on a stale baseline.

**Parallel work happens in the cloud, not in local worktrees.** For a
project this size, spinning up multiple local worktrees costs more
in `pnpm install` / `data/` copying / port juggling than it saves.
Hand parallel tracks off to cloud Claude sessions instead — each
gets its own sandbox, returns a PR, and doesn't touch the local
working tree.
16 changes: 16 additions & 0 deletions TODO.md
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,22 @@ the item when done — git log is the history.

## Bugs

- [ ] IBKR `getNativeKey` may use the wrong field for nativeKey. Surfaced
2026-05-07 during the Phase-3 revert (`afddd41`) when articulating
the per-broker uniqueness scheme. IBKR's `Contract.symbol` and
`Contract.localSymbol` aren't reliably unique — one symbol "AAPL"
matches the underlying stock + every option chain expiry + every
weekly + every LEAP. The actual primary key is `conId` (numeric).
If `IbkrBroker.getNativeKey` currently returns `localSymbol ||
symbol`, it works only by accident — the moment users hold the
same underlying across multiple expiries, aliceId starts colliding.
Audit `src/domain/trading/brokers/ibkr/IbkrBroker.ts` (look for
`getNativeKey`); change to `String(contract.conId)` if not already.
Also extend the same audit to Alpaca / LeverUp / Bybit-direct
brokers — each should have an explicit getNativeKey returning the
broker's documented primary key, not a lazy `localSymbol ||
symbol` fallback.

- [ ] Snapshot / FX: after currency conversion, snapshot values
occasionally come out as wildly wrong numbers (reported, cause
unknown). Likely a direction mistake (multiply vs divide) or
Expand Down
6 changes: 5 additions & 1 deletion packages/opentypebb/src/providers/eia/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,11 @@ export const eiaProvider = new Provider({
description:
'The U.S. Energy Information Administration (EIA) collects, analyzes, and ' +
'disseminates independent and impartial energy information.',
credentials: ['eia_api_key'],
// Plain `api_key` — Provider constructor auto-prefixes with provider name,
// so the runtime credential field becomes `eia_api_key`. Declaring the
// already-prefixed form here would double-prefix to `eia_eia_api_key`
// and silently break credential propagation.
credentials: ['api_key'],
fetcherDict: {
PetroleumStatusReport: EIAPetroleumStatusReportFetcher,
ShortTermEnergyOutlook: EIAShortTermEnergyOutlookFetcher,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,10 @@ interface EiaResponse {
response?: {
data?: Array<{
period: string
value: number | null
// EIA API serialises numeric observations as strings ("75.10"), with
// null only for missing periods. Coerce in extractData; the schema
// expects number.
value: string | number | null
'series-description'?: string
}>
}
Expand All @@ -47,12 +50,16 @@ export class EIAPetroleumStatusReportFetcher extends Fetcher {
const catInfo = CATEGORY_SERIES[query.category]
if (!catInfo) throw new EmptyDataError(`Unknown category: ${query.category}`)

// EIA API v2 takes PHP-style bracket params, not JSON-encoded sort —
// see https://www.eia.gov/opendata/documentation.php. The JSON form
// is silently rejected with HTTP 403 on most endpoints.
const params = new URLSearchParams({
api_key: apiKey,
frequency: 'weekly',
'data[0]': 'value',
'facets[series][]': catInfo.series,
sort: JSON.stringify([{ column: 'period', direction: 'desc' }]),
'sort[0][column]': 'period',
'sort[0][direction]': 'desc',
length: '260', // ~5 years of weekly data
})

Expand All @@ -65,9 +72,11 @@ export class EIAPetroleumStatusReportFetcher extends Fetcher {
const results: Record<string, unknown>[] = []
for (const obs of data.response?.data ?? []) {
if (obs.value == null) continue
const value = typeof obs.value === 'string' ? parseFloat(obs.value) : obs.value
if (Number.isNaN(value)) continue
results.push({
date: obs.period,
value: obs.value,
value,
category: query.category,
unit: catInfo.unit,
})
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,10 @@ interface EiaSteoResponse {
response?: {
data?: Array<{
period: string
value: number | null
// EIA API serialises numeric observations as strings ("75.10"), with
// null only for missing periods. Coerce in extractData; the schema
// expects number.
value: string | number | null
seriesDescription?: string
}>
}
Expand All @@ -46,12 +49,16 @@ export class EIAShortTermEnergyOutlookFetcher extends Fetcher {
const catInfo = CATEGORY_SERIES[query.category]
if (!catInfo) throw new EmptyDataError(`Unknown STEO category: ${query.category}`)

// EIA API v2 takes PHP-style bracket params, not JSON-encoded sort —
// see https://www.eia.gov/opendata/documentation.php. The JSON form
// is silently rejected with HTTP 403 on most endpoints.
const params = new URLSearchParams({
api_key: apiKey,
frequency: 'monthly',
'data[0]': 'value',
'facets[seriesId][]': catInfo.series,
sort: JSON.stringify([{ column: 'period', direction: 'desc' }]),
'sort[0][column]': 'period',
'sort[0][direction]': 'desc',
length: '120', // ~10 years of monthly data
})

Expand All @@ -68,9 +75,11 @@ export class EIAShortTermEnergyOutlookFetcher extends Fetcher {
const results: Record<string, unknown>[] = []
for (const obs of data.response?.data ?? []) {
if (obs.value == null) continue
const value = typeof obs.value === 'string' ? parseFloat(obs.value) : obs.value
if (Number.isNaN(value)) continue
results.push({
date: `${obs.period}-01`,
value: obs.value,
value,
category: query.category,
unit: catInfo.unit,
forecast: obs.period > currentPeriod,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { amakeRequest } from '../../../core/provider/utils/helpers.js'
import { EmptyDataError } from '../../../core/provider/utils/errors.js'

const FRED_BASE = 'https://api.stlouisfed.org/fred'
const GEOFRED_BASE = 'https://api.stlouisfed.org/geofred'

export interface FredObservation {
date: string
Expand All @@ -27,13 +28,17 @@ export interface FredSeriesInfo {

/**
* Build a FRED API URL with common parameters.
*
* Pass `base` to target a sibling API tree on api.stlouisfed.org —
* GeoFRED is at /geofred/... (no /fred/ prefix), not under /fred/geofred/.
*/
function buildFredUrl(
endpoint: string,
params: Record<string, string | number | undefined>,
apiKey: string,
base: string = FRED_BASE,
): string {
const url = new URL(`${FRED_BASE}/${endpoint}`)
const url = new URL(`${base}/${endpoint}`)
url.searchParams.set('file_type', 'json')
if (apiKey) url.searchParams.set('api_key', apiKey)
for (const [k, v] of Object.entries(params)) {
Expand All @@ -46,6 +51,13 @@ function buildFredUrl(

/**
* Fetch observations for a single FRED series.
*
* When the caller supplies `limit` without an explicit start date,
* "limit N" means "the latest N observations" — fetch desc and reverse
* to ascending so downstream date-based merges keep working. Asking
* upstream desc + reversing is what aligns with the OpenBB Python
* upstream and with user intuition; the prior asc default returned
* 1946-era observations for any limited query without an anchor date.
*/
export async function fetchFredSeries(
seriesId: string,
Expand All @@ -59,18 +71,23 @@ export async function fetchFredSeries(
units?: string
} = {},
): Promise<FredObservation[]> {
const sortOrder = opts.sortOrder ?? 'desc'
const url = buildFredUrl('series/observations', {
series_id: seriesId,
observation_start: opts.startDate ?? undefined,
observation_end: opts.endDate ?? undefined,
limit: opts.limit,
sort_order: opts.sortOrder ?? 'asc',
sort_order: sortOrder,
frequency: opts.frequency,
units: opts.units,
}, apiKey)

const data = await amakeRequest<{ observations?: FredObservation[] }>(url)
return (data.observations ?? []).filter(o => o.value !== '.')
const observations = (data.observations ?? []).filter(o => o.value !== '.')
// Caller-facing contract is ascending. If the upstream call ran desc,
// reverse before returning so multiSeriesToRecords' localeCompare and
// any date-ordered consumer keeps the same shape as before.
return sortOrder === 'desc' ? observations.reverse() : observations
}

/**
Expand Down Expand Up @@ -151,9 +168,14 @@ export async function fredReleaseTableApi(

/**
* Fetch FRED regional/GeoFRED data.
*
* GeoFRED lives at api.stlouisfed.org/geofred/... — a sibling tree of
* /fred/, not a child. The endpoint takes `series_id` (e.g. WIPCPI for
* per-capita income), and returns data nested under `meta.data`, keyed
* by observation date.
*/
export async function fredRegionalApi(
seriesGroup: string,
seriesId: string,
apiKey: string,
opts: {
regionType?: string
Expand All @@ -165,23 +187,24 @@ export async function fredRegionalApi(
transformationCode?: string
} = {},
): Promise<Record<string, unknown>[]> {
const url = buildFredUrl('geofred/series/data', {
series_group: seriesGroup,
const url = buildFredUrl('series/data', {
series_id: seriesId,
region_type: opts.regionType ?? 'state',
date: opts.date,
start_date: opts.startDate,
season: opts.seasonalAdjustment ?? 'SA',
units: opts.units,
frequency: opts.frequency,
transformation: opts.transformationCode,
}, apiKey)
}, apiKey, GEOFRED_BASE)

const data = await amakeRequest<{ meta?: Record<string, unknown>; data?: Record<string, unknown> }>(url)
if (!data.data) return []
const data = await amakeRequest<{ meta?: { data?: Record<string, unknown> } }>(url)
const dataMap = data.meta?.data
if (!dataMap) return []

// GeoFRED returns { data: { "2024-01-01": [{ region: ..., value: ... }, ...] } }
// GeoFRED returns { meta: { data: { "2024-01-01": [{ region, code, value, series_id }, ...] } } }
const results: Record<string, unknown>[] = []
for (const [date, regions] of Object.entries(data.data)) {
for (const [date, regions] of Object.entries(dataMap)) {
if (Array.isArray(regions)) {
for (const region of regions) {
results.push({ date, ...(region as Record<string, unknown>) })
Expand Down Expand Up @@ -214,8 +237,17 @@ export function multiSeriesToRecords(
}

/**
* Get credentials helper — extracts FRED API key.
* Get credentials helper — extracts the FRED API key.
*
* The SDK path delivers the key as `federal_reserve_api_key` (the
* provider-prefixed form, see Provider constructor). Older callers
* and direct helper invocations may still pass `fred_api_key` or
* `api_key`; keep them as fallback so this helper stays compatible
* with both call sites.
*/
export function getFredApiKey(credentials: Record<string, string> | null): string {
return credentials?.fred_api_key ?? credentials?.api_key ?? ''
return credentials?.federal_reserve_api_key
?? credentials?.fred_api_key
?? credentials?.api_key
?? ''
}
2 changes: 1 addition & 1 deletion packages/opentypebb/src/standard-models/fred-regional.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
import { z } from 'zod'

export const FredRegionalQueryParamsSchema = z.object({
symbol: z.string().describe('FRED series group ID for GeoFRED data.'),
symbol: z.string().describe('FRED regional series ID (e.g. WIPCPI for per-capita personal income).'),
region_type: z.string().default('state').describe('Region type: state, msa, county, etc.'),
date: z.string().nullable().default(null).describe('Observation date in YYYY-MM-DD.'),
start_date: z.string().nullable().default(null).describe('Start date for data range.'),
Expand Down
Loading
Loading