지난 글에서 프레임워크의 전체 구조와 BaseStrategy, Signal을 다뤘습니다. 이번 글에서는 그 뼈대 위에 얹히는 핵심 모듈 세 가지를 구현합니다.

  • KISBroker — 증권사 API를 하나로 감싸기
  • FundManager — 자금 중복 사용 방지
  • StrategyLoader — 전략 플러그인 시스템

1. KISBroker — 증권사 API를 하나로 감싸기

문제: API 파일이 5개

4편에서 api/ 디렉토리를 소개했습니다. 인증, 주문, 차트, 계좌, 시장 정보가 각각 다른 파일에 있습니다:

api/
├── kis_auth.py         # 인증
├── kis_order_api.py    # 주문
├── kis_chart_api.py    # 차트
├── kis_account_api.py  # 계좌
└── kis_market_api.py   # 시장 정보

전략에서 이걸 직접 쓰면 어떻게 될까요?

# ❌ 전략이 API를 직접 호출하면...
from api import kis_auth, kis_account_api, kis_market_api, kis_order_api

class MyStrategy:
    def generate_signal(self, stock_code, data):
        # 잔고 확인하려면 어떤 모듈이지?
        balance = kis_account_api.get_balance()  # 아 이거였나?
        # 아니면 이건가?
        balance = kis_market_api.get_account_balance()  # 이건가?
        # 현재가는?
        price = kis_market_api.get_current_price(stock_code)
        # 주문은?
        kis_order_api.place_order(...)

전략을 만들 때마다 “이 기능이 어떤 API 파일에 있더라?” 하고 찾아야 합니다. 게다가 API 모듈 구조가 바뀌면 모든 전략을 수정해야 하죠.

해결: KISBroker가 다 감싼다

# ✅ KISBroker를 통해 호출
class MyStrategy:
    def generate_signal(self, stock_code, data):
        balance = self._broker.get_account_balance()
        holdings = self._broker.get_holdings()
        cash = self._broker.get_available_cash()

전략은 self._broker만 알면 됩니다. 내부에서 어떤 API 모듈을 호출하는지, 인증 토큰은 어떻게 관리되는지 전략은 모릅니다. (알 필요도 없습니다)

KISBroker 구조

class KISBroker(BaseBroker):
    """한국투자증권 API 래퍼"""

    async def connect(self) -> bool:
        """API 인증 + 초기화"""
        from api import kis_auth, kis_account_api, kis_market_api

        if not kis_auth.auth():
            return False

        self._connected = True
        return True

    def get_account_balance(self) -> dict:
        """계좌 잔고 조회"""
        balance_info = self._kis_market_api.get_account_balance()
        return {
            'total_balance': balance_info.get('total_value', 0),
            'available_cash': balance_info.get('available_amount', 0),
            'invested_amount': balance_info.get('purchase_amount', 0),
            'total_profit_loss': balance_info.get('total_profit_loss', 0),
            # ...
        }

    def get_holdings(self) -> List[dict]:
        """보유 종목 조회"""
        return self._kis_market_api.get_existing_holdings()

    def get_available_cash(self) -> float:
        """주문 가능 금액 조회"""
        # ...

connect()가 호출되면 내부에서 kis_auth.auth()로 토큰을 발급받고, 이후 모든 API 호출에 이 토큰을 자동으로 사용합니다. 전략은 connect() 이후에 get_account_balance(), get_holdings() 같은 메서드만 호출하면 됩니다.

BaseBroker — 추상화의 이유

KISBrokerBaseBroker라는 추상 클래스를 상속합니다:

class BaseBroker(ABC):
    @abstractmethod
    async def connect(self) -> bool: ...

    @abstractmethod
    def get_account_balance(self) -> dict: ...

    @abstractmethod
    def get_holdings(self) -> List[dict]: ...

    @abstractmethod
    def get_available_cash(self) -> float: ...

“지금 한국투자증권만 쓰는데 왜 추상화하나?” 싶을 수 있습니다. 하지만 나중에 다른 증권사 API로 바꿀 때 빛을 발합니다:

# 한국투자증권
broker = KISBroker()

# 나중에 다른 증권사로 바꾸고 싶으면?
# broker = KiwoomBroker()  # 인터페이스가 같으니 전략 코드 변경 없음

전략 코드는 BaseBroker 인터페이스만 알기 때문에, 증권사를 바꿔도 전략을 수정할 필요가 없습니다.


2. FundManager — 자금 중복 사용 방지

문제: 같은 돈을 두 번 쓸 뻔 했다

