Post

Quant Agent Lab - (1)

Quant Agent Lab - (1)

들어가며

ML 시그널과 LLM의 판단을 결합한 퀀트 트레이딩 에이전트를 직접 만들어보기로 했다. 거창한 자동매매 시스템이 목표는 아니다. 퀀트라는 분야를 코드로 부딪쳐가며 배우는 게 목적인 학습용 프로젝트다.

전체 로드맵은 이렇게 잡았다. 성과지표 같은 뼈대부터 시작해서, 데이터 수집과 백테스트, ML 시그널, 마지막으로 LLM 판단 계층까지 단계적으로 쌓는다. 이 글은 그 첫 단계, 아이디어를 잡고 개발 환경을 만들어 첫 코드를 올리기까지 의 기록이다. 같은 곳에서 또 헤매지 않기 위해 그 과정을 최대한 그대로 남기고자 한다.


왜 Python인가

필자는 평소 업무로 C#과 .NET을 쓰는 백엔드 개발자며, PS 와 CP는 Java를 사용한다.하지만 퀀트 개발은 사정이 달랐다.

스펙 결정 전 몇 가지 축으로 나눠서 따져봤다.

첫째, ML 연구 루프 다. 피처를 바꿔보고 모델을 갈아끼우고 결과를 그려보는 반복 실험에서는 Python이 압도적이다. 노트북 환경에서 데이터를 보고, 그리고, 셀 하나 고쳐 다시 돌리는 사이클이 빠르다. 무엇보다 학습 자료의 양이 비교가 안 된다.

둘째, 에이전트와 실행 엔지니어링 이다. 의외로 이 영역은 C#도 강하다. 거래소 연동은 Binance.Net 같은 라이브러리가 사실상 표준급으로 성숙해 있고, LLM 오케스트레이션은 Microsoft가 내놓은 Agent Framework가 .NET에서 다중 에이전트 패턴까지 지원한다. ML 모델 자체도 ML.NET이 LightGBM과 ONNX 연동을 갖추고 있다.

셋째, 적합성 이다. 지금은 Java와 C#이 익숙하지만 python으로 ML 개발경험이 있기에 배우는 비용 크게 차이가 없다고 판단했다.

넷째, 단순성 이다. 교과서적 정답은 폴리글랏이다. Python으로 모델을 학습시키고 ONNX로 내보낸 뒤 C#에서 실행하는 식. 하지만 언어가 둘이면 빌드와 디버깅이 두 배가 된다. “간단하게” 시작한다는 목표와 정면으로 부딪힌다.

종합한 결론은 이렇다. 프로그래밍이 처음인 게 아니라 도메인이 처음일 때, 가장 큰 가속은 그 분야의 공용어를 쓰는 데서 나온다. 퀀트의 공용어는 Python이다. 벽에 부딪힐 때마다 검색하면 그대로 복붙해서 돌려볼 수 있는 코드가 Python으로 쏟아진다. 즉 언어의 우열 문제가 아니라 학습 레버리지의 문제다.

C#을 못 쓴다는 뜻이 아니다. 전략에 확신이 서면 ML 모델만 ONNX로 내보내 기존 .NET 백엔드에서 실행할 수 있다. ML.NET이 ONNX를 1급으로 지원하니 그 다리는 이미 놓여 있다.


성과지표 만들기

환경을 만들기 전에 무엇을 만들지부터 정했다. 보통 데이터 수집부터 손대고 싶을 수 있는데, 외부 API와 네트워크라는 불확실성을 처음부터 끌어안는 일이기 때문에 끌리지 않았다.

대신 외부 의존성이 0인 순수 함수 하나 를 첫 단추로 골랐다. 성과지표, 즉 누적수익률과 샤프 지수를 계산하는 함수다. 이 작업부터 시작한다면 테스트가 즉시 돌고, 나중에 백테스트 결과를 평가할 때 반드시 쓰며, 실패하는 테스트부터 통과까지 가는 한 사이클로 개발 도구 전체가 제대로 작동하는지 한 번에 검증된다.

