はじめに
週末に、React Native / Expoで開発しているアプリの暗号資産比較機能を修正しました。
今回の問題は、価格APIの取得に失敗すると、本来その取引ペアを扱っている取引所まで画面から消えてしまうことでした。
たとえば、ある取引所が BTC/JPY を実際に扱っていても、価格APIが一時的に失敗すると、UI上では「その取引所は存在しない」ように見えてしまいます。
これはデータ取得の問題というより、設計の問題でした。
問題のあった設計
以前のロジックは、概念的には次のようになっていました。
const price = await fetchTicker(exchange, pair);
if (!price) {
return null;
}
return {
exchange,
pair,
price,
};
この実装では、価格が取れなければ行全体を削除します。
しかし実際には、以下の2つは別の情報です。
その取引所が取引ペアをサポートしているか
現在価格を取得できるか
価格取得に失敗しただけで、取引ペアの存在まで否定してはいけません。
設計変更
判定を次の2段階に分離しました。
const supported = await checkPairSupport(exchange, pair);
if (!supported) {
return null;
}
const price = await fetchTicker(exchange, pair);
return {
exchange,
pair,
price: price ?? null,
};
これにより、価格APIが失敗しても取引所は表示できます。
UI側では、価格がない場合に別の表示へ切り替えます。
function renderPrice(row) {
if (row.price != null) {
return formatPrice(row.price);
}
return "価格取得中";
}
重要なのは、APIの失敗を「取引ペアなし」と解釈しないことです。
取引ペア判定の優先順位
取引ペアの存在判定は、次の順序にしました。
1. 取引所の公式マーケット一覧API
2. 外部マーケットデータ
3. 検証済みの静的ペア一覧
概念的には以下のような実装です。
async function resolveSupportedPairs(exchange) {
const officialPairs = await fetchOfficialPairs(exchange);
if (officialPairs.length > 0) {
return officialPairs;
}
const externalPairs = await fetchExternalPairs(exchange);
if (externalPairs.length > 0) {
return externalPairs;
}
return VERIFIED_PAIR_MAP[exchange] ?? [];
}
公式APIが一時的に利用できなくても、以前に確認済みの取引ペアまで消さないようにしました。
価格取得は表示条件にしない
今回の修正で最も大きかったのは、価格データをカード生成条件から外したことです。
以前は次のような処理がありました。
if (!rate || !cryptoPrice) {
return [];
}
この条件では、FXレートか暗号資産価格のどちらかが取得できないだけで、結果がすべて消えます。
修正後は、カード生成と価格計算を分離しました。
const rows = buildExchangeRows(supportedPairs);
const rowsWithPrice = rows.map((row) => {
const estimatedPrice = calculateEstimatedPrice(row, marketData);
return {
...row,
estimatedPrice,
};
});
estimatedPrice が計算できなくても、取引所情報自体は残ります。
APIフォールバック
API取得は以下のように段階的に処理しています。
async function fetchWithFallback(exchange, pair) {
try {
return await fetchOfficialTicker(exchange, pair);
} catch (officialError) {
console.warn("official api failed", officialError);
}
try {
return await fetchExternalTicker(exchange, pair);
} catch (externalError) {
console.warn("external api failed", externalError);
}
return null;
}
ただし、フォールバックは価格補完のためだけに使用します。
取引ペアの存在判定とは分離しています。
空キャッシュの問題
もう1つの原因は、以前のバージョンで保存された空キャッシュでした。
次のようなデータがキャッシュされていました。
{
pairs: {},
checkedAt: 1720000000000
}
新しいロジックを追加しても、この空データを正常なキャッシュとして再利用していたため、フォールバック処理が実行されませんでした。
修正前:
if (cached) {
return cached.pairs;
}
修正後:
if (cached && Object.keys(cached.pairs ?? {}).length > 0) {
return cached.pairs;
}
空のキャッシュは有効な結果として扱わないようにしました。
さらにキャッシュキーにバージョンを追加しました。
const CACHE_VERSION = "spot-v2";
const cacheKey = `${CACHE_VERSION}:${exchange}:${pair}`;
これにより、古い構造のキャッシュが新しい実装に影響しにくくなります。
取引ペアの正規化
取引所ごとにシンボル形式も異なります。
BTC_JPY
JPY-BTC
BTC/JPY
BTCJPY
このまま比較すると、同じペアを別物として扱ってしまいます。
そこで内部ではすべて BASE/QUOTE に正規化しました。
function normalizePair(base, quote) {
return `${base.toUpperCase()}/${quote.toUpperCase()}`;
}
取引所APIから取得した値も、パーサーを通して統一します。
function parseExchangePair(exchange, symbol) {
switch (exchange) {
case "bithumb":
return parseBithumbPair(symbol);
case "upbit":
return parseUpbitPair(symbol);
default:
return parseGenericPair(symbol);
}
}
重複除去キーも表示用文字列ではなく、正規化後の値を使います。
const key = `${exchangeId}|${base}|${quote}`;
UI構造
暗号資産機能は次の4つに分けました。
Spot
Conversion
P2P
Card
ここで重要なのは、表示上のタブと内部データの責務を分けることでした。
const CRYPTO_TABS = [
{ key: "spot", label: "Spot" },
{ key: "convert", label: "Conversion" },
{ key: "p2p", label: "P2P" },
{ key: "card", label: "Card" },
];
Spotでは実際の取引ペアを扱い、Conversionでは簡易交換、P2Pではユーザー間取引、Cardではカード購入経路を扱います。
1つの配列ですべてを処理すると条件分岐が増えすぎるため、モード別に取得関数を分けました。
function getRowsByMode(mode, params) {
switch (mode) {
case "spot":
return getSpotRows(params);
case "convert":
return getConversionRows(params);
case "p2p":
return getP2PRows(params);
case "card":
return getCardRows(params);
default:
return [];
}
}
リンクの分離
取引ページURLと紹介URLも分離しました。
{
tradeUrl: "https://exchange.example.com/trade/BTC_JPY",
referralUrl: "https://exchange.example.com/register?ref=xxx"
}
以前は1つのURLフィールドを使い回していたため、取引ボタンが登録ページへ移動するケースがありました。
UI側でも目的別に分けます。
<Button title="取引する" onPress={() => openUrl(row.tradeUrl)} />
{row.referralUrl && (
<Button title="口座を作成" onPress={() => openUrl(row.referralUrl)} />
)}
同じ事業者へのリンクでも、用途が違う場合は別フィールドにするべきでした。
テストした項目
修正後は以下を確認しました。
API失敗時も取引所カードが残るか
空キャッシュが再利用されないか
同じ取引ペアが重複しないか
取引所ごとのシンボル形式を正規化できるか
価格がない場合でもUIが壊れないか
取引URLと紹介URLが混ざらないか
Spot / Conversion / P2P / Card の表示が分離されているか
Android内部テスト環境で起動するか
学んだこと
今回の問題で一番重要だったのは、APIエラー処理ではなくデータモデルでした。
次の2つを同じ条件で扱うと、UIに誤った情報が表示されます。
価格が取得できない
取引ペアが存在しない
この2つは別の状態です。
同様に、以下も分ける必要があります。
取引可能か
現在価格を取得できるか
推定価格を計算できるか
取引ページへ移動できるか
外部APIを複数使うアプリでは、取得失敗をデータ不存在として扱わない設計が重要だと改めて感じました。