Description
Purpose and use
Charts turn operational records into time-series and category views used by dashboards, reports, and monitoring screens. They help users see trends in balances, transfers, FX prices, currency pairs, and other records without exporting raw data first.
Who uses this. Operations managers, treasury users, product analysts, and support leads use chart views to spot volume changes, gaps, unusual spikes, and service trends.
How it works. A chart request selects a record type, metric, grouping, date range, and gap strategy. The module prepares a consistent series for the UI so missing business days, currency-pair gaps, and sparse records display predictably.
What users do. Users choose a chart type, select filters such as currency pair or customer context, pick a time range, and review the resulting trend next to the operational list that produced it.
Outcomes and side effects. Charts are read-only. They do not change balances or transactions; they make existing records easier to monitor and investigate.
Related manuals: FX, Currencies, Accounts & Portfolios, Transfers.
Charts API Endpoints
The Charts module provides a universal chart generation system that converts flexible query parameters into SQL queries and returns data formatted for Nivo charts.
Line Chart
GET /v1/charts/line
Generate line chart data based on flexible query parameters. The system automatically converts the request into appropriate SQL queries and returns data formatted for Nivo line charts.
Core Parameters
| Parameter | Type | Required | Description | Example |
|---|---|---|---|---|
rec_type | string | ✅ | Record type to query | currency_pairs |
x_field | string | ✅ | Field to use for X-axis values | date, base_currency |
y_field | string | ✅ | Field to use for Y-axis values | rate |
Data Control Parameters
| Parameter | Type | Required | Description | Example |
|---|---|---|---|---|
group_by | string | ❌ | Field to group by for multiple series | base_currency, type, source |
limit | integer | ❌ | Maximum data points. Neither AppConfig bound binds at its seeded value — see the note below. Default 10, ceiling 100 | 100 |
offset | integer | ❌ | Offset for pagination. A negative value is silently treated as 0, not rejected. Paging is not stable — there is no ordering | 0 |
fill_gaps | string | ❌ | Gap filling strategy: "no", "daily", "monthly" | daily |
Chart Styling Parameters
| Parameter | Type | Required | Description | Example |
|---|---|---|---|---|
color | string | ❌ | Base color for chart series (HSL) | hsl(58, 70%, 50%) |
Filter Parameters
| Parameter | Type | Required | Description | Example |
|---|---|---|---|---|
search.base_currency | string | ❌ | Filter by base currency | USD, EUR, CHF |
search.target_currency | string | ❌ | Filter by target currency | USD, EUR, CHF |
search.type | string | ❌ | Filter by type | MID, BID, ASK, SELL, BUY |
search.source | string | ❌ | Filter by rate provider (rec_type=currency_pairs only). Falls back to fx.convert.default_source AppConfig when omitted. The override only works for plain equality — see below | SNB, OER, EB |
search.date.gte | string | ❌ | Filter by date greater than or equal to (RFC3339) | 2024-01-01T00:00:00Z |
search.date.lte | string | ❌ | Filter by date less than or equal to (RFC3339) | 2024-12-31T23:59:59Z |
search.date.gt | string | ❌ | Filter by date strictly greater than (RFC3339) | 2024-01-01T00:00:00Z |
search.date.lt | string | ❌ | Filter by date strictly less than (RFC3339) | 2024-12-31T23:59:59Z |
search.rate | string | ❌ | Filter by rate value; the comparison suffixes apply as for date | 1.05 |
search._text | string | ❌ | Reserved free-text filter: ILIKE-OR across base_currency, target_currency, type and source. Own operator set — see below | US |
The suffixes above are not the whole set. The shared search parser accepts .eq, .ne, .gt,
.gte, .lt, .lte, .in, .nin, .like, .start_with and .end_with on these fields,
and .eq=null / .ne=null for a null test (IS NULL and IS NOT NULL respectively); on any
other operator a null value is accepted and the whole filter is then silently dropped, so
?search.rate.gt=null neither narrows the result set nor errors. .ilike and .contains are not among
them — they belong to validTextSearchOperators, which governs the reserved _text field only,
so ?search.source.ilike=SN is parsed as an operator and then rejected with 400
query_m.invalid_operator.
Two behaviours worth knowing: plain equality on a string column compiles to =, which
Postgres evaluates case-sensitively, so ?search.base_currency=usd does not match a stored
USD — .like, .start_with and .end_with are the operators that compile to ILIKE and are
therefore the case-insensitive ones; and a date value is not validated — a malformed one
reaches Postgres and comes back as 500, not 400.
The reserved field itself works on this route. ?search._text.ilike=US is routed by
parseReservedTextSearch and compiled by BuildTextSearchWhereClause into an ILIKE-OR over
every string column of the record type — base_currency, target_currency, type and
source — and answers 200. .eq, .like, .contains, .start_with and .end_with are
accepted on _text as well, and a bare ?search._text=US means .like. It is the only
case-insensitive filter here that does not require naming a column.
limit and offset do not behave as the AppConfig keys suggest
query.ParseQueryParameters normalises both before the charts handler sees them, which leaves
the module's own bounds out of reach at their seeded values:
- It pre-seeds
limitwithconstants.DefaultLimit= 10 unconditionally, so the handler's "parameter absent" branch never runs andcharts.defaults.limitis never read. The default is 10, not 100. parseLimitOffsetclamps anything aboveconstants.MaxLimit= 100 down to 100, so the handler's comparison againstcharts.generation.max_data_pointscannot be true at the seeded default of 1000.?limit=5000answers 200 with at most 100 points; it is not rejected. The key itself is live —extractLimitcompares the already-clamped value against it andgenerateGenericCharttruncates the result set to it — so setting it below 100 makes it bind again: with 50,?limit=100answers 400charts_m.invalid_field.
By value: non-numeric → 400 common.invalid_input; -1 → 400 charts_m.invalid_field
(the only value that reaches the module's check); 0 and other negatives → 10; 1..100 as
given; above 100 → 100. A negative offset becomes 0.
Supported Record Types
Currency Pairs
- Table:
currencies.currency_pairs - X-Field Options:
date,base_currency,target_currency,type,source,rate—x_field,y_fieldandgroup_byare all validated against the same field set - Y-Field Options:
rate— the only numeric column.y_fieldis validated against the full field set below, so a text column is accepted and then breaks JSON encoding ofy; do not use one. - Group By Options:
date,base_currency,target_currency,type,source,rate— same set asx_field - Available Fields:
date,base_currency,target_currency,type,source,rate - Default source: When
search.sourceis omitted, the API injectsfx.convert.default_sourcefrom AppConfig (defaultOER). An explicitsearch.source=SNBwins — but only in that form.applyCurrencyPairsSearchDefaultsinspects the bare map keysourcealone, and an operator suffix produces a different key (search.source.in→source.in), so the default is injected in addition to what you asked for.?search.source.ne=OERagainst a default ofOERcompiles tosource IS DISTINCT FROM 'OER' AND source = 'OER', which can never match: you get 404charts_m.no_data_found. - Series cap: When
group_byproduces more series thancharts.generation.max_series(AppConfig, default 10), the response is truncated to that many series and still returns 200. Nothing in the body marks the truncation, and the surviving set is chosen by Go map iteration order, which is randomised — so grouping by a high-cardinality field silently returns an arbitrary subset that can differ between identical requests.
Gap Filling Strategies
The fill_gaps parameter allows you to fill missing time points in your chart data with null values, ensuring a complete timeline for better visualization.
Available Strategies
| Strategy | Description | Use Case |
|---|---|---|
no | No gap filling (default) | When you want only actual data points |
daily | Fill gaps with daily intervals | For daily time series data |
monthly | Fill gaps with monthly intervals | For monthly aggregated data |
How It Works
-
Date Range Detection: The system prefers
search.date.gteandsearch.date.lte. When either is missing or unparseable it falls back to the min and max of the rows actually returned — which, with the effective default limit of 10, is not the window you asked about. If no range can be resolved at all, the series is returned untouched.Gap filling is also applied only when
x_field=date.?x_field=base_currency&fill_gaps=dailyis accepted and silently does nothing. -
Gap Identification: Compares existing data points against the complete date range
-
Null Value Insertion: Inserts data points with
y: nullfor missing dates -
Complete Timeline: Returns a consistent timeline regardless of data availability
Example Scenarios
Daily Gap Filling: When you have data for July 1st, 3rd, and 5th, but want to show all 31 days of July:
fill_gaps=dailywill create 31 data points- Days 2, 4, 6-30 will have
y: null - Days 1, 3, 5 will have actual values
Monthly Gap Filling: When you have data for January, March, and December, but want to show all 12 months:
fill_gaps=monthlywill create 12 data points- February, April-November will have
y: null - January, March, December will have actual values
Chart Features
Multi-Series Support
When using group_by, the API returns multiple series, each with its own colour — but not
derived from color. generateColor discards the base colour and returns
hsl(index*360/total, 70%, 50%), so two grouped series are always hsl(0, 70%, 50%) and
hsl(180, 70%, 50%). The color parameter reaches only the single-series path and has no
effect on a grouped response.
Dynamic Color Generation
Colors are automatically generated for multiple series using HSL color space variations.
Pagination Support
limit and offset are passed to the query, but there is no ordering: the storage layer
issues Select(...).Limit(...).Offset(...) with no ORDER BY, and the module never reads the
sort parameter that the query parser accepts. With the effective default limit of 10,
x_field=date returns an arbitrary ten rows in an arbitrary order — the line is not
chronological and paging is not reproducible.
Usage Examples
Basic Exchange Rate Chart
curl -X GET "{{base_url}}/v1/charts/line?rec_type=currency_pairs&x_field=date&y_field=rate&limit=50"Multi-Currency Chart
curl -X GET "{{base_url}}/v1/charts/line?rec_type=currency_pairs&x_field=date&y_field=rate&group_by=base_currency&search.date.gte=2024-01-01T00:00:00Z&search.date.lte=2024-12-31T23:59:59Z"Filtered Chart with Search Parameters
curl -X GET "{{base_url}}/v1/charts/line?rec_type=currency_pairs&x_field=date&y_field=rate&search.type=MID&search.source=SNB&limit=50"Comprehensive Currency Pair Analysis
curl -X GET "{{base_url}}/v1/charts/line?rec_type=currency_pairs&x_field=date&y_field=rate&search.base_currency=USD&search.target_currency=EUR&search.date.gte=2025-07-01&search.date.lte=2025-07-31&search.type=SELL&search.source=SNB&color=hsl(58, 70%, 50%)"This example demonstrates:
- Specific currency pair: USD to EUR exchange rates
- Date range filtering: July 2025 data
- Transaction type: SELL transactions only
- Data source: SNB (Swiss National Bank)
- Custom styling: Specific HSL color for the chart
Gap Filling Example
curl -X GET "{{base_url}}/v1/charts/line?rec_type=currency_pairs&x_field=date&y_field=rate&search.date.gte=2025-07-01&search.date.lte=2025-07-31&fill_gaps=daily"This example demonstrates:
- Date range filtering: July 2025 data (31 days)
- Daily gap filling: Missing dates will be filled with null values
- Complete timeline: Returns 31 data points, one for each day in the range
Monthly Gap Filling Example
curl -X GET "{{base_url}}/v1/charts/line?rec_type=currency_pairs&x_field=date&y_field=rate&search.date.gte=2025-01-01&search.date.lte=2025-12-31&fill_gaps=monthly"This example demonstrates:
- Date range filtering: Full year 2025 data
- Monthly gap filling: Missing months will be filled with null values
- Complete timeline: Returns 12 data points, one for each month
Response:
[
{
"id": "series",
"color": "hsl(58, 70%, 50%)",
"data": [
{
"x": "2024-01-01",
"y": 1.0923
},
{
"x": "2024-01-02",
"y": null
},
{
"x": "2024-01-03",
"y": 1.0931
},
{
"x": "2024-01-04",
"y": null
},
{
"x": "2024-01-05",
"y": 1.0940
}
]
}
]Response Format
The API returns an array of chart series in Nivo-compatible format. When no group_by is specified, a single series with id: "series" is returned.
Single Series Response (No Group By)
[
{
"id": "series",
"color": "hsl(58, 70%, 50%)",
"data": [
{
"x": "2025-07-30",
"y": 1.150009
},
{
"x": "2025-07-31",
"y": 1.138944
}
]
}
]Multi-Series Response (With Group By)
[
{
"id": "EUR",
"color": "hsl(0, 70%, 50%)",
"data": [
{
"x": "2024-01-01",
"y": 1.0923
},
{
"x": "2024-01-02",
"y": 1.0931
}
]
},
{
"id": "USD",
"color": "hsl(180, 70%, 50%)",
"data": [
{
"x": "2024-01-01",
"y": 0.9155
},
{
"x": "2024-01-02",
"y": 0.9148
}
]
}
]Response Fields
id: Series identifier (group value when usinggroup_by, otherwise "series")color: HSL color value for the seriesdata: Array of data pointsx: X-axis value (formatted as string, dates are simplified to YYYY-MM-DD format)y: Y-axis value, a bare JSON number — ornullwhen the row carries no value fory_field: on a point inserted byfill_gaps, and also when the column is empty or absent
Error Handling
| Status Code | Description | Common Causes |
|---|---|---|
| 400 | Bad Request | Missing rec_type/x_field/y_field; a rec_type other than currency_pairs; an x_field, y_field or group_by outside the field set; a non-numeric limit/offset (common.invalid_input); limit=-1; non-HSL color; fill_gaps outside no|daily|monthly; a search key whose field name is not a plain identifier, e.g. search.foo-bar (query_m.invalid_field). Not an invalid date value, not a limit above the AppConfig maximum, and not a well-formed but unknown search. — the first two are described above and the third is a 500 |
| 401 | Unauthorized | Missing or invalid token, from auth.Middleware. Also from auth.RateLimitMiddleware when app-config rate_limits.rate_limits_switcher is on: it runs first, and rbac.CanCallAPI → findMatchingEndpoint answers permission denied with WithCode(401) when no cached endpoint grant matches this route, so an authenticated-but-ungranted caller sees 401, not the 403 below |
| 403 | Forbidden | Not from this module — checkPermissions returns early for reference-data tables, and currency_pairs is one, so RBAC is never consulted for the only supported rec_type. The status also arrives from the auth middleware before the handler runs, with a different code: common.rbac_no_rec_access when the endpoint grant is denied — only reachable with rate_limits.rate_limits_switcher off, since with it on the rate limiter answers that caller 401 first — or license_m.license_invalid / license_m.license_expired / license_m.module_not_licensed from the licence check |
| 404 | Not Found | No data available for the specified criteria |
| 500 | Internal Server Error | Database connection issues; query execution errors; a malformed date value in search.date.*, which reaches Postgres unvalidated and comes back as charts_m.chart_generation_failed; and a well-formed but unrecognised search. — the shared search parser raises query_m.invalid_search_field without a status, so it surfaces as 500 rather than 400 (a field name that is not a plain identifier fails an earlier check and is a 400 instead) |
| 429 | Too Many Requests | auth.RateLimitMiddleware, mounted on the root router ahead of authentication and only when app-config rate_limits.rate_limits_switcher is true. An unauthenticated caller can therefore get a 429 without ever seeing the 401. code is one of rate_limits_m.exceeded, rate_limits_m.global_exceeded, rate_limits_m.failed_to_increment_ip_limit, or the literal strings Global rate limit exceeded / rate limit exceeded |
| 503 | Service Unavailable | Two sources with two different body shapes. During graceful shutdown health.LifecycleMiddleware writes a bare {overall_status, message, timestamp} map with no code — not the standard envelope. When the auth cache is unreachable ensureCacheAvailable writes the envelope with code: "auth_m.internal_server_error" (note the auth_m prefix, not common.), but only with the rate limiter off — see below. Branch on the shape, not on the status |
license_m.license_key_missing and license_m.license_service_unavailable are matched by the
middleware but cannot reach a client: the first needs licenseService == nil, which a running
process cannot be in — rbac.Init routes an InitLicenseService failure through
logger.Fatalf, so a bad licence key stops the process at startup — and the second is never
written into the licence-error context at all.
Best Practices
- Use appropriate date ranges: Limit date ranges to avoid performance issues
- Set reasonable limits: Use
limitto control data volume. The ceiling is 100 (constants.MaxLimit), applied by the shared query parser before this module sees the value, socharts.generation.max_data_pointscannot raise it above 100 — only lower it, if set below 100. Ask for more and you silently get 100 - Group by meaningful fields: Use
group_byto create meaningful chart series - Cache results: Consider caching chart data for frequently accessed queries
- Monitor performance: Large datasets may require pagination or filtering
- Use specific filters: Combine multiple search parameters for precise data selection
- Validate date formats: Use RFC3339 format for date filters
- Consider data sources: Different sources may have varying data quality and availability
Verify 2FA code and complete authentication. This is the only point where MFA-enabled logins receive authenticated session artifacts. When auth.2fa_challenge=true, send the challenge token from /v1/authenticate in X-MFA-Challenge. Failed codes are counted: a wrong code reports the attempts left, and once they are exhausted a cooling-off period starts and further attempts are refused until it elapses. Branch on `code` (`otp_m.otp_invalid`, `otp_m.max_attempts_reached`, `otp_m.cooling_period_active`) rather than on the status: the TOTP branch reports a wrong code as 400 (the web middleware treats 401 as a token-refresh signal) while the email/phone branch reports it as 401, and both branches keep 429 for the two rate-limited states so the client can read the time left. The cooling-off gate fails closed: a read the server cannot complete refuses the attempt with 500 `otp_m.cache_error` on both branches rather than letting it through.
Generate line chart data in Nivo format. Only rec_type=currency_pairs is implemented; the chart type is fixed to line by the handler and is not a request parameter. Series cap: when the grouping produces more series than charts.generation.max_series (AppConfig, default 10), the response is TRUNCATED to that many series and still answers 200. There is no flag in the body saying truncation happened, and no error — a client that groups by a high-cardinality field silently sees only an arbitrary N of them (the truncation takes Go map iteration order, which is randomised — the set you get is not the first N by any ordering and can differ between identical requests). Search filters: a value of null on .eq or .ne is a null test (IS NULL / IS NOT NULL). On ANY OTHER operator, null is accepted and the whole filter is then SILENTLY DROPPED by ParseSearchQuery — ?search.rate.gt=null does not narrow the result set and does not error. Rate source: search.source is honoured when given; when omitted, the AppConfig value fx.convert.default_source is injected before the query runs (the same default FX convert uses). If that key is empty, no source filter is applied at all. Data points: y is null on points produced by fill_gaps, so a series may legitimately contain nulls between real values. Ordering: THERE IS NONE. The storage layer issues Select(...).Limit(...).Offset(...) with no ORDER BY, and the charts handler never reads the sort parameter — query.ParseQueryParameters accepts it, nothing consumes it. With the effective default limit of 10, x_field=date returns an arbitrary ten rows in an arbitrary order, so the "line" is not chronological and paging with offset is not stable between requests. Permissions: the module's own record-type check cannot refuse this route. checkPermissions returns early for reference-data tables, and referenceDataTables contains currency_pairs — the only accepted rec_type — so rbac.RecPermission is never called and neither the common.forbidden path nor the unregistered-record-type 403 can fire. Every 403 a caller sees here comes from the auth or licence middleware.