프로젝트 구조는 이렇게 잡았다.

1
2
3
4
5
6
7
8
9
10
quant-agent/
├── quantagent/
│   ├── __init__.py
│   └── metrics.py        # 성과지표 (순수 함수)
├── tests/
│   └── test_metrics.py   # 테스트
├── pyproject.toml        # pytest 설정 포함
├── requirements.txt      # 지금은 pytest 하나밖에없음
├── .gitignore
└── README.md

복리, 잘못된 예와 올바른 예

성과지표의 첫 함수는 누적수익률이다. 여기서 흔히 빠지는 함정이 있다. 기간별 수익률을 그냥 더하는 것이다.

1
2
3
4
5
# 잘못된 예: 수익률을 단순히 더한다
def total_return_wrong(returns):
    return sum(returns)
    # +50% 다음 -50%를 넣으면 0.5 + (-0.5) = 0.0
    # "본전"이라는 착각을 일으킨다

이게 왜 틀렸는지는 숫자를 따라가 보면 분명하다. 1000만 원이 있다고 하자. +50%면 1500만 원이 된다. 거기서 -50%면 750만 원이다. 본전이 아니라 250만 원을 잃었다. 따라서 올바른 계산은 더하기가 아니라 곱하기, 즉 복리다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
import math

# 일봉 기준 연율화 계수. 24시간 거래하는 코인이면 365를 쓴다.
TRADING_DAYS_PER_YEAR = 252


def total_return(returns):
    """기간별 수익률을 하나의 누적 수익률로 복리 계산한다.

    returns는 소수로 표현한 기간 수익률의 시퀀스다 (0.01 == +1%).
    """
    equity = 1.0
    for r in returns:
        equity *= 1.0 + r   # 매 기간 (1 + 수익률)을 곱해 자산을 누적
    return equity - 1.0     # +0.5 후 -0.5 → 0.75 → -0.25 (-25%)

total_return([0.5, -0.5])1.0 × 1.5 × 0.5 - 1.0, 즉 -0.25를 돌려준다. 단순 합산이 놓치는 손실을 정확히 잡아낸다.

샤프 지수, divide by zero 검증

두 번째 함수는 샤프 지수다. 수익률의 평균을 변동성으로 나눠 위험 대비 수익을 재는 지표다. 여기서 주의할 점 두 가지가 있다. 빈 입력과 변동성이 0인 경우다. 둘 다 그대로 두면 0으로 나누는 런타임 에러가 난다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
def sharpe_ratio(returns, risk_free=0.0, periods_per_year=TRADING_DAYS_PER_YEAR):
    """기간 수익률 시계열의 연율화 샤프 지수.

    모표준편차(N으로 나눔)를 사용한다. 빈 시계열이거나 변동성이 0이면
    0.0을 반환해, 호출하는 쪽이 0으로 나누기를 절대 만나지 않게 한다.
    """
    if not returns:
        return 0.0

    per_period_rf = risk_free / periods_per_year
    excess = [r - per_period_rf for r in returns]

    mean = sum(excess) / len(excess)
    variance = sum((x - mean) ** 2 for x in excess) / len(excess)
    std = math.sqrt(variance)
    if std == 0:
        return 0.0

    return (mean / std) * math.sqrt(periods_per_year)

표본표준편차(N-1로 나눔) 대신 모표준편차(N으로 나눔)를 택한 건 테스트 값을 결정적으로 만들기 위해서다. 학습 단계에선 이 정도 단순화가 낫다. 나중에 라이브러리로 옮길 때 정확한 정의를 다시 맞추면 된다.

RED에서 GREEN으로

TDD의 핵심은 실패하는 테스트를 먼저 보는 것 이다. 그래야 테스트가 진짜로 무언가를 검증하는지 확신할 수 있다. 그래서 구현을 비워둔 채 테스트부터 돌렸다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
import pytest

from quantagent.metrics import total_return, sharpe_ratio