이건 실제로 겪은 문제입니다. 자동매매 루프에서 종목 A와 종목 B에 거의 동시에 매수 신호가 나왔습니다:

10:01:03.100  종목 A 매수 신호! 가용 자금: 1,000,000원 → 500,000원 매수
10:01:03.150  종목 B 매수 신호! 가용 자금: 1,000,000원 → 500,000원 매수
                                         ↑ 아직 A 주문이 반영 안 됨!

0.05초 차이로 두 종목이 같은 자금을 봤습니다. 결과: 100만 원으로 100만 원어치를 주문하는 상황. (실제로는 두 번째 주문이 증거금 부족으로 실패하지만, 주문 실패 처리 로직이 복잡해집니다)

해결: reserve → confirm → release

FundManager는 예약 시스템으로 이 문제를 해결합니다. 식당 좌석 예약과 비슷합니다:

┌──────────────────────────────────────────────────────┐
│ 총 자금: 1,000,000원                                  │
│                                                       │
│ ┌─────────────┐  ┌─────────────┐  ┌─────────────┐   │
│ │  가용 자금   │  │  예약 자금   │  │  투자 자금   │   │
│ │  500,000원  │  │  500,000원  │  │     0원     │   │
│ └─────────────┘  └─────────────┘  └─────────────┘   │
│                                                       │
│ 종목 A 주문 → 50만원 "예약" → 가용에서 예약으로 이동   │
│ 종목 B 주문 → 50만원 남음 → 정확한 잔고로 판단         │
└──────────────────────────────────────────────────────┘

코드로 보면:

class FundManager:
    def __init__(self, initial_funds=0, max_position_count=20):
        self._lock = threading.RLock()  # 스레드 안전!

        self.total_funds = initial_funds
        self.available_funds = initial_funds
        self.reserved_funds = 0.0
        self.invested_funds = 0.0

        # 주문별 예약 추적
        self.order_reservations: Dict[str, float] = {}

핵심은 threading.RLock()입니다. 여러 종목의 매수 신호가 동시에 들어와도, 한 번에 하나의 스레드만 자금을 건드릴 수 있습니다.

3단계 자금 흐름

1단계: reserve (예약)

def reserve_funds(self, order_id: str, amount: float) -> bool:
    with self._lock:
        if self.available_funds < amount:
            return False  # 자금 부족

        self.available_funds -= amount
        self.reserved_funds += amount
        self.order_reservations[order_id] = amount
        return True

주문을 넣기 전에 자금을 예약합니다. 이 순간부터 다른 종목은 이 금액을 사용할 수 없습니다.

2단계: confirm (체결 확인)

def confirm_order(self, order_id: str, actual_amount: float) -> None:
    with self._lock:
        reserved = self.order_reservations[order_id]
        self.reserved_funds -= reserved
        self.invested_funds += actual_amount
        del self.order_reservations[order_id]

        # 예약과 실제 체결 금액의 차액 정산
        diff = reserved - actual_amount
        if diff > 0:
            self.available_funds += diff  # 남은 돈 환불

주문이 체결되면 예약 → 투자로 전환합니다. 지정가 주문은 예약 금액과 실제 체결 금액이 다를 수 있어서, 차액을 자동으로 정산합니다.

3단계: release (매도 회수) 또는 cancel (주문 취소)

def release_investment(self, amount: float, stock_code: str = "") -> None:
    with self._lock:
        self.invested_funds -= amount
        self.available_funds += amount

def cancel_order(self, order_id: str) -> None:
    with self._lock:
        reserved = self.order_reservations[order_id]
        self.reserved_funds -= reserved
        self.available_funds += reserved
        del self.order_reservations[order_id]

매도하면 투자 → 가용으로, 주문 취소하면 예약 → 가용으로 돌려줍니다.

추가 안전장치들

FundManager에는 자금 관리 외에도 몇 가지 안전장치가 있습니다:

종목당 투자 한도:

def get_max_buy_amount(self, stock_code: str) -> float:
    with self._lock:
        max_per_stock = self.total_funds * self.max_position_ratio  # 9%
        max_total = self.total_funds * self.max_total_investment_ratio  # 90%
        remaining = max_total - self.invested_funds - self.reserved_funds

        return min(max_per_stock, remaining, self.available_funds)

총 자금 1,000만 원이면 종목당 최대 90만 원(9%), 전체 투자는 최대 900만 원(90%)까지. 세 가지 한도 중 가장 작은 값을 사용합니다.

동시 보유 종목 수 제한:

def can_add_position(self, stock_code: str) -> bool:
    with self._lock:
        if stock_code in self.current_position_codes:
            return True  # 이미 보유 중이면 분할매수 허용
        return len(self.current_position_codes) < self.max_position_count

