Skip to content

Commit 1084c7d

Browse files
authored
Merge pull request #42 from interruping/feat/vcr-exchange-cassettes
test: Exchange API (Asset, Order) VCR cassette 통합 테스트 추가
2 parents 51cb970 + c54a00c commit 1084c7d

15 files changed

Lines changed: 2129 additions & 0 deletions

scripts/record_order_cassettes.py

Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
"""시장가 매수→매도 VCR cassette 녹화 스크립트.
2+
3+
실제 거래가 발생하므로 소량의 금전적 손실(스프레드+수수료)이 발생한다.
4+
KRW-BTC 마켓에서 10,000원으로 매수 후 즉시 전량 매도한다.
5+
6+
사용법:
7+
UPBIT_ACCESS_KEY=xxx UPBIT_SECRET_KEY=yyy \
8+
uv run python scripts/record_order_cassettes.py
9+
"""
10+
11+
from __future__ import annotations
12+
13+
import os
14+
import sys
15+
import time
16+
17+
# 프로젝트 루트의 tests 모듈을 import하기 위해 경로 추가
18+
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
19+
20+
from tests._vcr import upbeat_vcr # noqa: E402
21+
22+
from upbeat import Upbeat # noqa: E402
23+
24+
_MARKET = "KRW-BTC"
25+
_BUY_AMOUNT = "10000" # 매도 시 최소금액(5000원) 확보를 위해 넉넉히
26+
_CASSETTE_PATH = "orders/market_buy_sell.yaml"
27+
28+
29+
def _require_env(name: str) -> str:
30+
value = os.environ.get(name)
31+
if not value:
32+
print(f"ERROR: {name} 환경변수가 설정되지 않았습니다.")
33+
sys.exit(1)
34+
return value
35+
36+
37+
def main() -> None:
38+
access_key = _require_env("UPBIT_ACCESS_KEY")
39+
secret_key = _require_env("UPBIT_SECRET_KEY")
40+
41+
print("=" * 60)
42+
print("시장가 매수→매도 VCR Cassette 녹화")
43+
print("=" * 60)
44+
print(f" 마켓: {_MARKET}")
45+
print(f" 매수 금액: {_BUY_AMOUNT}원")
46+
print(" 예상 손실: 스프레드 + 수수료 ≈ 100~200원")
47+
print(f" Cassette: {_CASSETTE_PATH}")
48+
print("=" * 60)
49+
print()
50+
51+
confirm = input("계속하시겠습니까? (y/N): ").strip().lower()
52+
if confirm != "y":
53+
print("취소되었습니다.")
54+
sys.exit(0)
55+
56+
client = Upbeat(
57+
access_key=access_key,
58+
secret_key=secret_key,
59+
max_retries=0,
60+
auto_throttle=False,
61+
)
62+
63+
with client, upbeat_vcr.use_cassette(
64+
_CASSETTE_PATH, record_mode="all"
65+
):
66+
# 1) 시장가 매수
67+
print(f"\n[1/4] 시장가 매수 ({_MARKET}, {_BUY_AMOUNT}원)...")
68+
buy = client.orders.create(
69+
market=_MARKET,
70+
side="bid",
71+
ord_type="price",
72+
price=_BUY_AMOUNT,
73+
)
74+
print(f" 주문 생성: uuid={buy.uuid}, state={buy.state}")
75+
76+
# 체결 대기
77+
time.sleep(1)
78+
79+
# 2) 매수 주문 상태 조회
80+
print("[2/4] 매수 주문 상태 조회...")
81+
buy_detail = client.orders.get(uuid=buy.uuid)
82+
print(
83+
f" state={buy_detail.state}, "
84+
f"executed_volume={buy_detail.executed_volume}"
85+
)
86+
87+
if buy_detail.executed_volume == "0":
88+
print("ERROR: 매수 체결량이 0입니다. 매도를 건너뜁니다.")
89+
sys.exit(1)
90+
91+
# 3) 매수 수량 전량 시장가 매도
92+
print(f"[3/4] 시장가 매도 (volume={buy_detail.executed_volume})...")
93+
try:
94+
sell = client.orders.create(
95+
market=_MARKET,
96+
side="ask",
97+
ord_type="market",
98+
volume=buy_detail.executed_volume,
99+
)
100+
print(f" 주문 생성: uuid={sell.uuid}, state={sell.state}")
101+
except Exception as e:
102+
print(f"ERROR: 매도 실패 — {e}")
103+
print(" 수동으로 매도해야 합니다!")
104+
sys.exit(1)
105+
106+
# 체결 대기
107+
time.sleep(1)
108+
109+
# 4) 매도 주문 상태 조회
110+
print("[4/4] 매도 주문 상태 조회...")
111+
sell_detail = client.orders.get(uuid=sell.uuid)
112+
print(
113+
f" state={sell_detail.state}, "
114+
f"executed_volume={sell_detail.executed_volume}"
115+
)
116+
117+
print(f"\nCassette 저장 완료: tests/cassettes/{_CASSETTE_PATH}")
118+
print("녹화가 완료되었습니다.")
119+
120+
121+
if __name__ == "__main__":
122+
main()

tests/_vcr.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,28 @@ def _ensure_body_bytes(cassette_dict: dict) -> dict:
7575
return cassette_dict
7676

7777

78+
def _prettify_response_bodies(cassette_dict: dict) -> None:
79+
"""serialize 직전에 bytes body를 pretty JSON 문자열로 변환한다."""
80+
for interaction in cassette_dict.get("interactions", []):
81+
body = interaction.get("response", {}).get("body")
82+
if not isinstance(body, dict):
83+
continue
84+
raw = body.get("string", "")
85+
if isinstance(raw, bytes):
86+
raw = raw.decode("utf-8")
87+
try:
88+
parsed = json.loads(raw)
89+
body["string"] = json.dumps(
90+
parsed, indent=2, ensure_ascii=False, sort_keys=False
91+
)
92+
except (json.JSONDecodeError, TypeError):
93+
# JSON이 아니어도 bytes → str 변환은 필요 (YAML 저장용)
94+
if isinstance(body.get("string"), bytes):
95+
body["string"] = body["string"].decode("utf-8")
96+
97+
7898
def serialize(cassette_dict: dict) -> str:
99+
_prettify_response_bodies(cassette_dict)
79100
return yaml.dump(
80101
_mark_multiline(cassette_dict),
81102
default_flow_style=False,

tests/api/test_accounts_vcr.py

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
from __future__ import annotations
2+
3+
import os
4+
5+
from tests.conftest import upbeat_vcr
6+
from upbeat import Upbeat
7+
from upbeat.types.account import Account
8+
9+
10+
def _client() -> Upbeat:
11+
return Upbeat(
12+
access_key=os.environ.get("UPBIT_ACCESS_KEY", "test-key"),
13+
secret_key=os.environ.get("UPBIT_SECRET_KEY", "test-secret"),
14+
max_retries=0,
15+
auto_throttle=False,
16+
)
17+
18+
19+
# ── Accounts ───────────────────────────────────────────────────────────
20+
21+
22+
class TestListAccounts:
23+
@upbeat_vcr.use_cassette("accounts/list.yaml")
24+
def test_returns_valid_accounts(self) -> None:
25+
with _client() as client:
26+
result = client.accounts.list()
27+
28+
assert len(result) >= 1
29+
account = result[0]
30+
assert isinstance(account, Account)
31+
assert isinstance(account.currency, str)
32+
assert isinstance(account.balance, str)
33+
assert isinstance(account.locked, str)
34+
assert isinstance(account.unit_currency, str)

0 commit comments

Comments
 (0)