PRD|자동매매봇 PRD]의 기술 설계를 정의한다. PRD가 정한 전제는 셋이다 — ① 검증된 엣지가 없으므로 목적은 수익이 아니라 집행 인프라 확보와 실시간 갭 측정, ② 토스에 sandbox가 없으므로 paper 안전성은 우리 코드가 유일한 방어선, ③ 리스크 사다리로 단계적 확대하되 승급 트리거는 수익률이 아닌 운영 지표.
이 설계의 최우선 목표는 “돈을 잘 버는 구조”가 아니라 **“틀렸을 때 손실이 설계된 한도를 넘지 않는 구조”**다.
Overview
무엇을: backend에 autotrading 도메인을 신설해 신호 → 리스크 게이트 → 주문 집행 → 체결·손익 추적 루프를 만든다.
왜: 신호는 있으나 집행 경로가 없어 신호 품질을 측정할 수 없고, 손실을 끊는 장치가 없다.
어떻게: 기존 signal·stock·order·account 도메인을 application 레이어에서 조합하고, paper/live 집행을 전략 패턴으로 분리한다. PaperTradeExecutor는 토스 Gateway를 주입받지 않아 실주문이 타입 수준에서 불가능하다.
Terminology
용어
정의
Proposal(제안)
신호·사이징·리스크 판정을 거친 주문 후보. 거부된 것도 감사 목적으로 전량 기록한다
RiskGate(리스크 게이트)
모든 주문이 반드시 통과하는 필터. 우회 경로가 없다
RiskTier(사다리 단계)
종목당 비율·보유 수·일일 한도를 묶은 세트 (T1/T2/T3)
ExecutionMode(실행 모드)
PAPER(모의 체결 기록) / LIVE(토스 실주문)
OCO
One-Cancels-Other. 손절가·목표수익률을 쌍으로 걸어 한쪽 체결 시 다른 쪽이 취소되는 토스 조건부 주문
KillSwitch(킬 스위치)
신규 주문 차단 + 미체결 취소 + 대기 제안 무효화를 1초 내 수행하는 즉시 정지 장치
TradingDay(거래일)
자동매매의 하루 단위 상태. 당일 실현손익·집행 건수·중단 여부를 보유
Slippage(슬리피지)
의도 가격과 실제 체결가의 차이
Define Problem
AS-IS
실제 코드 근거는 다음과 같다.
신호는 있다. 4팩터 합성은 ml/app/signal.py#combine이 수행하고, 결과는 signal_snapshots 테이블(symbol UNIQUE, signal_json·refreshed_at)에 적재된다. 조회는 GetSignalSnapshotUseCase.
백테스트한 전략 정의를 그대로 실전 예약매매로 배포. 검증한 것과 집행하는 것이 같은 정의를 공유
백테스트(ml/app/cost_model.py)와 페이퍼 체결이 동일한 비용 파라미터를 쓰게 단일화
전략 마켓플레이스·전략 공유는 미참고 — 사용자 1명 시스템
Possible Solutions
방안 1 — 실행 주체를 어디에 둘 것인가
방안
설명
왜 채택 / 미채택
backend 단독 (채택)
autotrading 도메인·스케줄러·집행을 전부 backend(:50000)에 둔다
주문·포지션·리스크는 강한 일관성이 필요하고, 필요한 데이터(signal_snapshots·orders·accounts·stock_price_cache)와 토스 주문 Gateway가 전부 backend 소유다. 네트워크 홉이 없어 킬 스위치가 같은 프로세스 안에서 즉시 반영된다 (PRD NFR 1초)
worker 오케스트레이션
worker(:50002)가 주기 트리거하고 backend API를 호출
미채택. ADR-008/010의 worker 역할(stateless 오케스트레이션)에는 부합하나, 집행은 stateless가 아니다. 홉이 늘면 킬 스위치 전파·주문 멱등·부분 실패 처리가 전부 분산 문제가 된다. 지금 규모에 과한 방안
ml 계산 위임
사이징·리스크 판정을 ml(:50003)에 맡긴다
미채택. 사이징·게이트는 산술 몇 줄이지 ML이 아니다. 홉만 늘고 장애점이 생긴다
방안 2 — 비용 모델 중복을 어떻게 다룰 것인가
ml/app/cost_model.py가 이미 있는데 Kotlin에도 필요하다.
방안
설명
왜 채택 / 미채택
Kotlin 재구현 + 파라미터 단일화 (채택)
TradingCostModel 값 객체를 Kotlin에 두되, 수수료율·거래세율·슬리피지율 3개 파라미터를 설정으로 단일 출처화하고 동일 픽스처 교차 검증 테스트로 드리프트를 막는다
원본이 43줄·3파라미터·분기 없는 순수 함수다. 이걸 위해 HTTP 홉을 만드는 것이 훨씬 비싸다. 드리프트 위험은 교차 검증 테스트로 상쇄된다
ml에 HTTP 위임
체결마다 ml을 호출
미채택. 체결 경로에 네트워크 장애점을 넣는다. 산술 한 줄에 홉을 붙이는 것은 과설계
공유 라이브러리 추출
언어 중립 계산 모듈
미채택. 언어가 달라 실질적으로 불가능하거나 과도한 인프라를 요구한다
방안 3 — paper 격리를 어떻게 보장할 것인가
토스에 sandbox가 없어 같은 자격증명·같은 엔드포인트로 실주문이 나간다.
방안
설명
왜 채택 / 미채택
의존성 분리 (채택)
TradeExecutor 인터페이스의 두 구현. PaperTradeExecutor는 OrderGateway·ConditionalOrderGateway를 생성자에서 받지 않는다
freqtrade의 dry-run이 API 키 없이 도는 것과 같은 원리. 실주문이 “설정 실수로 나갈 수 있는 것”이 아니라 참조할 대상이 없어 컴파일되지 않는 것이 된다
autotrading은 다른 도메인의 테이블을 직접 읽지 않는다 (no-crosscontext-raw-read). 조회는 소유 도메인의 DomainService를 거치고, application 매퍼가 autotrading의 값 객체로 변환한다.
인터페이스 시그니처
domain — Repository
interface AutoTradingPolicyRepository { fun load(): AutoTradingPolicy // 싱글톤 1행. 없으면 기본값(PAPER/T1/killSwitch off)으로 생성 fun save(policy: AutoTradingPolicy): AutoTradingPolicy}interface TradingDayRepository { fun findBy(tradeDate: LocalDate): TradingDay? fun save(tradingDay: TradingDay): TradingDay}interface OrderProposalRepository { fun save(proposal: OrderProposal): OrderProposal fun saveAll(proposals: List<OrderProposal>): List<OrderProposal> fun findBy(proposalId: Long): OrderProposal? fun findAllPending(): List<OrderProposal> fun findAllIn(tradeDate: LocalDate): List<OrderProposal>}interface TradingPositionRepository { fun findAllOpen(mode: ExecutionMode): List<TradingPosition> fun findOpenBy(symbol: String, mode: ExecutionMode): TradingPosition? fun save(position: TradingPosition): TradingPosition}interface TradingFillRepository { fun save(fill: TradingFill): TradingFill fun findAllIn(tradeDate: LocalDate, mode: ExecutionMode): List<TradingFill>}
domain — Gateway
/** 토스 OCO 조건부 주문. order 도메인 소유 (신규 파일 — 기존 OrderGateway 미수정) */interface ConditionalOrderGateway { /** 손절가·목표수익률을 쌍으로 등록하고 conditionalOrderId 를 반환한다. */ fun placeOcoOrder(spec: OcoOrderSpec): String fun cancelConditionalOrder(accountSeq: Int, conditionalOrderId: String)}interface AutoTradingAlertGateway { fun notifyExecuted(fill: TradingFill) fun notifyProposalPending(proposal: OrderProposal) fun notifyHalted(reason: TradingHaltReason, tradingDay: TradingDay) fun notifyTierChanged(from: RiskTier, to: RiskTier, reason: String)}
domain — 집행 전략
interface TradeExecutor { fun mode(): ExecutionMode fun buy(proposal: OrderProposal, marketPrice: BigDecimal): TradingFill fun sell(position: TradingPosition, marketPrice: BigDecimal, reason: SellReason): TradingFill}
PaperTradeExecutor(costModel: TradingCostModel) — 외부 Gateway 의존 없음. 모의 체결만 만든다.
data class RunAutoTradingCycleCommand(val tradeDate: LocalDate)data class ApproveOrderProposalCommand(val proposalId: Long)data class ToggleKillSwitchCommand(val enabled: Boolean)data class AutoTradingStatusResponse( val executionMode: ExecutionMode, val riskTier: RiskTier, val killSwitchEnabled: Boolean, val tradingCapital: BigDecimal, val openPositionCount: Int, val dailyRealizedPnl: BigDecimal, val dailyLossLimitUsageRate: BigDecimal, val halted: Boolean,)
클래스 역할 정의
도메인 모델
클래스명
역할
핵심 책임
AutoTradingPolicy
자동매매 정책 (싱글톤 1행)
실행 모드·킬 스위치·현재 사다리 단계 보유. enableKillSwitch()·promoteTier()·demoteTier()로 상태 전이. canPlaceNewOrder() 질의
재시도하지 않는다.fetchOpenOrders(accountSeq)로 접수 여부를 확인한다. 확인 불가 시 해당 종목을 당일 후보에서 제외하고 error-alert 발송 (PRD FR-20)
429 (ORDER 그룹 10 TPS)
자체 스로틀 8 TPS. 기존 TossRateLimiterFacade 재사용. 초과분은 다음 사이클로 이월
OCO 등록 실패
해당 포지션을 즉시 시장가 청산(SellReason.OCO_REGISTRATION_FAILED). 손절 없는 포지션을 보유하지 않는다 (PRD FR-5)
중복 주문
clientOrderId = "AT-{proposalId}" 멱등 키. 같은 제안은 두 번 집행되지 않는다
사이클 중복 실행
RunAutoTradingCycleUseCase에 @Transactional + trading_days 행 비관적 락. 이전 사이클 미완료 시 즉시 반환
킬 스위치 경합
정책은 단일 행. 집행 직전 policy.canPlaceNewOrder()를 트랜잭션 내에서 재확인한다
프로세스 재기동
상태가 전부 DB에 있어 30초 내 복구. 미체결 주문은 SyncPositionFillsUseCase가 토스 조회로 재동기화
부분 체결
TradingPosition.applyBuyFill()이 누적 반영. OCO 수량은 체결 수량 기준으로 등록
휴장·시간 외
GET /api/v1/market-calendar/KR 조회. 장 마감 5분 전부터 신규 진입 중단
통화 혼재
유니버스를 KRX(6자리 숫자 심볼)로 한정해 회피 (PRD FR-16·FR-37)
이벤트 아키텍처 판단
이벤트를 도입하지 않는다. ApplicationEvent(Layer 1)·Kafka(Layer 2) 모두 미사용이다.
근거 — 자동매매 사이클은 신호 조회부터 체결 기록까지가 하나의 트랜잭션 경계 안에서 강한 일관성을 요구한다. 리스크 한도 소진량과 주문 집행이 비동기로 갈리면 한도를 넘긴 집행이 가능해진다. 도메인 간 결합을 끊을 이유(무관한 도메인·비동기 허용)가 없고, 소비자도 없다. private-be-architecture-rule 기준 Layer 1·2 어느 쪽 조건도 성립하지 않는다.
향후 알림을 비동기화할 필요가 생기면 그때 Layer 1(@TransactionalEventListener AFTER_COMMIT)을 검토한다. 지금은 AutoTradingAlertGateway 동기 호출로 충분하다.
FE 영향 분석
1차 범위에서 FE 변경은 없다. M1~M2는 스케줄러와 REST 관리 API만으로 동작하며, 화면 없이 Discord 알림으로 관측한다.
항목
영향
기존 화면
없음 — 기존 API 계약을 변경하지 않는다
신규 API
GET /api/v1/autotrading/status·GET /api/v1/autotrading/proposals·POST /api/v1/autotrading/proposals/{id}/approve·POST /api/v1/autotrading/kill-switch. 전부 신규 경로라 충돌 없음
화면 필요 시점
PRD FR-26(제안 조회·승인 화면)은 P1 · M3. paper 전건 자동 구간에서는 승인 화면이 필요 없다
PaperTradeExecutor에서 Toss Open API로 가는 간선이 없다 — 이것이 PRD FR-35의 구조적 보장이다.
Sequence Diagram
sequenceDiagram
participant Sch as Scheduler
participant UC as RunAutoTradingCycleUseCase
participant Sig as signal DomainService
participant Svc as AutoTradingDomainService
participant Gate as RiskGate
participant Exec as TradeExecutor
participant Alert as AlertGateway
Sch->>UC: execute(tradeDate)
UC->>Sig: 최신 시그널 스냅샷 조회
Sig-->>UC: 신호 목록
UC->>Svc: runCycle(candidates)
Svc->>Gate: evaluate(candidate, policy, tradingDay, positions)
Gate-->>Svc: RiskDecision (통과 / 거부사유)
Svc->>Exec: buy(proposal, marketPrice)
Exec-->>Svc: TradingFill
Svc->>Alert: notifyExecuted(fill)
ERD
erDiagram
auto_trading_policy ||--o{ order_proposals : governs
trading_days ||--o{ order_proposals : contains
order_proposals ||--o| trading_fills : produces
trading_positions ||--o{ trading_fills : accumulates
auto_trading_policy {
bigint id PK
varchar execution_mode
varchar risk_tier
boolean kill_switch_enabled
decimal trading_capital
}
trading_days {
bigint id PK
date trade_date UK
decimal realized_pnl
boolean halted
varchar halt_reason
}
order_proposals {
bigint id PK
date trade_date
varchar symbol
varchar status
int quantity
decimal intended_price
varchar reject_reason
}
trading_positions {
bigint id PK
varchar symbol
varchar execution_mode
int quantity
decimal average_price
decimal stop_price
decimal target_price
varchar oco_order_id
}
trading_fills {
bigint id PK
bigint position_id FK
varchar execution_mode
varchar side
decimal fill_price
decimal realized_pnl
}
컬럼 상세·인덱스는 DB 설계 문서에서 확정한다. trading_positions는 (symbol, execution_mode, closed_at IS NULL) 조합으로 열린 포지션 유일성을 보장한다.
금액 컬럼은 전부 decimal — stock_price_cache.last_price가 varchar(50)인 전례를 반복하지 않는다.
Testing Plan
TDD 순서(RED → GREEN → REFACTOR)를 강제한다. 프레임워크는 Kotest.
레벨
범위
핵심 케이스
domain
RiskGate·RiskTier·PositionSizer·TradingCostModel·Entity 상태 전이
종목당 한도 초과 거부 / 보유 수 초과 거부 / 1주 미달 종목 거부 / 일일 중단 시 전건 거부 / 킬 스위치 시 전건 거부 / T2→T1 강등이 -5%에서 발동 / 만료 제안 승인 불가 / 종결 상태 재전이 거부
domain
TradingCostModel교차 검증
ml/app/cost_model.py와 동일 입력 → 동일 출력. 매수 실효가 상승·매도 실효가 하락·수량 0 처리
application
UseCase
DomainService 모킹. 사이클 중복 실행 시 즉시 반환 / 승인 흐름 / 킬 스위치 토글