def test_total_return_compounds():
    # +10% 후 +10%는 +20%가 아니라 +21%다. 이익이 복리로 붙기 때문이다.
    assert total_return([0.1, 0.1]) == pytest.approx(0.21)


def test_total_return_handles_loss_asymmetry():
    # +50% 후 -50%는 본전이 아니라 -25%. 테스트로 못박을 가치가 있는 함정.
    assert total_return([0.5, -0.5]) == pytest.approx(-0.25)


def test_sharpe_no_variation_is_zero():
    # 수익률이 일정하면 변동성이 0. 0으로 나누기를 막아야 한다.
    assert sharpe_ratio([0.01, 0.01, 0.01]) == 0.0

구현을 raise NotImplementedError로 비워두고 pytest를 돌리니 예상대로 전부 실패했다(RED). 그다음 위의 실제 구현을 채우고 다시 돌리니 초록불이 들어왔다(GREEN). 마지막으로 커밋까지 완료했다.

이 작은 사이클이 앞으로 모든 기능에 반복될 과정이다.

실패케이스를 먼저 확인하지 않으면, 테스트가 실제로는 아무것도 검증하지 않는데도 통과하는 가짜 성공에 빠질 수 있다. RED → GREEN → 커밋. 이 순서를 지키는 게 TDD의 전부다.


환경 구축 삽질기

실제로 시간을 꽤 잡아먹은 부분이다. 윈도우 환경에서 파이썬을 설치하는 귀찮은 과정도 담아봤다. 필자는 윈도우와 맥 둘 다 사용하는데 시간이 되면 맥도 추가로 정리해보겠다.

winget이 인식되지 않는다

파이썬을 설치 관리자로 깔끔하게 깔려고 winget을 쳤더니 이렇게 나왔다.

1
2
'winget'() 내부 또는 외부 명령, 실행할 수 있는 프로그램, 또는
배치 파일이 아닙니다.

패키지가 아예 없는 줄 알았다. 그런데 확인해보니 패키지 자체는 깔려 있었다.

1
2
(Get-AppxPackage Microsoft.DesktopAppInstaller).Version
# 1.28.240.0

즉 프로그램은 있는데 PATH가 없었던 것 이다. 실제 실행 파일은 디스크에 있었다.

1
2
Get-ChildItem "$env:LOCALAPPDATA\Microsoft\WindowsApps\winget.exe"
# winget.exe 가 존재함

일단 이번 세션에서만 임시로 PATH를 이어 winget을 살렸다. 그리고 파이썬 설치 관리자를 깔았다.

1
2
winget install --id Python.PythonInstallManager --source winget
# 설치 성공

--id--source winget을 붙인 이유는, 같은 이름이 Microsoft Store 쪽에도 있어서 winget이 어느 걸 깔지 되묻는 걸 막기 위해서다. 즉 모호함을 없애 한 번에 원하는 패키지로 보낸다.

크기가 0인 실행 파일의 정체

설치 후 새 창에서 py --version을 쳤는데 또 인식이 안 됐다. 파일이 있는지 다시 뒤졌더니 흥미로운 게 보였다.

1
2
Get-ChildItem "$env:LOCALAPPDATA\Microsoft\WindowsApps\*py*.exe"
# py.exe, pymanager.exe, python.exe ... 전부 Length 가 0

py.exe, python.exe가 다 있는데 크기(Length)가 전부 0 이었다. 처음엔 깨진 파일인 줄 알았다. 알고 보니 이건 윈도우의 앱 실행 별칭(App Execution Alias) 이라는 장치다. 진짜 실행 파일이 아니라, 실행 시점에 실제 앱으로 연결해주는 일종의 바로가기 지점이다. 그래서 크기가 0으로 보인다.

앱 실행 별칭이 동작하려면 두 가지가 필요하다. 하나는 윈도우 설정에서 그 별칭 스위치가 켜져 있는 것, 다른 하나는 별칭이 사는 폴더가 PATH에 등록돼 있는 것이다. 둘 중 하나라도 빠지면 명령이 인식되지 않는다.

