Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -63,3 +63,4 @@ select = ["E", "F", "I", "UP", "B", "SIM"]
[tool.pyright]
venvPath = "."
venv = ".venv"
extraPaths = ["."]
Empty file added tests/__init__.py
Empty file.
106 changes: 106 additions & 0 deletions tests/_vcr.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
"""VCR 설정 및 커스텀 YAML serializer.

- 기본 record_mode="none" → CI에서 실 API 호출 차단
- VCR_RECORD_MODE 환경변수로 녹화 모드 전환
- 응답 body를 pretty-print JSON + YAML literal block scalar(|)로 저장
"""

from __future__ import annotations

import json
import os
import sys
from typing import Any

import vcr
import yaml
from vcr.record_mode import RecordMode

# ── Pretty JSON body ────────────────────────────────────────────────────


def _pretty_json_body(response: dict) -> dict:
"""응답 body가 JSON이면 정렬·들여쓰기하여 카세트에 읽기 좋게 저장한다."""
body = response.get("body", {}).get("string", "")
# 리플레이 시 bytes로 로드된 body는 건드리지 않는다
if isinstance(body, bytes):
return response
try:
parsed = json.loads(body)
response["body"]["string"] = json.dumps(
parsed, indent=2, ensure_ascii=False, sort_keys=False
)
except (json.JSONDecodeError, TypeError):
pass
return response


# ── YAML literal block scalar ───────────────────────────────────────────


class _LiteralStr(str):
"""yaml dumper가 literal block scalar(|)로 출력하도록 표시하는 래퍼."""


def _literal_representer(dumper: yaml.Dumper, data: _LiteralStr) -> Any:
return dumper.represent_scalar("tag:yaml.org,2002:str", data, style="|")


yaml.add_representer(_LiteralStr, _literal_representer)


def _mark_multiline(obj: Any) -> Any:
"""dict/list를 재귀 탐색하며 개행이 포함된 문자열을 _LiteralStr로 감싼다."""
if isinstance(obj, dict):
return {k: _mark_multiline(v) for k, v in obj.items()}
if isinstance(obj, list):
return [_mark_multiline(v) for v in obj]
if isinstance(obj, str) and "\n" in obj:
return _LiteralStr(obj)
return obj


# ── Serialize / Deserialize ─────────────────────────────────────────────


def _ensure_body_bytes(cassette_dict: dict) -> dict:
"""vcrpy 리플레이 시 body를 bytes로 기대하므로 str → bytes 변환."""
for interaction in cassette_dict.get("interactions", []):
for key in ("request", "response"):
body = interaction.get(key, {}).get("body")
if isinstance(body, dict) and isinstance(
body.get("string"), str
):
body["string"] = body["string"].encode("utf-8")
return cassette_dict


def serialize(cassette_dict: dict) -> str:
return yaml.dump(
_mark_multiline(cassette_dict),
default_flow_style=False,
allow_unicode=True,
)


def deserialize(cassette_string: str) -> Any:
data = yaml.safe_load(cassette_string)
return _ensure_body_bytes(data)


# ── VCR 인스턴스 ────────────────────────────────────────────────────────

# tests/_vcr 모듈 자체가 serialize/deserialize를 갖고 있으므로
# register_serializer에 모듈을 직접 등록한다.
_this_module = sys.modules[__name__]

upbeat_vcr = vcr.VCR(
cassette_library_dir="tests/cassettes",
filter_headers=[("Authorization", "REDACTED")],
filter_query_parameters=["access_key"],
decode_compressed_response=True,
record_mode=RecordMode(os.environ.get("VCR_RECORD_MODE", "none")),
before_record_response=_pretty_json_body,
)
upbeat_vcr.register_serializer("pretty-yaml", _this_module)
upbeat_vcr.serializer = "pretty-yaml"
182 changes: 182 additions & 0 deletions tests/api/test_quotation_vcr.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,182 @@
from __future__ import annotations

from tests.conftest import upbeat_vcr
from upbeat import Upbeat
from upbeat.types.quotation import (
CandleDay,
CandleMinute,
CandlePeriod,
CandleSecond,
Orderbook,
OrderbookInstrument,
Ticker,
Trade,
)


def _client() -> Upbeat:
return Upbeat(max_retries=0, auto_throttle=False)


# ── Tickers ─────────────────────────────────────────────────────────────


class TestGetTickers:
@upbeat_vcr.use_cassette("quotation/get_tickers.yaml")
def test_returns_valid_tickers(self) -> None:
with _client() as client:
result = client.quotation.get_tickers("KRW-BTC")

assert len(result) >= 1
ticker = result[0]
assert isinstance(ticker, Ticker)
assert ticker.market == "KRW-BTC"
assert ticker.change in ("EVEN", "RISE", "FALL")
assert isinstance(ticker.trade_price, float)
assert isinstance(ticker.timestamp, int)


class TestGetTickersByQuote:
@upbeat_vcr.use_cassette("quotation/get_tickers_by_quote.yaml")
def test_returns_valid_tickers(self) -> None:
with _client() as client:
result = client.quotation.get_tickers_by_quote("KRW")

assert len(result) >= 1
for ticker in result:
assert isinstance(ticker, Ticker)
assert ticker.market.startswith("KRW-")


# ── Candles ─────────────────────────────────────────────────────────────


class TestGetCandlesMinutes:
@upbeat_vcr.use_cassette("quotation/get_candles_minutes.yaml")
def test_returns_valid_candles(self) -> None:
with _client() as client:
result = client.quotation.get_candles_minutes(market="KRW-BTC", unit=1)

assert len(result) >= 1
candle = result[0]
assert isinstance(candle, CandleMinute)
assert candle.market == "KRW-BTC"
assert isinstance(candle.unit, int)
assert isinstance(candle.opening_price, float)


