본문으로 건너뛰기

업비트 오픈API로 거래소 페이지 만들어보기 - 오더북

O-h-y-o약 4 분

업비트 오픈API로 거래소 페이지 만들어보기 - 오더북

// global.d.ts
interface ISocketOrderbookResponse {
  cd: string;
  tms: number;
  tas: number;
  tbs: number;
  obu: [
    {
      ap: number;
      bp: number;
      as: number;
      bs: number;
    }
  ];
  st: string;
  ty: string;
}
const connectOrderbookSocket = () => {
  orderbookSocket = new WebSocket("wss://api.upbit.com/websocket/v1");

  orderbookSocket.onopen = (e: any) => {
    orderbookSocket.send(
      `${JSON.stringify([
        { ticket: "orderbook" },
        { type: "orderbook", codes: [upbit.selectCoin] },
        { format: "SIMPLE" },
      ])}`
    );
  };

  orderbookSocket.onmessage = async (payload: any) => {
    const r = (await new Response(
      payload.data
    ).json()) as ISocketOrderbookResponse;

    orderbookList.value = r;
  };
};
Code
// UpbitOrderbook.vue
<template>
  <div class="orderbook-wrap">
    <div ref="scrollRef" class="orderbook-area">
      <EchartsDefault
        :binding-options="bindingOptions"
        style="width: 100%; height: 1000px"
      />
      <div class="order-price-wrap">
        <ul class="ask">
          <li
            v-for="(data, i) in orderbookAsk?.reverse()"
            :key="i"
            :class="[
              data.per > 0 ? 'color-rise' : 'color-fall',
              { box: data.ap === upbit.tradeData.tp },
            ]"
          >
            <span>
              {{ data.ap.toLocaleString() }}
            </span>
            <span class="per">
              {{ data.per > 0 ? "+" : "" }}{{ data.per.toFixed(2) }}%
            </span>
          </li>
        </ul>
        <ul class="bid">
          <li
            v-for="(data, i) in orderbookBid"
            :key="i"
            :class="[
              data.per > 0 ? 'color-rise' : 'color-fall',
              { box: data.bp === upbit.tradeData.tp },
            ]"
          >
            <span>
              {{ data.bp.toLocaleString() }}
            </span>
            <span class="per">
              {{ data.per > 0 ? "+" : "" }}{{ data.per.toFixed(2) }}%
            </span>
          </li>
        </ul>
      </div>
      <div class="trade-wrap">
        <div class="info">
          <span>체결가</span>
          <span>체결량</span>
        </div>
        <ul class="trade">
          <li v-for="data in upbit.tradeList.slice(0, 20)" :key="data.sid">
            <span class="trade-price">{{ data.tp.toLocaleString() }}</span>
            <span class="trade-volume" :class="data.ab">
              {{ data.tv.toFixed(3) }}
            </span>
          </li>
        </ul>
      </div>

      <div class="coin-info-wrap">
        <ul class="trade-amount-wrap">
          <li>
            <span> 거래량 </span>
            <span>
              {{ Math.round(tickerData.atv24h)?.toLocaleString() }}
              <span style="font-size: 10px; color: #888">
                {{ upbit.selectCoin.split("-")[1] }}
              </span>
            </span>
          </li>
          <li>
            <span> 거래대금 </span>
            <div>
              <span>
                {{
                  tickerData.atp24h &&
                  Number(
                    tickerData.atp24h.toFixed().slice(0, -6)
                  ).toLocaleString()
                }}
                <span style="font-size: 10px; color: #888">백만원</span>
              </span>
              <span>(최근 24시간)</span>
            </div>
          </li>
        </ul>
        <ul class="highest-lowest-wrap">
          <li>
            <span> 52주 최고</span>
            <div>
              <span class="color-rise">
                {{ tickerData.h52wp?.toLocaleString() }}
              </span>
              <span>({{ tickerData.h52wdt }})</span>
            </div>
          </li>
          <li>
            <span> 52주 최저</span>
            <div>
              <span class="color-fall">
                {{ tickerData.l52wp?.toLocaleString() }}
              </span>
              <span> ({{ tickerData.l52wdt }}) </span>
            </div>
          </li>
        </ul>
        <ul class="today-info-wrap">
          <li>
            <span>전일종가</span>
            <span>{{ tickerData.pcp?.toLocaleString() }} </span>
          </li>
          <li>
            <span>당일고가</span>
            <div>
              <span class="color-rise">
                {{ tickerData.hp?.toLocaleString() }}
              </span>
              <em class="color-rise">
                +{{
                  (
                    (tickerData.hp - tickerData.pcp) /
                    (tickerData.pcp / 100)
                  ).toFixed(2)
                }}%
              </em>
            </div>
          </li>
          <li>
            <span>당일저가</span>
            <div>
              <span class="color-fall">
                {{ tickerData.lp?.toLocaleString() }}
              </span>
              <em class="color-fall">
                {{
                  (
                    (tickerData.lp - tickerData.pcp) /
                    (tickerData.pcp / 100)
                  ).toFixed(2)
                }}%
              </em>
            </div>
          </li>
        </ul>
      </div>
    </div>
    <nav class="nav-bottom">
      <span>{{ orderbookList.tas?.toFixed(3) }}</span>
      <button type="button">수량({{ upbit.selectCoin }})</button>
      <span>{{ orderbookList.tbs?.toFixed(3) }}</span>
    </nav>
  </div>