매도 후 재매수 쿨다운:

# 매도 후 30분간 같은 종목 재매수 금지
self._sell_cooldowns: Dict[str, datetime] = {}
self.sell_cooldown_minutes = 30

급하게 손절했는데 바로 다시 사는 “복수 매매”를 방지합니다. (경험에서 나온 기능입니다 😅)


3. StrategyLoader — 전략 플러그인 시스템

문제: 전략을 추가할 때마다 코드를 수정해야 한다

프레임워크 없이 전략을 추가하면 이런 식입니다:

# ❌ 전략을 추가할 때마다 main.py를 수정
from strategies.breakout import BreakoutStrategy
from strategies.orb import ORBStrategy
from strategies.momentum import MomentumStrategy  # 새로 추가

strategy = MomentumStrategy()  # 매번 직접 연결

전략이 10개가 되면 import 10줄, 초기화 10줄… 그리고 전략 이름을 오타 내면 런타임 에러.

해결: 폴더만 추가하면 자동 발견

StrategyLoader는 strategies/ 디렉토리를 스캔해서 유효한 전략 폴더를 자동으로 찾아 로드합니다:

strategies/
├── base.py                 # BaseStrategy (건드리지 않음)
├── config.py               # StrategyLoader (건드리지 않음)
├── sample/                 # ← 폴더만 추가하면 됨!
│   ├── config.yaml         #   필수: 전략 설정
│   └── strategy.py         #   필수: 전략 코드
└── my_new_strategy/        # ← 새 전략도 폴더만 추가!
    ├── config.yaml
    └── strategy.py

전략 발견: discover_strategies()

class StrategyLoader:
    STRATEGIES_DIR = Path("strategies")

    @staticmethod
    def discover_strategies() -> Dict[str, Path]:
        strategies_dir = StrategyLoader.STRATEGIES_DIR
        discovered = {}

        for path in strategies_dir.iterdir():
            # 폴더이고, _로 시작하지 않고, 필수 파일이 있으면 전략
            if path.is_dir() and not path.name.startswith('_'):
                if StrategyLoader.validate_strategy(path):
                    discovered[path.name] = path

        return discovered

strategies/ 아래의 모든 폴더를 돌면서, config.yamlstrategy.py가 있으면 유효한 전략으로 인식합니다. _로 시작하는 폴더(예: __pycache__)는 무시합니다.

전략 검증: validate_strategy()

@staticmethod
def validate_strategy(strategy_path: Path) -> bool:
    if not strategy_path.is_dir():
        return False

    config_file = strategy_path / "config.yaml"
    strategy_file = strategy_path / "strategy.py"

    return config_file.exists() and strategy_file.exists()

두 파일이 모두 있어야 유효한 전략입니다. 하나라도 없으면 무시됩니다. 실수로 빈 폴더를 만들어도 에러가 나지 않습니다.

전략 로드: load_strategy()

@staticmethod
def load_strategy(strategy_name: str) -> BaseStrategy:
    # 1. 설정 로드
    config_loader = StrategyConfig(strategy_name)
    config = config_loader.load()  # config.yaml → dict

    # 2. 전략 클래스 동적 로드
    strategy_class = StrategyLoader._load_strategy_class(strategy_name)

    # 3. 인스턴스 생성 (설정 주입)
    return strategy_class(config)

세 단계로 이루어집니다:

  1. config.yaml을 읽어서 딕셔너리로 변환
  2. strategy.py에서 BaseStrategy를 상속한 클래스를 동적으로 찾아 로드
  3. 설정을 넣어서 인스턴스 생성

“동적으로 찾는다”는 건, strategy.py 안에서 클래스 이름이 Strategy로 끝나고 BaseStrategy를 상속한 클래스를 자동으로 찾는다는 뜻입니다:

# strategy.py 안에서 이런 클래스를 자동 발견
class SampleStrategy(BaseStrategy):   # ✅ 이름이 Strategy로 끝남 + BaseStrategy 상속
    ...

class Helper:                          # ❌ Strategy로 안 끝남 → 무시
    ...

StrategyConfig — YAML을 딕셔너리로

class StrategyConfig:
    def load(self) -> Dict[str, Any]:
        with open(self._config_path, 'r', encoding='utf-8') as f:
            return yaml.safe_load(f)

    def get(self, key: str, default=None):
        """점(.) 표기법으로 중첩 키 접근"""
        keys = key.split('.')
        value = self._config
        for k in keys:
            if isinstance(value, dict) and k in value:
                value = value[k]
            else:
                return default
        return value

