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
122 changes: 122 additions & 0 deletions scripts/record_order_cassettes.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
"""시장가 매수→매도 VCR cassette 녹화 스크립트.

실제 거래가 발생하므로 소량의 금전적 손실(스프레드+수수료)이 발생한다.
KRW-BTC 마켓에서 10,000원으로 매수 후 즉시 전량 매도한다.

사용법:
UPBIT_ACCESS_KEY=xxx UPBIT_SECRET_KEY=yyy \
uv run python scripts/record_order_cassettes.py
"""

from __future__ import annotations

import os
import sys
import time

# 프로젝트 루트의 tests 모듈을 import하기 위해 경로 추가
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))

from tests._vcr import upbeat_vcr # noqa: E402

from upbeat import Upbeat # noqa: E402

_MARKET = "KRW-BTC"
_BUY_AMOUNT = "10000" # 매도 시 최소금액(5000원) 확보를 위해 넉넉히
_CASSETTE_PATH = "orders/market_buy_sell.yaml"


def _require_env(name: str) -> str:
value = os.environ.get(name)
if not value:
print(f"ERROR: {name} 환경변수가 설정되지 않았습니다.")
sys.exit(1)
return value


def main() -> None:
access_key = _require_env("UPBIT_ACCESS_KEY")
secret_key = _require_env("UPBIT_SECRET_KEY")

print("=" * 60)
print("시장가 매수→매도 VCR Cassette 녹화")
print("=" * 60)
print(f" 마켓: {_MARKET}")
print(f" 매수 금액: {_BUY_AMOUNT}원")
print(" 예상 손실: 스프레드 + 수수료 ≈ 100~200원")
print(f" Cassette: {_CASSETTE_PATH}")
print("=" * 60)
print()

confirm = input("계속하시겠습니까? (y/N): ").strip().lower()
if confirm != "y":
print("취소되었습니다.")
sys.exit(0)

client = Upbeat(
access_key=access_key,
secret_key=secret_key,
max_retries=0,
auto_throttle=False,
)

with client, upbeat_vcr.use_cassette(
_CASSETTE_PATH, record_mode="all"
):
# 1) 시장가 매수
print(f"\n[1/4] 시장가 매수 ({_MARKET}, {_BUY_AMOUNT}원)...")
buy = client.orders.create(
market=_MARKET,
side="bid",
ord_type="price",
price=_BUY_AMOUNT,
)
print(f" 주문 생성: uuid={buy.uuid}, state={buy.state}")

# 체결 대기
time.sleep(1)

# 2) 매수 주문 상태 조회
print("[2/4] 매수 주문 상태 조회...")
buy_detail = client.orders.get(uuid=buy.uuid)
print(
f" state={buy_detail.state}, "
f"executed_volume={buy_detail.executed_volume}"
)

if buy_detail.executed_volume == "0":
print("ERROR: 매수 체결량이 0입니다. 매도를 건너뜁니다.")
sys.exit(1)

# 3) 매수 수량 전량 시장가 매도
print(f"[3/4] 시장가 매도 (volume={buy_detail.executed_volume})...")
try:
sell = client.orders.create(
market=_MARKET,
side="ask",
ord_type="market",
volume=buy_detail.executed_volume,
)
print(f" 주문 생성: uuid={sell.uuid}, state={sell.state}")
except Exception as e:
print(f"ERROR: 매도 실패 — {e}")
print(" 수동으로 매도해야 합니다!")
sys.exit(1)

# 체결 대기
time.sleep(1)

# 4) 매도 주문 상태 조회
print("[4/4] 매도 주문 상태 조회...")
sell_detail = client.orders.get(uuid=sell.uuid)
print(
f" state={sell_detail.state}, "
f"executed_volume={sell_detail.executed_volume}"
)

print(f"\nCassette 저장 완료: tests/cassettes/{_CASSETTE_PATH}")
print("녹화가 완료되었습니다.")


if __name__ == "__main__":
main()
8 changes: 4 additions & 4 deletions src/upbeat/api/orders.py
Original file line number Diff line number Diff line change
Expand Up @@ -235,12 +235,12 @@ def cancel_and_new(
)
return CancelAndNewOrderResponse.model_validate(response.data)

def get_chance(self, *, market: str) -> list[OrderChance]:
def get_chance(self, *, market: str) -> OrderChance:
params = {"market": market}
response = self._transport.request(
"GET", "/v1/orders/chance", params=params, credentials=self._credentials
)
return [OrderChance.model_validate(item) for item in response.data]
return OrderChance.model_validate(response.data)


class AsyncOrdersAPI(_AsyncAPIResource):
Expand Down Expand Up @@ -458,9 +458,9 @@ async def cancel_and_new(
)
return CancelAndNewOrderResponse.model_validate(response.data)

