CorebanqCorebanq Developer Docs
Charts

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

ParameterTypeRequiredDescriptionExample
rec_typestring✅Record type to querycurrency_pairs
x_fieldstring✅Field to use for X-axis valuesdate, base_currency
y_fieldstring✅Field to use for Y-axis valuesrate

Data Control Parameters

ParameterTypeRequiredDescriptionExample
group_bystring❌Field to group by for multiple seriesbase_currency, type, source
limitinteger❌Maximum data points. Neither AppConfig bound binds at its seeded value — see the note below. Default 10, ceiling 100100
offsetinteger❌Offset for pagination. A negative value is silently treated as 0, not rejected. Paging is not stable — there is no ordering0
fill_gapsstring❌Gap filling strategy: "no", "daily", "monthly"daily

Chart Styling Parameters

ParameterTypeRequiredDescriptionExample
colorstring❌Base color for chart series (HSL)hsl(58, 70%, 50%)

Filter Parameters

ParameterTypeRequiredDescriptionExample
search.base_currencystring❌Filter by base currencyUSD, EUR, CHF
search.target_currencystring❌Filter by target currencyUSD, EUR, CHF
search.typestring❌Filter by typeMID, BID, ASK, SELL, BUY
search.sourcestring❌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 belowSNB, OER, EB
search.date.gtestring❌Filter by date greater than or equal to (RFC3339)2024-01-01T00:00:00Z
search.date.ltestring❌Filter by date less than or equal to (RFC3339)2024-12-31T23:59:59Z
search.date.gtstring❌Filter by date strictly greater than (RFC3339)2024-01-01T00:00:00Z
search.date.ltstring❌Filter by date strictly less than (RFC3339)2024-12-31T23:59:59Z
search.ratestring❌Filter by rate value; the comparison suffixes apply as for date1.05
search._textstring❌Reserved free-text filter: ILIKE-OR across base_currency, target_currency, type and source. Own operator set — see belowUS

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 limit with constants.DefaultLimit = 10 unconditionally, so the handler's "parameter absent" branch never runs and charts.defaults.limit is never read. The default is 10, not 100.
  • parseLimitOffset clamps anything above constants.MaxLimit = 100 down to 100, so the handler's comparison against charts.generation.max_data_points cannot be true at the seeded default of 1000. ?limit=5000 answers 200 with at most 100 points; it is not rejected. The key itself is live — extractLimit compares the already-clamped value against it and generateGenericChart truncates the result set to it — so setting it below 100 makes it bind again: with 50, ?limit=100 answers 400 charts_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_field and group_by are all validated against the same field set
  • Y-Field Options: rate — the only numeric column. y_field is validated against the full field set below, so a text column is accepted and then breaks JSON encoding of y; do not use one.
  • Group By Options: date, base_currency, target_currency, type, source, rate — same set as x_field
  • Available Fields: date, base_currency, target_currency, type, source, rate
  • Default source: When search.source is omitted, the API injects fx.convert.default_source from AppConfig (default OER). An explicit search.source=SNB wins — but only in that form. applyCurrencyPairsSearchDefaults inspects the bare map key source alone, 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=OER against a default of OER compiles to source IS DISTINCT FROM 'OER' AND source = 'OER', which can never match: you get 404 charts_m.no_data_found.
  • Series cap: When group_by produces more series than charts.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

StrategyDescriptionUse Case
noNo gap filling (default)When you want only actual data points
dailyFill gaps with daily intervalsFor daily time series data
monthlyFill gaps with monthly intervalsFor monthly aggregated data

How It Works

  1. Date Range Detection: The system prefers search.date.gte and search.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=daily is accepted and silently does nothing.

  2. Gap Identification: Compares existing data points against the complete date range

  3. Null Value Insertion: Inserts data points with y: null for missing dates

  4. 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=daily will 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=monthly will 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 using group_by, otherwise "series")
  • color: HSL color value for the series
  • data: Array of data points
    • x: X-axis value (formatted as string, dates are simplified to YYYY-MM-DD format)
    • y: Y-axis value, a bare JSON number — or null when the row carries no value for y_field: on a point inserted by fill_gaps, and also when the column is empty or absent

Error Handling

Status CodeDescriptionCommon Causes
400Bad RequestMissing 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
401UnauthorizedMissing 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
403ForbiddenNot 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
404Not FoundNo data available for the specified criteria
500Internal Server ErrorDatabase 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)
429Too Many Requestsauth.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
503Service UnavailableTwo 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

  1. Use appropriate date ranges: Limit date ranges to avoid performance issues
  2. Set reasonable limits: Use limit to control data volume. The ceiling is 100 (constants.MaxLimit), applied by the shared query parser before this module sees the value, so charts.generation.max_data_points cannot raise it above 100 — only lower it, if set below 100. Ask for more and you silently get 100
  3. Group by meaningful fields: Use group_by to create meaningful chart series
  4. Cache results: Consider caching chart data for frequently accessed queries
  5. Monitor performance: Large datasets may require pagination or filtering
  6. Use specific filters: Combine multiple search parameters for precise data selection
  7. Validate date formats: Use RFC3339 format for date filters
  8. Consider data sources: Different sources may have varying data quality and availability

POSTVerify 2FA

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.

GETGenerate line chart data

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.

On this page