YAML 파일을 Python 딕셔너리로 변환하고, 'risk_management.stop_loss_pct'처럼 점 표기법으로 중첩된 값에 접근할 수 있습니다:

config.get('risk_management.stop_loss_pct', 0.05)
# config.yaml의 risk_management: → stop_loss_pct: 값을 가져옴
# 없으면 기본값 0.05

새 전략 추가하기 — 실제 순서

새 전략을 추가하는 전체 순서입니다:

# 1. 폴더 생성
mkdir strategies/my_breakout

# 2. config.yaml 작성
cat > strategies/my_breakout/config.yaml << 'EOF'
strategy:
  name: "MyBreakout"
  version: "1.0.0"

parameters:
  breakout_period: 20
  volume_threshold: 1.5

risk_management:
  stop_loss_pct: 0.03
  take_profit_pct: 0.05
EOF

# 3. strategy.py 작성
# strategies/my_breakout/strategy.py
from strategies.base import BaseStrategy, Signal, SignalType

class MyBreakoutStrategy(BaseStrategy):
    name = "MyBreakout"
    version = "1.0.0"

    def on_init(self, broker, data_provider, executor):
        self._broker = broker
        self._data_provider = data_provider
        self._executor = executor
        self._is_initialized = True
        return True

    def generate_signal(self, stock_code, data, timeframe='daily'):
        # 여기에 돌파 로직 작성
        ...

    def on_order_filled(self, order): ...
    def on_market_open(self): ...
    def on_market_close(self): ...
# 4. 끝! 프레임워크가 자동으로 발견합니다

main.py를 수정할 필요 없습니다. StrategyLoader가 알아서 찾아줍니다.


4. 모든 것이 연결되는 순간

프로그램이 시작되면 이 세 모듈이 이런 순서로 엮입니다:

프로그램 시작 (main.py)
│
▼
┌────────────────────────────────────────┐
│ 1. KISBroker 연결                       │
│    broker = KISBroker()                 │
│    await broker.connect()               │
│    → API 인증, 토큰 발급                │
└──────────────────┬─────────────────────┘
                   ▼
┌────────────────────────────────────────┐
│ 2. FundManager 초기화                   │
│    fund_manager = FundManager()         │
│    balance = broker.get_account_balance()│
│    fund_manager.update_total_funds(     │
│        balance['available_cash'])       │
│    → 실제 계좌 잔고로 자금 설정          │
└──────────────────┬─────────────────────┘
                   ▼
┌────────────────────────────────────────┐
│ 3. StrategyLoader로 전략 발견 & 로드     │
│    strategies = StrategyLoader          │
│        .discover_strategies()           │
│    strategy = StrategyLoader            │
│        .load_strategy('sample')         │
│    → config.yaml 로드 + 클래스 인스턴스화│
└──────────────────┬─────────────────────┘
                   ▼
┌────────────────────────────────────────┐
│ 4. 전략에 모듈 주입                     │
│    strategy.on_init(                    │
│        broker=broker,                   │
│        data_provider=data_provider,     │
│        executor=executor                │
│    )                                    │
│    → 전략이 broker, data를 사용 가능     │
└──────────────────┬─────────────────────┘
                   ▼
            매매 루프 시작! 🚀

이 흐름이 4편에서 설명한 BaseStrategy 라이프사이클의 1~2단계(__init__on_init)에 해당합니다. 이후 장 시작(on_market_open) → 신호 생성(generate_signal) → 체결(on_order_filled) → 장 마감(on_market_close)으로 이어집니다.


마무리

이번 글에서 다룬 것을 정리하면:

  1. KISBroker: 5개 API 모듈을 하나의 인터페이스로 감싸서, 전략은 self._broker만 호출하면 됩니다. BaseBroker 추상화로 나중에 증권사를 바꿔도 전략 코드는 수정할 필요 없습니다.
  2. FundManager: reserve → confirm → release 3단계로 자금 중복 사용을 방지합니다. 스레드 락으로 동시 매수 신호에도 안전하고, 종목당 한도·전체 한도·재매수 쿨다운까지 관리합니다.
  3. StrategyLoader: strategies/ 폴더에 config.yaml + strategy.py만 넣으면 자동 발견·로드됩니다. main.py 수정 없이 새 전략을 추가할 수 있습니다.

이 세 모듈이 4편의 BaseStrategy와 만나면, 전략만 갈아끼우는 자동매매 프레임워크가 완성됩니다.

이 프레임워크는 GitHub에 공개되어 있습니다: kis-trading-template

참고 – 블로그 내 관련 글