토스증권 Open API 시작하기 (7) - dry-run으로 실제 주문 없이 전략 검증하기

지난 6편에서는 삼성전자의 현재가를 전일 종가와 비교해서 하락률을 계산했습니다.
삼성전자가 전일 종가 대비 3% 이상 하락하면 매수 후보로 본다.
조건을 숫자로 만들고 나면 다음 단계는 자연스럽게 주문입니다. 그런데 자동매매에서는 이 지점에서 한 번 멈추는 편이 좋습니다.
코드가 처음으로 실제 계좌와 연결되는 순간부터는 단순한 if 문도 주문 버튼이 됩니다. 가격 조회는 몇 번 틀려도 다시 고치면 되지만, 주문은 한 번 나가면 체결, 취소, 정정, 잔고 반영까지 따라옵니다.
그래서 이번 글에서는 6편에서 만든 하락률 조건을 바로 실주문으로 연결하지 않고, 먼저 dry-run으로 검증하는 구조를 만들어보겠습니다.
오늘의 목표: 전일 대비 3% 이상 하락 신호가 나오면 실제 주문 대신 PAPER 주문을 기록한다.
이번 글 역시 투자 권유가 아니라 토스증권 Open API와 Python으로 자동매매 프로그램을 안전하게 만들어가는 개발 기록입니다.
1. 왜 바로 주문하지 않았나
6편의 마지막 코드는 이렇게 끝났습니다.
target_drop_rate = Decimal("-3")
if drop_rate <= target_drop_rate:
print("매수 후보 조건 충족")
else:
print("아직 매수 후보가 아닙니다")
여기서 print() 대신 주문 코드를 넣으면 일단 동작은 만들어집니다.
if drop_rate <= target_drop_rate:
client.order.buy_market(
symbol="005930",
quantity=1,
)
하지만 이렇게 바로 붙이면 확인할 수 있는 것이 너무 적습니다.
정말 내가 생각한 시점에 신호가 발생했는지, 같은 종목에 이미 미체결 주문이 있는지, 장 시간이 맞는지, 주문 가능 금액이 충분한지, 기록 파일에는 어떤 값이 남는지 알기 어렵습니다.
자동매매 코드는 수익을 내기 전에 먼저 “내가 의도한 대로만 움직이는지”를 보여줘야 합니다. 그래서 저는 새 주문형 전략을 만들 때 실주문보다 dry-run을 먼저 붙이는 쪽으로 기준을 잡았습니다.
2. dry-run, mock, backtest 구분하기
처음에는 dry-run, mock, backtest가 비슷해 보였습니다. 전부 실제 주문을 하지 않는다는 점에서는 같으니까요.
그런데 자동매매 프로그램을 만들다 보면 세 가지의 역할이 조금씩 다릅니다.
| 구분 | 쓰는 시점 | 핵심 |
|---|---|---|
| mock | 테스트 코드에서 API 응답을 가짜로 바꿀 때 | 특정 상황을 재현한다 |
| backtest | 과거 데이터로 전략 규칙을 돌려볼 때 | 규칙의 과거 동작을 비교한다 |
| dry-run | 실제 실행 흐름에서 주문만 종이 주문으로 바꿀 때 | 실시간에 가까운 운영 흐름을 확인한다 |
mock은 단위 테스트에 가깝습니다. 예를 들어 현재가가 59,900원일 때 조건이 참이 되는지 확인할 수 있습니다.
backtest는 과거 데이터를 많이 넣고 전략을 반복 재생합니다. 어떤 조건이 너무 자주 발생하는지, 손절과 익절 기준이 어느 정도 영향을 주는지 볼 때 좋습니다.
dry-run은 조금 더 운영에 가깝습니다. 실제 실행 시간, 실제 API 응답, 실제 상태 저장 흐름을 사용하되 마지막 주문만 PAPER 주문으로 남깁니다.