</template>

<script setup lang="ts">
import { ref, computed, onMounted } from "vue";
import EchartsDefault from "src/components/EchartsDefault.vue";
import { useUpbitSocketStore } from "src/stores/socket-upbit";

const upbit = useUpbitSocketStore();
const scrollRef = ref();
const orderbookList = ref<ISocketOrderbookResponse>(
  {} as ISocketOrderbookResponse
);
const orderbookAsk = computed(() => {
  return orderbookList.value.obu?.map((e) => ({
    ap: e.ap,
    as: e.as,
    per: (e.ap - tickerData.value.pcp) / (tickerData.value.pcp / 100),
  }));
});
const orderbookBid = computed(() => {
  return orderbookList.value.obu?.map((e) => ({
    bp: e.bp,
    bs: e.bs,
    per: (e.bp - tickerData.value.pcp) / (tickerData.value.pcp / 100),
  }));
});
const tickerData = computed(() => upbit.tickerData);

const bindingOptions = ref({
  xAxis: [
    {
      type: "value",
      inverse: true,
      show: false,
    },
    {
      gridIndex: 1,
      type: "value",
      inverse: false,
      show: false,
    },
  ],
  yAxis: [
    {
      type: "category",
      position: "right",
      show: false,
    },
    {
      gridIndex: 1,
      type: "category",
      position: "left",
      show: false,
    },
  ],
  grid: [
    {
      left: 0,
      right: "68%",
      top: 0,
      height: 500,
    },
    {
      left: "68%",
      right: 0,
      bottom: 0,
      height: 500,
    },
  ],
  series: [
    {
      data: computed(
        () =>
          orderbookAsk.value && orderbookAsk.value.map((e) => e.as.toFixed(3))
      ),
      type: "bar",
      barWidth: "30",
      showBackground: true,
      backgroundStyle: {
        color: "#ECF3FA",
      },
      itemStyle: {
        color: "#CCDDF2",
      },
      label: {
        show: true,
        position: "insideRight",
      },
    },
    {
      xAxisIndex: 1,
      yAxisIndex: 1,
      data: computed(
        () =>
          orderbookBid.value && orderbookBid.value.map((e) => e.bs.toFixed(3))
      ),
      type: "bar",
      barWidth: "30",
      showBackground: true,
      backgroundStyle: {
        color: "#F1EAEB",
      },
      itemStyle: {
        color: "#EAD2CF",
      },
      label: {
        show: true,
        position: "insideLeft",
      },
    },
  ],
});

const setScroll = () => {
  scrollRef.value.scrollTop = 100;
};

let orderbookSocket: WebSocket;

const connectOrderbookSocket = () => {
  orderbookSocket = new WebSocket("wss://api.upbit.com/websocket/v1");

  orderbookSocket.onopen = (e: any) => {
    orderbookSocket.send(
      `${JSON.stringify([
        { ticket: "orderbook" },
        { type: "orderbook", codes: [upbit.selectCoin] },
        { format: "SIMPLE" },
      ])}`
    );
  };

  orderbookSocket.onmessage = async (payload: any) => {
    const r = (await new Response(
      payload.data
    ).json()) as ISocketOrderbookResponse;

    orderbookList.value = r;
  };

  setScroll();
};

const reloadOrderbook = () => {
  orderbookSocket.close();
  connectOrderbookSocket();
};

onMounted(() => {
  connectOrderbookSocket();
  upbit.reloadOrderbook = reloadOrderbook;
});
</script>

<style scoped lang="scss">
.orderbook-wrap {
  min-width: 500px;
  width: 50%;
  margin-right: 10px;

  ul {
    margin: 0;
    padding: 0;

    li {
      margin: 0;
    }
  }
}

.orderbook-area {
  height: 800px;
  background-color: #fff;
  position: relative;
  overflow-y: scroll;
}

