0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

価格APIの失敗で取引所が消える問題を、取引ペア判定と価格取得の分離で解決した

0
Posted at

はじめに

週末に、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を複数使うアプリでは取得失敗をデータ不存在として扱わない設計が重要だと改めて感じました
0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?