이 차이를 분리해두면 글을 쓰는 입장에서도, 코드를 고치는 입장에서도 훨씬 덜 헷갈립니다.
3. 프로젝트의 dry-run 기준
현재 프로젝트에서는 새 주문형 전략의 설정 기준을 DryRunStrategyConfig로 잡았습니다.
핵심은 단순합니다.
from dataclasses import dataclass
from tossinvestsdk.strategy.base_config import BaseStrategyConfig
@dataclass(slots=True)
class DryRunStrategyConfig(BaseStrategyConfig):
dry_run: bool = True
새 전략은 기본값이 dry_run=True입니다. 실주문을 하려면 설정에서 명시적으로 dry_run=False를 넣어야 합니다.
실수하기 쉬운 부분도 하나 있습니다. 설정 파일에서는 boolean이 문자열로 들어오는 경우가 있습니다.
{
"dry_run": "false"
}
Python에서 문자열 "false"는 truthy 값입니다. 그냥 bool("false")로 처리하면 True가 되어버립니다. 그래서 현재 구현에서는 "true", "false", "on", "off", 1, 0처럼 허용할 값만 명확히 파싱하고, 애매한 값은 예외로 막습니다.
@staticmethod
def coerce_dry_run(value: bool | int | str) -> bool:
if isinstance(value, bool):
return value
if isinstance(value, int) and value in (0, 1):
return bool(value)
if isinstance(value, str):
normalized = value.strip().lower()
if normalized in {"true", "1", "yes", "on"}:
return True
if normalized in {"false", "0", "no", "off"}:
return False
raise ValueError("dry_run must be a boolean value")
작은 코드지만 자동매매에서는 이런 부분이 꽤 중요합니다. 설정 하나 잘못 읽어서 실주문 경로가 열리면 안 되기 때문입니다.
4. 6편의 조건을 종이 주문으로 바꾸기
이제 6편에서 만든 하락률 조건을 dry-run 흐름으로 바꿔보겠습니다.
먼저 주문처럼 기록할 작은 모델을 만듭니다.
from dataclasses import dataclass
from decimal import Decimal
@dataclass(slots=True)
class PaperOrder:
order_id: str
symbol: str
side: str
quantity: int
reference_price: Decimal
reason: str
dry_run: bool = True
실제 주문 API 응답과 완전히 같을 필요는 없습니다. 이 단계에서 중요한 것은 “왜 이 주문이 만들어졌는지”를 나중에 다시 볼 수 있게 남기는 것입니다.
그다음 신호를 주문 후보로 바꿉니다.
from datetime import datetime
from decimal import Decimal
def make_paper_order(
*,
symbol: str,
current_price: Decimal,
drop_rate: Decimal,
quantity: int,
) -> PaperOrder:
now = datetime.now().strftime("%Y%m%d%H%M%S")
return PaperOrder(
order_id=f"PAPER-drop-buy-{symbol}-{now}",
symbol=symbol,
side="BUY",
quantity=quantity,
reference_price=current_price,
reason=f"전일 대비 {drop_rate:.2f}% 하락",
)
그리고 조건문은 이렇게 바뀝니다.
target_drop_rate = Decimal("-3")
if drop_rate <= target_drop_rate:
order = make_paper_order(
symbol="005930",
current_price=current_price,
drop_rate=drop_rate,
quantity=1,
)
print(order)
else:
print("아직 매수 후보가 아닙니다")
이 코드의 핵심은 주문 함수가 호출되지 않는다는 점입니다. 조건은 실제로 판단하지만, 결과는 종이 주문으로만 남깁니다.
5. 상태 파일에 남겨보기
콘솔 출력만으로는 나중에 확인하기 어렵습니다. dry-run은 기록까지 남겨야 의미가 있습니다.
가장 단순한 형태로 JSON 파일에 저장해보겠습니다.
import json
from dataclasses import asdict
from pathlib import Path
def save_paper_order(order: PaperOrder, path: Path) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
payload = asdict(order)
payload["reference_price"] = str(order.reference_price)
path.write_text(
json.dumps(payload, ensure_ascii=False, indent=2),
encoding="utf-8",
)
저장되는 값은 이런 모양입니다.
{
"order_id": "PAPER-drop-buy-005930-20260906103000",
"symbol": "005930",
"side": "BUY",
"quantity": 1,
"reference_price": "67900",
"reason": "전일 대비 -3.00% 하락",
"dry_run": true
}
실제 프로젝트의 가격·거래량 전략은 여기서 한 단계 더 나아가 pending_orders, positions, trade_history, audit_log를 따로 관리합니다.
처음부터 그 구조를 전부 따라 만들 필요는 없습니다. 다만 dry-run을 “주문을 안 하는 옵션” 정도로만 두면 나중에 검증할 정보가 부족해집니다.
제가 생각하는 최소 기록은 네 가지입니다.
| 기록 | 이유 |
|---|---|
| 신호 발생 시각 | 언제 조건이 참이 되었는지 확인 |
| 기준 가격 | 어떤 가격을 보고 판단했는지 확인 |
| 주문 방향과 수량 | 실제 주문으로 바꿀 때의 영향 확인 |
| 판단 사유 | 나중에 로그만 보고도 맥락 확인 |
6. 실주문 경로는 명시적으로만 열기
dry-run 코드가 생겼다면 이제 실주문 경로도 안전하게 분기할 수 있습니다.
def submit_buy_signal(
*,
client,
config,
symbol: str,
current_price: Decimal,
drop_rate: Decimal,
quantity: int,
):
if config.dry_run:
order = make_paper_order(
symbol=symbol,
current_price=current_price,
drop_rate=drop_rate,
quantity=quantity,
)
save_paper_order(order, Path("strategy_state") / f"{symbol}.paper.json")
return order
return client.order.buy_market(
symbol=symbol,
quantity=quantity,
)
이렇게 두면 기본 실행은 종이 주문으로 끝납니다.
실제 주문을 하려면 설정에서 직접 꺼야 합니다.
config = DryRunStrategyConfig(
dry_run=False,
)
물론 이 한 줄을 넣기 전에 할 일이 많습니다. 매수 가능 금액 조회, 미체결 주문 조회, 장 시간 확인, 주문 수량 제한, 예외 처리, 체결 확인, 상태 저장 실패 시 중단 같은 안전장치가 먼저 있어야 합니다.

