Plotting trades on a price chart looks like the easy part of a trading journal. The user imports a CSV of fills, the app draws a marker per fill on a candlestick chart, and the user sees where each entry and exit landed relative to the price action. The hard part is the bar under the marker. A fill at 14:37:12 UTC needs the 1-minute bar that opened at 14:37, for that instrument, on that date. If the app only has a 15-minute or daily bar, the marker gets pinned next to a price the trade never touched.
Why the marker needs the 1-minute bar at the fill time#
Charting libraries place a marker by time and price. Both come from the fill record, so a lone marker is always correct. The problem is what gets drawn around it. If the chart is built from 15-minute bars, the candle that contains the fill spans 14:30 to 14:45. Its open and close can sit well away from the fill price, and a stop that was hit inside that quarter hour is invisible because the bar shows only a range. On a daily chart it's worse. The candle covers the whole session, and a scalp that entered and exited within four minutes collapses into two markers stacked inside one candle, often outside its body.
Users notice this immediately. A journal that shows an entry at 187.20 on a candle whose range is 186.10 to 186.90 looks wrong, and the user stops trusting the P&L the app computes from the same data. More precise markers won't fix it. What fixes it is a 1-minute bar series that actually contains the fill minute, so the chart around each trade is the chart the trader was looking at.
There is a second use for that exact bar. Tax and reporting tools sometimes need a reference price at a timestamp when there was no fill: a crypto transfer between wallets, a corporate action, a token grant. The same lookup that places a marker answers the question of what an instrument traded at during a given minute.
Where free and bundled price feeds fall short#
Most journal apps start on whatever price source is at hand: the free tier of a quote API, the data bundled with a charting widget, or the broker's own history export. These work in the demo. They break on the backfill.
Intraday depth is the first gap. Free feeds commonly offer minute bars for recent weeks only, and daily bars beyond that. A user importing three years of trades gets accurate charts for the last month and daily approximations for everything older. Coverage thins further on less traded instruments, where a minute series may simply be missing for the date in question.
Forex and crypto add their own holes. A feed built around US stocks often carries FX as daily closes only, which is useless for a 1-minute marker on a EURUSD scalp. Crypto history taken from a single venue has gaps wherever that venue had an outage, delisted the pair, or didn't list it yet on the fill date.
Rate limits finish the job. A journal with 4,000 saved trades that fetches one bar per trade makes 4,000 calls. At the Free tier ceiling of 60 requests per minute, that backfill takes over an hour while the user watches a spinner. Multiply by every new signup and the import screen becomes the slowest and least reliable page in the app.
The usual response is to add sources: one for stocks, one for forex, one for crypto, another for gold and oil. Now the app has four JSON shapes. One returns epoch seconds, one ISO strings, one exchange-local time with no offset. One labels a bar by its open time, another by its close. Volume is a float in one, a string in another, absent in the FX feed. Every one of these differences becomes a branch in the import code, and every branch is a place where a marker lands one bar off.
What to design for instead#
Four properties make the lookup reliable across asset classes.
Deep 1-minute history behind one credential. The SiftingIO bars endpoints under /v1/hist return the same interval ladder for US stocks, forex, crypto, and commodities: 1m, 5m, 15m, 30m, 1h, 1d, 1w, and 1mo. Every plan includes the full available history, including Free. An app that imports years of trades should check coverage for its oldest fills and choose a plan with enough request capacity for the backfill.
One response shape. Every bar carries t, o, h, l, c, and v regardless of class, and t is the bar's open time in Unix epoch milliseconds, UTC. The import code has one parser and one time convention. The only field that changes meaning is v: share volume for stocks, base-asset volume for crypto, aggregated traded volume for forex.
A fetch pattern that respects the rate limit. Fetch by symbol and UTC day rather than one call per trade. One request for AAPL on 2026-08-14 returns the minute bars covering every AAPL fill that day. A user with 4,000 trades spread over 60 symbol-days needs roughly 60 requests, plus extra pages wherever a day runs past the page cap. The example below requests 1,000 bars per page; see the per-class bars reference for current supported limits. A full regular US stock session has at most 390 one-minute bars. A 24-hour UTC date has 1,440 minute slots, but not every slot necessarily has a bar. With limit=1000, a requested day containing more than 1,000 returned bars needs another page. Keep following next_cursor until it is absent, regardless of the asset class.
Correct session boundaries. A missing bar at the fill minute can reflect a timezone mismatch, an uncovered session, a closed market, no reported trades, or a data gap. Bars alone do not identify the cause. Show a visible warning in the import UI and check coverage before snapping a marker to the nearest bar.
Here is the lookup in Python. The bars endpoints require gzip, and the requests library decompresses transparently once the header is set.
import os
import requests
KEY = os.environ["SIFTING_KEY"]
BASE = "https://api.sifting.io/v1/hist"
HEADERS = {"X-API-Key": KEY, "Accept-Encoding": "gzip"}
def minute_bars(asset_class, symbol, day):
"""Every 1-minute bar for one symbol on one UTC date, keyed by open time (epoch ms).
Full RFC3339 bounds keep the window in UTC for every asset class."""
bars = {}
params = {
"interval": "1m",
"limit": 1000, # page size used by this example; follow next_cursor for remaining bars
"start": f"{day}T00:00:00Z",
"end": f"{day}T23:59:59Z",
}
while True:
r = requests.get(f"{BASE}/{asset_class}/{symbol}/bars", headers=HEADERS, params=params)
r.raise_for_status()
body = r.json()
for bar in body["data"]:
bars[bar["t"]] = bar
cursor = body.get("meta", {}).get("next_cursor")
if not cursor:
return bars
params = {"cursor": cursor}
def bar_at_fill(bars, fill_ms):
"""Floor the fill timestamp to the minute and look it up. None means no bar was returned for that minute."""
return bars.get(fill_ms - (fill_ms % 60_000))
day_bars = minute_bars("forex", "EURUSD", "2026-08-14")
fill_ms = 1786718232000 # 2026-08-14T14:37:12Z
bar = bar_at_fill(day_bars, fill_ms)
if bar is None:
print("no bar at fill minute: check timezone, session, and data availability")
else:
print(bar["t"], bar["o"], bar["h"], bar["l"], bar["c"])
The same function works for minute_bars("stocks", "AAPL", ...) and minute_bars("crypto", "BTCUSD", ...) without a branch, which is the point. The marker code downstream reads o, h, l, and c and never asks which class the bar came from.
Common pitfalls#
Date-only start values mean different things per class. On the stocks endpoint a plain YYYY-MM-DD start is read in US Eastern time; on forex, crypto, and commodities it is midnight UTC. A journal that builds its window from a date string will pull a stock day that starts hours off from its FX day, and markers near the session open will land on the wrong bar. Always send full RFC3339 timestamps with a Z suffix, as the example does, so every class reads the window as UTC.
A stock fill outside regular hours has no bar. The stock series covers 09:30 to 16:00 ET on trading days only; pre-market and after-hours fills return nothing at that minute, and a half-day session emits fewer bars. Treat a miss as a warning to the user, since snapping to 09:30 would show a pre-market entry sitting on the opening candle.
The cursor is absent, not null, on the last page. The response meta omits next_cursor at the tail, so code that reads it with a hard key lookup raises KeyError on the final page and the import aborts after the last bar was already fetched. Use a .get lookup and stop on a missing or empty value. And if any request comes back as 406 gzip_required, the Accept-Encoding header was dropped, usually by an HTTP client that rebuilds headers on redirect.
A 1-minute bar series that spans every asset class, paged predictably, is the difference between a journal users trust and one they abandon on the import screen. Read the docs