별칭 스위치를 켰는데도 안 됐다. 그래서 PATH에 그 폴더가 들어있는지를 직접 확인했다.

1
2
$env:Path -split ';' | Select-String WindowsApps
# 아무것도 출력되지 않음

원인은 WindowsApps 폴더가 PATH에서 통째로 빠져 있었다. 별칭을 아무리 켜도 윈도우가 그 폴더를 찾지 못하니 소용이 없던 것이다.

여기서 임시 PATH 연결을 다시 쓸 수도 있었다. 하지만 임시방편이고 매번 다시 해야 한다. 정석은 사용자 환경 변수에 영구 등록 하는 것이다. GUI로 처리했다.

윈도우 키를 누르고 “환경 변수”를 검색해 “계정의 환경 변수 편집”을 연다. 위쪽 사용자 변수에서 Path를 선택해 편집을 누르고, 새 줄을 하나 추가해 아래 경로를 붙여넣는다.

1
C:\Users\<사용자명>\AppData\Local\Microsoft\WindowsApps

기존에 있던 경로 줄을 지우거나 덮어쓰면 안 된다. 새 줄 하나만 추가하는 것이다. 기존 경로를 건드리면 다른 프로그램이 인식되지 않을 수 있다.

저장하고 완전히 새 PowerShell 창 을 열어 확인했다.

1
2
py --version
# Python 3.13.13

드디어 떴다. 환경 변수는 새로 뜨는 창부터 적용되므로, 기존 창에서 계속 시도했다면 영영 안 됐을 것이다.

환경 변수를 바꾼 뒤에는 반드시 새 터미널 창을 열어야 한다. 이미 열려 있던 창은 옛 PATH를 그대로 들고 있다. “분명히 등록했는데 안 된다”의 9할은 이 이유다.


가상환경과 첫 초록불

이제 프로젝트 폴더 안에서 가상환경을 만든다. 가상환경은 이 프로젝트만의 독립된 파이썬 공간이다. .NET의 프로젝트별 패키지 복원과 비슷한 개념으로, 시스템 전역을 더럽히지 않는다.

1
py -m venv .venv

다음은 활성화인데, 여기서 PowerShell 실행 정책에 막히는 일이 흔하다. 스크립트 실행이 차단돼 Activate.ps1이 빨간 에러를 낸다. 이 세션에 한해 정책을 풀고 활성화하면 된다.

1
2
Set-ExecutionPolicy -Scope Process -ExecutionPolicy RemoteSigned
.venv\Scripts\Activate.ps1

성공하면 프롬프트 맨 앞에 (.venv)가 붙는다. 이 표시가 가상환경 안에 들어왔다는 신호다. 이 상태에서 패키지를 깔면 시스템이 아니라 .venv 안에만 들어간다.

1
2
pip install -r requirements.txt
# Successfully installed ... pytest-8.3.4

requirements.txt에는 지금 pytest 하나뿐이다. pandas나 scikit-learn은 실제로 쓰는 다음 단계에서 추가한다. 지금 필요 없는 걸 미리 깔지 않는다는 원칙, 즉 YAGNI다.

그리고 오늘의 목표.

1
pytest
1
2
3
collected 6 items
tests\test_metrics.py ......                              [100%]
6 passed in 0.02s

코드가 컴퓨터에서 그대로 잘 돈다는 증거다.


마치며

작은 사이클로 움직인다. 실패하는 테스트를 먼저 보고, 최소한의 코드로 통과시키고, 커밋한다. 한 번에 큰 걸 만들지 않는다.

다음 편에서는 데이터 계층과 백테스트의 뼈대를 만든다. 주식 가격 데이터를 불러와 검증하고 정리하는 부분부터, 네트워크에 의존하는 코드와 순수 로직을 어떻게 분리하는지를 다룬다.


References

This post is licensed under CC BY 4.0 by the author.