.order-price-wrap {
  position: absolute;
  width: calc(36% - 3px);
  height: 100%;
  background-color: #fff;
  top: 0;
  left: 0;
  right: 0;
  margin: auto;
  height: 1000px;

  > ul {
    &.ask,
    &.bid {
      height: 50%;
      display: flex;
      flex-direction: column;
      justify-content: space-around;

      > li {
        text-align: center;
        height: 30px;
        line-height: 2;
        font-weight: 700;
        display: flex;
        align-items: center;
        justify-content: space-between;
        padding-left: 15px;
        padding-right: 5px;
        position: relative;

        &.trade {
          border: 1px solid #000;
        }

        > span {
          width: 50%;
          text-align: right;
          font-size: 11px;
          &.per {
            font-size: 12px;
          }
        }
      }

      .box {
        position: relative;

        &::before {
          content: "";
          width: 100%;
          height: 100%;
          position: absolute;
          box-sizing: border-box;
          top: 0;
          left: 0;
          border: 2px solid #000;
        }

        &::after {
          content: "";
          position: absolute;
          top: 0;
          bottom: 0;
          left: 0;
          width: 0px;
          height: 0px;
          margin: auto;
          border-top: 7px solid transparent;
          border-bottom: 7px solid transparent;
          border-left: 7px solid #000;
        }
      }
    }

    &.ask {
      > li {
        background-color: #ecf3fa;
      }
    }

    &.bid {
      > li {
        background-color: #f1eaeb;
      }
    }
  }
}

.trade-wrap {
  width: 32%;
  position: absolute;
  left: 0;
  bottom: -202px;
  height: 500px;
  overflow: hidden;

  .info {
    background-color: #f2f2f2;
    height: 30px;

    > span {
      display: inline-block;
      width: 50%;
      text-align: center;
      line-height: 2.3;
      font-size: 13px;
    }
  }

  .trade {
    padding-right: 2px;
    > li {
      margin-bottom: 3px;
      span {
        display: inline-block;
        text-align: right;
        width: 50%;
        font-size: 11px;
      }
    }
  }
}

.coin-info-wrap {
  width: 32%;
  position: absolute;
  right: 0;
  bottom: 300px;
  font-size: 11px;
  padding: 7px;

  > ul {
    padding-bottom: 16px;

    &:nth-child(2) {
      padding: 0 0 16px;
      border-top: 1px solid #eee;
      border-bottom: 1px solid #eee;
    }
    > li {
      display: flex;
      justify-content: space-between;
      margin-top: 16px;

      > div {
        display: flex;
        flex-direction: column;
        text-align: right;

        > span {
          &:last-child {
            color: #999;
            font-size: 11px;
          }
        }

        > em {
          font-style: normal;
          font-size: 11px;
        }
      }
    }
  }
}

.nav-bottom {
  width: 100%;
  height: 40px;
  display: flex;
  background-color: #f2f2f2;

  > button {
    width: calc(36% - 3px);
    height: 100%;
    border-left: 1px solid #ccc;
    border-right: 1px solid #ccc;
    margin-left: 1px;
  }

  > span {
    display: inline-block;
    flex: 1;
    height: 100%;
    font-size: 12px;
    line-height: 3.5;

    &:first-child {
      text-align: right;
      padding-right: 10px;
    }

    &:last-child {
      text-align: left;
      padding-left: 10px;
    }
  }
}

.color-rise,
.BID {
  color: #d24f45;
}

.color-fall,
.ASK {
  color: #1261c4;
}
</style>

지금까지 만들었던 기능들로 불러오는 데이터들을 가져와 오더북에 최종적으로 연결해주었습니다.

오더북에서 쓰인 차트는 Bar 차트를 위아래로 각 하나씩 만들었습니다.

현재 예시 차트에서는 UpbitTrade.vue 에서 getTradeAPI 로 불러온 데이터가 연결되어있지는 않지만 실제 돌아가는 프로세스에서는 잘 연결되어 초기 데이터부터 잘 보일것입니다.

여기까지 업비트 거래소 페이지의 차트, 마켓리스트, 거래체결내역, 오더북을 구현해보았습니다.
전체적으로 세부적인 기능까지는 구현하지는 않고 핵심 기능들만 구현하였습니다.

마지막으로 MarketList.vuechangeMarket 함수를 실행할때 reload를 시켜야하는 함수들을 실행시키는 코드를 추가하며 마무리 하겠습니다.

정보

// UpbitTrade.vue
...
const reloadTrade = async () => {
  await getTradeAPI();
  upbit.disconnectTradeSocket();
  upbit.connectTradeSocket();
}

onMounted(async () => {
  ...
  upbit.reloadTrade = reloadTrade;
});
...

// MarketList.vue
const changeMarket = (market: string) => {
  closeTickerSocket();
  selectMarket.value = market;
  controlLoadAll();
  connectTickerSocket(Object.keys(markets.value[market]));
  upbit.reloadCandle();
  upbit.reloadTrade();
  upbit.reloadOrderbook();
  selectSort.value.sortTarget = "";
  selectSort.value.sort = "desc";
  dataSort("trp");
};