「USD/JPY の 5 年チャートを Web ページに 1 枚埋めたい、サーバは立てたくない、API キー登録もしたくない」が一発で叶う構成: Frankfurter API (ECB の公開為替を CORS 対応で配る無料ラッパ) + 自前 SVG ラインチャート。auth 不要、CORS 通る、JSON 直返し、ブラウザだけで完結する。チャート描画は ~120 行、純粋ロジック ~180 行 + テスト 20 件。
🌐 デモ: https://sen.ltd/portfolio/forex-history/
📦 GitHub: https://github.com/sen-ltd/forex-history
なぜ Frankfurter を選ぶか
ブラウザから直接叩ける為替 API は、実は思ったより少ない:
-
ECB 公式 (
www.ecb.europa.eu): XML/CSV、CORS 無し → サーバ proxy 必須 - Yahoo Finance: 非公式 + CORS 不可 + たまに止まる
- Alpha Vantage: API キー必須 + 無料枠が 1 日 25 リクエスト
- OpenExchangeRates: API キー必須 + 無料枠 1000/月
-
Frankfurter (
api.frankfurter.dev): CORS 対応 + auth 不要 + JSON 直返し + ECB データそのもの
Frankfurter は ECB が毎営業日に発表する reference rates をそのまま再配信する個人運営の無料サービス。営業日午後 3 時 CET 過ぎ に更新される。商用利用も明示的に OK。
URL シンプル:
https://api.frankfurter.dev/v1/2021-01-01..2026-01-01?base=USD&symbols=JPY,EUR,GBP
レスポンスを per-symbol 時系列に pivot する
Frankfurter のレスポンスは 日付キーで通貨を持つ 形:
{
"base": "USD",
"rates": {
"2021-01-04": { "JPY": 103.55, "EUR": 0.815, "GBP": 0.731 },
"2021-01-05": { "JPY": 103.20, "EUR": 0.812, "GBP": 0.730 }
}
}
可視化側は 通貨ごとの時系列 が欲しいので pivot する:
export function parseFrankfurterResponse(json) {
if (!json || !json.rates) return null;
const dates = Object.keys(json.rates).sort();
const symbols = new Set();
for (const d of dates) for (const s of Object.keys(json.rates[d])) symbols.add(s);
const series = {};
for (const sym of symbols) series[sym] = [];
for (const date of dates) {
for (const sym of symbols) {
const v = json.rates[date]?.[sym];
if (typeof v === "number" && Number.isFinite(v)) {
series[sym].push({ date, value: v });
}
}
}
return { base: json.base, symbols: [...symbols].sort(), series, start: json.start_date, end: json.end_date };
}
ポイント:
-
Object.keys(rates).sort()で日付昇順に正規化。Frankfurter は日付昇順で返してくれるが、JS object のキー順は仕様上保証されない ので明示的に sort -
Number.isFinite(v)で null / NaN を drop。ECB は祝日のデータを 存在しない 扱いするが、稀にnullで来ることがある — フィルタしないと line chart が y=0 に飛ぶ - symbols は全日付を見て収集: 一部の日だけしか出ない通貨にも対応 (実用上はほぼ起きない)
テスト:
test("parseFrankfurterResponse drops missing values inside a row", () => {
const json = {
base: "USD",
rates: {
"2021-01-04": { JPY: 103.0 },
"2021-01-05": { JPY: null, EUR: 0.815 },
},
};
const out = parseFrankfurterResponse(json);
assert.equal(out.series.JPY.length, 1); // null dropped
assert.equal(out.series.EUR.length, 1);
});
y 軸の "Nice Numbers" アルゴリズム
ライン chart の y 軸は 読みやすい数字 で目盛を切る。100, 200, 300 のような切りの良い値で。これは Heckbert (1990) の "Nice Numbers for Graph Labels" アルゴリズム:
export function niceTicks(min, max, targetCount = 5) {
if (min === max) return [min];
if (min > max) [min, max] = [max, min];
const step = niceStep((max - min) / targetCount);
const first = Math.ceil(min / step) * step;
const out = [];
for (let v = first; v <= max + step * 1e-9; v += step) {
out.push(Number(v.toFixed(10)));
}
return out;
}
function niceStep(rough) {
const exp = Math.floor(Math.log10(rough));
const f = rough / Math.pow(10, exp);
let nice;
if (f < 1.5) nice = 1;
else if (f < 3) nice = 2;
else if (f < 7) nice = 5;
else nice = 10;
return nice * Math.pow(10, exp);
}
要点:
- ステップ候補は 1, 2, 5 × 10^n のいずれか
-
Math.log10で桁数を取り、Math.pow(10, exp)でその桁の最小単位 (0.01,1,100等) を作る - 浮動小数点 epsilon (
step * 1e-9) を末尾比較に入れて、最後の tick が落ちるのを防ぐ -
Number(v.toFixed(10))で0.30000000000000004のような浮動小数点ノイズを除去
D3 が内部で使っているのと同じ alg。テスト 2 つで主要パスを pin:
test("niceTicks chooses 1/2/5 × 10^n step sizes", () => {
assert.deepEqual(niceTicks(0, 10, 5), [0, 2, 4, 6, 8, 10]);
assert.deepEqual(niceTicks(0, 100, 5), [0, 20, 40, 60, 80, 100]);
});
test("niceTicks handles fractional ranges", () => {
const ticks = niceTicks(0.1, 0.5, 5);
assert.ok(ticks.every((t) => !String(t).includes("0000000")));
});
SVG <path d="..."> を 1 系列に組み立てる
SVG ライン chart の本体は <path d="M x0 y0 L x1 y1 L x2 y2 ..."> 1 行。これを生成する:
export function buildLinePath(series, xFn, yFn) {
if (!series.length) return "";
if (series.length === 1) {
const { x, y } = xyOf(series[0], xFn, yFn);
return `M ${fmt(x)} ${fmt(y)} l 0.01 0`; // 単点でも何か描く
}
const first = xyOf(series[0], xFn, yFn);
let d = `M ${fmt(first.x)} ${fmt(first.y)}`;
for (let i = 1; i < series.length; i++) {
const { x, y } = xyOf(series[i], xFn, yFn);
d += ` L ${fmt(x)} ${fmt(y)}`;
}
return d;
}
3 つの工夫:
-
xFnとyFnを引数化 して、関数を unit-free に保つ。呼び出し側でlinearScaleを bind してから渡す -
0 点 / 1 点 / N 点 で分岐。0 点は空文字、1 点は near-zero stub (
l 0.01 0) —l小文字は相対指定で原点に小さい線を引いて、最低 1 ピクセルは表示させる -
fmt(n)で 3 桁丸め。sub-pixel 精度より細かくは目に見えないので、出力サイズを圧縮できる。Number(n.toFixed(3)).toString()で末尾の不要な0も切る
test("buildLinePath rounds coords to 3 decimal places for compact output", () => {
const xFn = () => 1.123456789;
const yFn = () => 2.987654321;
const d = buildLinePath([{date:"x",value:1},{date:"y",value:2}], xFn, yFn);
assert.equal(d, "M 1.123 2.988 L 1.123 2.988");
});
SVG y 軸の "flip" は linearScale で吸収
SVG の y 座標は 下方向に増える (DOM 流)。チャートの値は 上方向に増える ようにしたい (グラフ流)。これは scaler に 逆順の range を渡すだけで吸収できる:
// 上端 y=PAD_TOP に max、下端 y=PAD_TOP+PLOT_H に min を割り当てる
const yFn = (v) => linearScale(v, yMin, yMax, PAD_TOP + PLOT_H, PAD_TOP);
linearScale の実装側で reverse 専用ロジックを書かなくても、rangeMin < rangeMax を要求しない設計 にしておくと自然に動く:
export function linearScale(value, domainMin, domainMax, rangeMin, rangeMax) {
if (domainMax === domainMin) return (rangeMin + rangeMax) / 2;
const t = (value - domainMin) / (domainMax - domainMin);
return rangeMin + t * (rangeMax - rangeMin);
}
test("linearScale handles inverted ranges (for y-axis flipping)", () => {
assert.equal(linearScale(0, 0, 10, 100, 0), 100); // value at domain min → range "start" 100
assert.equal(linearScale(10, 0, 10, 100, 0), 0); // value at domain max → range "end" 0
});
既知の落とし穴
多通貨の スケール問題: USD/JPY (~100-160) と USD/EUR (~0.8) を同じ y 軸に重ね描きすると、JPY が支配的になって他の系列が 底に張り付いた平坦な線 に見える (上のスクショがまさにそれ)。
解決策は 2 つ:
- indexed-to-100: 各系列を「期間始 = 100」に正規化して 変動率の重ね描き にする。同じスケールになって直接比較できる
- 複数 y 軸: 系列ごとに左右 / 内側に別の y 軸を描く。Bloomberg 端末のスタイル。ただし軸の意味がパッと分かりにくくなる
本ツールは「Frankfurter から取って素直に描く」を優先したので両方未実装。indexed-to-100 トグルを足すなら linearScale 一段挟むだけで実現できる。
まとめ
- ブラウザだけ で為替履歴を取りたいなら Frankfurter (CORS + auth 不要 + JSON 直返し)。商用 OK
- レスポンスは 日付 → 通貨 の object で来るので、可視化前に per-symbol 時系列に pivot
- y 軸の "Nice Numbers" は 1/2/5 × 10^n の選び方で人間に読みやすくなる
- SVG
<path>は xFn / yFn を引数化 + 3 桁丸め で 1 行 N 点 N 倍に大きくならない - y 軸 flip は
linearScaleに 逆順 range を渡すだけで吸収
ソース: https://github.com/sen-ltd/forex-history — MIT、合計 ~350 行 (JS)、20 ユニットテスト、ビルド不要、依存ゼロ。
🛠 本記事は SEN 合同会社 が公開している小さな開発者ツール群の 1 つ。他は portfolio 一覧 から。