7. 테스트로 확인한 부분
dry-run 설정은 단순해 보여도 테스트를 꼭 두는 편이 좋습니다.
예를 들어 현재 프로젝트에는 이런 테스트가 있습니다.
def test_dry_run_config_defaults_to_paper_mode():
assert DryRunStrategyConfig().dry_run is True
기본값이 종이 주문 모드인지 확인합니다.
문자열 설정도 확인합니다.
@pytest.mark.parametrize(
("value", "expected"),
[(False, False), (0, False), ("off", False), ("false", False), ("on", True)],
)
def test_dry_run_config_parses_explicit_mode(value, expected):
assert DryRunStrategyConfig(dry_run=value).dry_run is expected
그리고 애매한 값은 거부합니다.
def test_dry_run_config_rejects_ambiguous_values():
with pytest.raises(ValueError, match="dry_run"):
DryRunStrategyConfig(dry_run="paper")
"paper"라는 말은 사람이 보기에는 dry-run처럼 느껴질 수 있습니다. 하지만 프로그램 입장에서는 공식적으로 허용한 값이 아니므로 막는 편이 낫습니다.
자동매매 설정은 친절한 추측보다 명확한 실패가 더 안전합니다.
8. 실거래 전에 확인할 것
이번 글의 코드는 실제 주문을 넣지 않습니다. 그래도 나중에 실주문과 연결하려면 아래 항목을 dry-run 로그에서 먼저 확인해야 합니다.
| 확인 항목 | 봐야 하는 이유 |
|---|---|
| 신호 빈도 | 조건이 너무 자주 발생하지 않는지 확인 |
| 중복 주문 | 같은 종목에 종이 주문이 여러 번 생기지 않는지 확인 |
| 미체결 처리 | 주문 후 체결 전 상태를 관리할 수 있는지 확인 |
| 매수 가능 금액 | 실제 주문 가능 금액 안에서 수량이 계산되는지 확인 |
| 예외 처리 | API 실패나 저장 실패가 조용히 묻히지 않는지 확인 |
특히 저장 실패는 가볍게 보면 안 됩니다. 주문은 나갔는데 상태 파일 저장이 실패하면, 다음 실행에서 프로그램은 자신이 무엇을 했는지 모를 수 있습니다.
dry-run 단계에서 이런 실패 흐름을 먼저 겪어보는 것이 좋습니다. 실제 돈이 움직이지 않을 때 발견한 버그가 가장 싸게 고친 버그입니다.
9. 마무리
이번 글에서는 6편의 하락률 조건을 실제 주문으로 연결하지 않고, dry-run으로 종이 주문을 남기는 구조를 만들어봤습니다.
흐름은 이렇게 정리할 수 있습니다.
| 단계 | 내용 |
|---|---|
| 1 | 현재가와 전일 종가로 하락률 계산 |
| 2 | 하락률 조건으로 매수 후보 판단 |
| 3 | 실제 주문 대신 PAPER 주문 생성 |
| 4 | 주문 사유와 기준 가격을 상태 파일에 저장 |
| 5 | 실주문은 dry_run=False를 명시한 경우에만 검토 |
자동매매를 만들다 보면 빨리 주문까지 연결하고 싶어집니다. 저도 그랬습니다. 그런데 막상 코드를 오래 굴릴수록, 주문보다 기록이 먼저라는 생각이 강해집니다.
기록이 있어야 신호를 믿을 수 있고, 신호를 믿을 수 있어야 주문을 검토할 수 있습니다.
다음 글에서는 이 흐름을 한 단계 더 넓혀서 WebSocket으로 실시간 체결과 호가 데이터를 받아보겠습니다. 현재가를 한 번 조회하는 방식에서 벗어나, 장중에 가격이 움직이는 모습을 프로그램이 계속 따라가게 만드는 단계입니다.
참고 자료
- 토스증권 Open API 문서
- 토스증권 OpenAPI latest openapi.json
- Python dataclasses 공식 문서
- Python json 공식 문서
- 프로젝트 기준:
tossinvestsdk/strategy/dry_run.py,tests/test_dry_run_config.py
Leave a comment