async def get_chance(self, *, market: str) -> list[OrderChance]:
async def get_chance(self, *, market: str) -> OrderChance:
params = {"market": market}
response = await self._transport.request(
"GET", "/v1/orders/chance", params=params, credentials=self._credentials
)
return [OrderChance.model_validate(item) for item in response.data]
return OrderChance.model_validate(response.data)
6 changes: 3 additions & 3 deletions src/upbeat/types/order.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ class OrderCreated(BaseModel):
state: str
created_at: str
volume: str | None = None
remaining_volume: str
remaining_volume: str | None = None
executed_volume: str
reserved_fee: str
remaining_fee: str
Expand All @@ -42,8 +42,8 @@ class OrderCreated(BaseModel):
time_in_force: str | None = None
identifier: str | None = None
smp_type: str | None = None
prevented_volume: str
prevented_locked: str
prevented_volume: str | None = None
prevented_locked: str | None = None


# ── OrderDetail (GET /v1/order 응답) ────────────────────────────────────
Expand Down
21 changes: 21 additions & 0 deletions tests/_vcr.py
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,28 @@ def _ensure_body_bytes(cassette_dict: dict) -> dict:
return cassette_dict


def _prettify_response_bodies(cassette_dict: dict) -> None:
"""serialize 직전에 bytes body를 pretty JSON 문자열로 변환한다."""
for interaction in cassette_dict.get("interactions", []):
body = interaction.get("response", {}).get("body")
if not isinstance(body, dict):
continue
raw = body.get("string", "")
if isinstance(raw, bytes):
raw = raw.decode("utf-8")
try:
parsed = json.loads(raw)
body["string"] = json.dumps(
parsed, indent=2, ensure_ascii=False, sort_keys=False
)
except (json.JSONDecodeError, TypeError):
# JSON이 아니어도 bytes → str 변환은 필요 (YAML 저장용)
if isinstance(body.get("string"), bytes):
body["string"] = body["string"].decode("utf-8")


def serialize(cassette_dict: dict) -> str:
_prettify_response_bodies(cassette_dict)
return yaml.dump(
_mark_multiline(cassette_dict),
default_flow_style=False,
Expand Down
34 changes: 34 additions & 0 deletions tests/api/test_accounts_vcr.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
from __future__ import annotations

import os

from tests.conftest import upbeat_vcr
from upbeat import Upbeat
from upbeat.types.account import Account


def _client() -> Upbeat:
return Upbeat(
access_key=os.environ.get("UPBIT_ACCESS_KEY", "test-key"),
secret_key=os.environ.get("UPBIT_SECRET_KEY", "test-secret"),
max_retries=0,
auto_throttle=False,
)


# ── Accounts ───────────────────────────────────────────────────────────


class TestListAccounts:
@upbeat_vcr.use_cassette("accounts/list.yaml")
def test_returns_valid_accounts(self) -> None:
with _client() as client:
result = client.accounts.list()

assert len(result) >= 1
account = result[0]
assert isinstance(account, Account)
assert isinstance(account.currency, str)
assert isinstance(account.balance, str)
assert isinstance(account.locked, str)
assert isinstance(account.unit_currency, str)
24 changes: 11 additions & 13 deletions tests/api/test_orders.py
Original file line number Diff line number Diff line change
Expand Up @@ -512,27 +512,26 @@ def test_calls_correct_endpoint(self) -> None:
def handler(request: httpx.Request) -> httpx.Response:
assert request.url.path == "/v1/orders/chance"
assert request.url.params["market"] == "KRW-BTC"
return _json_response([ORDER_CHANCE_DATA])
return _json_response(ORDER_CHANCE_DATA)

transport = _make_transport(handler)
api = OrdersAPI(transport, CREDENTIALS)
result = api.get_chance(market="KRW-BTC")
assert len(result) == 1
assert isinstance(result[0], OrderChance)
assert result[0].bid_fee == "0.0005"
assert isinstance(result, OrderChance)
assert result.bid_fee == "0.0005"

def test_parses_nested_objects(self) -> None:
def handler(request: httpx.Request) -> httpx.Response:
return _json_response([ORDER_CHANCE_DATA])
return _json_response(ORDER_CHANCE_DATA)

transport = _make_transport(handler)
api = OrdersAPI(transport, CREDENTIALS)
result = api.get_chance(market="KRW-BTC")
assert result[0].market.id == "KRW-BTC"
assert result[0].market.bid is not None
assert result[0].market.bid.currency == "KRW"
assert result[0].bid_account.currency == "KRW"
assert result[0].ask_account.currency == "BTC"
assert result.market.id == "KRW-BTC"
assert result.market.bid is not None
assert result.market.bid.currency == "KRW"
assert result.bid_account.currency == "KRW"
assert result.ask_account.currency == "BTC"


# ── TestAsyncOrders ──────────────────────────────────────────────────────
Expand Down Expand Up @@ -578,10 +577,9 @@ async def handler(request: httpx.Request) -> httpx.Response:
@pytest.mark.asyncio
async def test_get_chance(self) -> None:
async def handler(request: httpx.Request) -> httpx.Response:
return _json_response([ORDER_CHANCE_DATA])
return _json_response(ORDER_CHANCE_DATA)

transport = _make_async_transport(handler)
api = AsyncOrdersAPI(transport, CREDENTIALS)
result = await api.get_chance(market="KRW-BTC")
assert len(result) == 1
assert isinstance(result[0], OrderChance)
assert isinstance(result, OrderChance)
Loading
Loading