class TestGetCandlesSeconds:
@upbeat_vcr.use_cassette("quotation/get_candles_seconds.yaml")
def test_returns_valid_candles(self) -> None:
with _client() as client:
result = client.quotation.get_candles_seconds(market="KRW-BTC")

assert len(result) >= 1
candle = result[0]
assert isinstance(candle, CandleSecond)
assert candle.market == "KRW-BTC"
assert isinstance(candle.opening_price, float)


class TestGetCandlesDays:
@upbeat_vcr.use_cassette("quotation/get_candles_days.yaml")
def test_returns_valid_candles(self) -> None:
with _client() as client:
result = client.quotation.get_candles_days(market="KRW-BTC")

assert len(result) >= 1
candle = result[0]
assert isinstance(candle, CandleDay)
assert candle.market == "KRW-BTC"
assert isinstance(candle.prev_closing_price, float)
assert isinstance(candle.change_price, float)
assert isinstance(candle.change_rate, float)


class TestGetCandlesWeeks:
@upbeat_vcr.use_cassette("quotation/get_candles_weeks.yaml")
def test_returns_valid_candles(self) -> None:
with _client() as client:
result = client.quotation.get_candles_weeks(market="KRW-BTC")

assert len(result) >= 1
candle = result[0]
assert isinstance(candle, CandlePeriod)
assert candle.market == "KRW-BTC"
assert isinstance(candle.first_day_of_period, str)


class TestGetCandlesMonths:
@upbeat_vcr.use_cassette("quotation/get_candles_months.yaml")
def test_returns_valid_candles(self) -> None:
with _client() as client:
result = client.quotation.get_candles_months(market="KRW-BTC")

assert len(result) >= 1
candle = result[0]
assert isinstance(candle, CandlePeriod)
assert candle.market == "KRW-BTC"
assert isinstance(candle.first_day_of_period, str)


class TestGetCandlesYears:
@upbeat_vcr.use_cassette("quotation/get_candles_years.yaml")
def test_returns_valid_candles(self) -> None:
with _client() as client:
result = client.quotation.get_candles_years(market="KRW-BTC")

assert len(result) >= 1
candle = result[0]
assert isinstance(candle, CandlePeriod)
assert candle.market == "KRW-BTC"
assert isinstance(candle.first_day_of_period, str)


# ── Orderbooks ──────────────────────────────────────────────────────────


class TestGetOrderbooks:
@upbeat_vcr.use_cassette("quotation/get_orderbooks.yaml")
def test_returns_valid_orderbooks(self) -> None:
with _client() as client:
result = client.quotation.get_orderbooks("KRW-BTC")

assert len(result) >= 1
ob = result[0]
assert isinstance(ob, Orderbook)
assert ob.market == "KRW-BTC"
assert len(ob.orderbook_units) >= 1
unit = ob.orderbook_units[0]
assert isinstance(unit.ask_price, float)
assert isinstance(unit.bid_price, float)


class TestGetOrderbookInstruments:
@upbeat_vcr.use_cassette("quotation/get_orderbook_instruments.yaml")
def test_returns_valid_instruments(self) -> None:
with _client() as client:
result = client.quotation.get_orderbook_instruments("KRW-BTC")

assert len(result) >= 1
inst = result[0]
assert isinstance(inst, OrderbookInstrument)
assert inst.market == "KRW-BTC"
assert isinstance(inst.supported_levels, list)


# ── Trades ──────────────────────────────────────────────────────────────


class TestGetTrades:
@upbeat_vcr.use_cassette("quotation/get_trades.yaml")
def test_returns_valid_trades(self) -> None:
with _client() as client:
result = client.quotation.get_trades("KRW-BTC")

assert len(result) >= 1
trade = result[0]
assert isinstance(trade, Trade)
assert trade.market == "KRW-BTC"
assert trade.ask_bid in ("ASK", "BID")
assert isinstance(trade.trade_price, float)
assert isinstance(trade.sequential_id, int)
65 changes: 65 additions & 0 deletions tests/cassettes/quotation/get_candles_days.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
interactions:
- request:
body: ''
headers:
Accept:
- '*/*'
Accept-Encoding:
- gzip, deflate
Connection:
- keep-alive
Host:
- api.upbit.com
User-Agent:
- python-httpx/0.28.1
method: GET
uri: https://api.upbit.com/v1/candles/days?market=KRW-BTC
response:
body:
string: |-
[
{
"market": "KRW-BTC",
"candle_date_time_utc": "2026-03-11T00:00:00",
"candle_date_time_kst": "2026-03-11T09:00:00",
"opening_price": 102417000.0,
"high_price": 103988000.0,
"low_price": 101150000.0,
"trade_price": 103460000.0,
"timestamp": 1773240059132,
"candle_acc_trade_price": 104890599315.22821,
"candle_acc_trade_volume": 1024.30999241,
"prev_closing_price": 102417000.0,
"change_price": 1043000.0,
"change_rate": 0.0101838562
}
]
headers:
Cache-Control:
- no-cache, no-store, max-age=0, must-revalidate
Connection:
- keep-alive
Content-Type:
- application/json;charset=UTF-8
Date:
- Wed, 11 Mar 2026 14:40:59 GMT
ETag:
- W/"0e2f0e810adeff5a52edede46f10a87be"
Expires:
- '0'
Limit-By-Ip:
- 'Yes'
Pragma:
- no-cache
Remaining-Req:
- group=candles; min=600; sec=7
Transfer-Encoding:
- chunked
Vary:
- origin,access-control-request-method,access-control-request-headers,accept-encoding
content-length:
- '455'
status:
code: 200
message: ''
version: 1
Loading
Loading