VPS에서 자체 호스팅 주식 리서치 에이전트 구축하기
Python과 DuckDB를 활용해 나만의 주식 리서치 에이전트를 구축하는 방법을 설명합니다. systemd timer를 이용한 자동 데이터 수집부터 Claude API를 통한 LLM 분석까지, 시장 데이터를 로컬에 저장하고 효율적으로 관리하는 전체 과정을 단계별로 안내합니다.
자체 호스팅 주식 리서치 에이전트란 무엇인가
자체 호스팅 주식 리서치 에이전트는 사용자가 소유한 서버에서 실행되는 소형 프로그램입니다. 이 프로그램은 주기적으로 시장 데이터를 가져와 로컬 데이터베이스에 저장하고, 필터링을 수행한 뒤 대규모 언어 모델(LLM)에 변경 사항에 대한 보고서 작성을 요청합니다. 이 에이전트는 데이터를 읽고 필터링하는 역할을 수행합니다. 이 프로그램은 직접 거래를 수행하지 않으며, 이 가이드의 어떠한 내용도 재무적 조언이 아닙니다.
두 사람이 이 시스템을 처음부터 구축하더라도 서로 다른 라이브러리를 선택할 수 있지만, 결국 동일한 네 가지 구성 요소로 귀결됩니다. 가격과 펀더멘털 데이터를 공급하는 피드, 지금까지 가져온 모든 행을 보관하는 로컬 저장소, 타이머에 맞춰 저장소를 갱신하는 작업, 그리고 선별된 데이터를 문장으로 변환하는 LLM 계층입니다. 이 가이드는 Python, DuckDB, systemd 타이머, 그리고 Claude API(애플리케이션 프로그래밍 인터페이스)를 사용하여 이러한 구조를 구축합니다. 이는 고정된 루프를 가진 제한적인 에이전트입니다. 만약 일반적인 버전의 에이전트를 먼저 접하고 싶다면, AI 에이전트 학습을 위한 단계별 경로를 통해 도메인 문제로 넘어가기 전에 루프, 도구, 메모리 개념을 순서대로 학습할 수 있습니다. 실행(트레이딩)은 별도의 작업이며 실패 모드 또한 다르므로, 이는 트레이딩 봇을 위해 설정된 VPS에서 다루어야 합니다.
네 가지 구성 요소와 각 역할
피드(The feed)는 외부 세계와 통신하는 유일한 부분입니다. 티커와 날짜 범위를 요청하는 방법과 행 데이터를 반환하는 방법을 알고 있습니다. 하위의 모든 요소는 피드가 아닌 데이터베이스를 읽으므로, 피드에 장애가 발생해도 화면이 깨지는 대신 하루치 신규 데이터만 누락됩니다.
저장소(The store)는 이 작업의 핵심 목적입니다. 기록하지 못한 일일 종가는 나중에 다시 가져올 수 있는 경우가 많습니다. 하지만 장중 시세, 수정 전의 추정치, 재작성 전의 펀더멘털 수치는 다시 가져올 수 없습니다. 저장소는 데이터가 발표된 당시에 실제로 어떤 내용을 담고 있었는지 기록을 구축하는 방법입니다.
스케줄러(The scheduler)는 새로고침 시점을 결정합니다. 서버 환경에서는 이것이 systemd timer이며, 코드보다 VPS가 더 중요한 이유가 바로 여기에 있습니다.
LLM 계층(The LLM layer)은 SQL이 생성한 짧은 텍스트 블록을 읽고 요약을 작성합니다. 이 계층은 데이터베이스에 직접 연결하거나 쿼리를 생성하지 않습니다. 만약 모델이 SQL을 직접 작성한다면, 잘못된 토큰 하나가 유창한 문장 속에서 잘못된 숫자로 변할 수 있으며 이를 대조할 방법이 없습니다. SQL이 숫자를 생성하고 모델이 산문만 작성하게 하면, 모델은 산문에서만 오류를 범할 수 있고 사용자는 전달된 행 데이터와 산문을 대조하여 검증할 수 있습니다.
노트북 대신 VPS에서 실행해야 하는 이유
스케줄러가 그 이유입니다. 뉴욕 시간으로 16:00에 마감되는 미국 시장은 베를린 시간으로 22:00, 자카르타 시간으로는 다음 날 04:00입니다. 노트북은 이 두 시간 모두 절전 모드 상태일 것입니다. 실행을 놓치면 늦은 알림보다 더 큰 비용이 발생합니다. 일봉 데이터는 보통 나중에 다시 가져올 수 있지만, 수정되는 데이터는 그렇지 않으므로 기록의 공백은 영구적으로 남습니다. 재부팅 후에도 스케줄과 저장된 상태를 유지해야 하는 모든 에이전트에게 동일한 논리가 적용되며, 이것이 바로 KiroCrew를 개인 VPS에서 상시 가동형 에이전트로 실행하는 것의 핵심입니다.
두 번째 이유는 규모는 작지만 여전히 실질적입니다. 서버는 하나의 API 키를 하나의 파일에 보관하며, 로그인 셸이 없는 단일 시스템 사용자가 소유하고, 하나의 작업에서만 사용합니다. 웹 서핑을 함께 하는 노트북에서 이런 환경을 구성하기는 훨씬 어렵습니다. 키를 코드나 모델의 입력값에 포함하지 마십시오. 이에 관한 내용은 AI 에이전트에서 API 키를 안전하게 관리하는 방법에서 다룹니다.
디스크, RAM, 토큰 용량 산정
디스크 용량 산정은 간단합니다. 일일 데이터는 티커당 거래일마다 한 행씩 생성되며, 미국 증시의 연간 거래일은 약 252일입니다.
이 차트의 데이터
[
{
"label": "20 tickers",
"rows_after_1y": "5,040",
"rows_after_10y": "50,400"
},
{
"label": "100 tickers",
"rows_after_1y": "25,200",
"rows_after_10y": "252,000"
},
{
"label": "500 tickers",
"rows_after_1y": "126,000",
"rows_after_10y": "1,260,000"
}
]티커 20개는 1년 후 5,040 행이 됩니다. 500 tickers 라인은 10년 후 1,260,000 행에 도달합니다. 각 행은 날짜와 몇 개의 double형 데이터로 구성되며, DuckDB는 열 단위로 압축하여 저장하므로 기가바이트 단위가 아닌 수십 메가바이트 수준입니다. 제 추정치를 포함하여 그 어떤 수치도 맹신하지 마십시오. 첫 번째 백필(backfill)을 마친 후 du -h /opt/research/data/market.duckdb을 실행하여 직접 확인한 수치를 사용하십시오.
RAM은 소규모 VPS에서 문제가 될 수 있습니다. DuckDB는 기본적으로 단일 쿼리를 처리할 때 시스템 메모리의 상당 부분과 모든 코어를 점유합니다. 이는 분석 전용 서버에는 적합하지만, 다른 서비스가 함께 실행되는 2 GB 용량의 서버에는 부적절합니다. 전체 가격 테이블에 대해 집계 연산을 수행하면 커널에 의해 프로세스가 강제 종료될 수 있으며, systemd는 Main process exited, code=killed, status=9/KILL를 보고하고 journalctl -k은 메모리 부족(OOM)으로 인한 종료를 보여줍니다. memory_limit과 threads를 명시적으로 설정하면 쿼리가 종료되는 대신 속도가 느려지게 됩니다.
토큰 사용량은 추정하지 말고 측정해야 합니다. 모든 Messages API 응답에는 input_tokens와 output_tokens를 포함하는 usage 객체가 포함되어 있습니다. 매 호출 시마다 이 두 값을 테이블에 기록하십시오. 일주일이 지나면 실제 사용량을 파악할 수 있으며, 여기에 확인 당일 모델의 단가를 곱하면 됩니다. 계획을 세울 때 고려할 만한 두 가지 사실이 있습니다. 모든 Claude 모델에서 출력 토큰은 입력 토큰보다 비싸게 책정되므로, 메모를 200단어로 제한하는 것이 전송하는 데이터를 줄이는 것보다 비용 절감 효과가 큽니다. 또한 프롬프트 캐싱은 하루 한 번 실행하는 작업에는 도움이 되지 않습니다. 캐시의 기본 유지 시간은 5분이며, Anthropic이 제공하는 최장 유지 시간은 1시간입니다. 1시간 캐시는 5분 캐시(1.25배)보다 비싼 기본 입력 가격의 2배를 지불해야 합니다. 어느 쪽이든 다음 날 작업이 시작될 때 캐시된 블록은 이미 만료되므로 전체 입력 가격을 다시 지불해야 합니다. 캐싱은 한 번의 작업에서 동일한 대용량 텍스트 블록을 반복적으로 호출할 때 비용 효율적입니다.
구성 요소 설치
sudo apt update
sudo apt install -y python3-venv
sudo useradd --system --create-home --home-dir /opt/research --shell /usr/sbin/nologin research
sudo -u research python3 -m venv /opt/research/venv
sudo -u research /opt/research/venv/bin/pip install duckdb pandas yfinance anthropic
sudo install -d -o research -g research -m 750 /opt/research/data코드를 작성하기 전에 설치 상태를 확인합니다:
sudo -u research /opt/research/venv/bin/python -c 'import duckdb, yfinance, anthropic; print("ok")'이 명령은 ok을 출력합니다. 가상 환경을 건너뛰고 시스템 Python에 대해 pip install를 실행했다면, Ubuntu 24.04는 error: externally-managed-environment 오류를 발생시키며 작업을 중단합니다. 이는 /usr/lib/python3 경로의 소유권이 배포판에 있으며, pip가 해당 경로에 쓰기 작업을 수행하는 것을 허용하지 않기 때문입니다. 가상 환경(venv)은 예의상 사용하는 것이 아닙니다. pip가 접근할 수 있는 유일한 디렉터리입니다.
API 키는 서비스 사용자가 읽을 수 있고 다른 사용자는 접근할 수 없는 파일에 저장해야 합니다:
sudo install -d -m 755 /etc/research
sudo install -m 640 -o root -g research /dev/null /etc/research/env
sudoedit /etc/research/env파일에 한 줄을 입력합니다. 따옴표나 export은 포함하지 마십시오. systemd는 이 파일을 셸에 전달하지 않고 직접 해석하기 때문입니다:
ANTHROPIC_API_KEY=sk-ant-your-key-here저장소: 두 개의 테이블
# /opt/research/store.py
import duckdb
DB = '/opt/research/data/market.duckdb'
SCHEMA = [
"""
CREATE TABLE IF NOT EXISTS prices (
ticker VARCHAR,
day DATE,
open DOUBLE,
high DOUBLE,
low DOUBLE,
close DOUBLE,
volume BIGINT,
PRIMARY KEY (ticker, day)
)
""",
"""
CREATE TABLE IF NOT EXISTS runs (
started_at TIMESTAMPTZ,
model VARCHAR,
input_tokens BIGINT,
output_tokens BIGINT,
hits BIGINT
)
""",
]
def connect(read_only=False):
con = duckdb.connect(DB, read_only=read_only)
con.execute("SET memory_limit='512MB'")
con.execute('SET threads=2')
if not read_only:
for statement in SCHEMA:
con.execute(statement)
return con(ticker, day)의 기본 키(primary key)는 새로고침을 반복해도 안전하게 유지하는 핵심 요소입니다. INSERT OR REPLACE은 해당 티커와 날짜에 이미 존재하는 행을 덮어쓰므로, 백필(backfill)을 두 번 실행해도 테이블 데이터가 중복되지 않습니다. 기본 키가 없다면, 충돌 후 재실행 시 모든 바(bar) 데이터가 조용히 중복되며, 이후 계산하는 모든 평균값은 오류 메시지 없이 잘못된 결과로 이어집니다.
DuckDB는 단 하나의 프로세스만 파일 쓰기 권한을 가질 수 있도록 허용합니다. 두 번째 쓰기 프로세스는 즉시 Could not set lock on file 오류와 함께 실패하며, 이때 해당 파일을 점유 중인 PID가 함께 표시됩니다. 실제 환경에서는 다른 터미널에 열어둔 대화형 duckdb 셸이 원인인 경우가 많습니다. 읽기 전용 연결로도 이 문제를 피할 수는 없습니다. DuckDB의 동시성 문서에서는 읽기와 쓰기를 모두 수행하는 프로세스 하나를 두거나, 읽기만 수행하는 여러 프로세스를 두는 두 가지 모드를 설명하며, 이 둘을 혼합하는 것은 지원하지 않습니다. connect은 read_only 플래그를 사용하여 여러 읽기 프로세스가 파일을 공유할 수 있게 하지만, 이는 쓰기 프로세스가 파일을 열고 있지 않을 때만 작동합니다. 여러 프로세스가 동시에 쓰기 작업을 수행해야 한다면 이는 다른 엔진의 영역입니다. SQLite는 WAL 모드에서 쓰기 프로세스가 커밋하는 동안에도 읽기 작업을 허용하며, busy timeout 설정을 통해 다른 쓰기 프로세스가 실패하는 대신 대기하도록 만들 수 있습니다. 서버 워크로드를 위한 DuckDB와 SQLite 비교에서 두 엔진을 비교하고, VPS에서 SQLite 운영하기에서 WAL 모드를 안정적으로 설정하는 방법을 다룹니다.
갱신 작업
# /opt/research/refresh.py
import sys
import pandas as pd
import yfinance as yf
from store import connect
TICKERS = ['AAPL', 'MSFT', 'KO', 'SAP', 'TSM']
FIRST_DAY = '2016-01-01'
COLS = ['ticker', 'day', 'open', 'high', 'low', 'close', 'volume']
def fetch(ticker, start):
df = yf.Ticker(ticker).history(start=start, auto_adjust=True)
if df.empty:
return None
df = df.reset_index()
stamps = pd.to_datetime(df['Date'])
if stamps.dt.tz is not None:
stamps = stamps.dt.tz_localize(None)
df['day'] = stamps.dt.date
df['ticker'] = ticker
df = df.rename(columns={'Open': 'open', 'High': 'high', 'Low': 'low',
'Close': 'close', 'Volume': 'volume'})
return df[COLS]
def main():
con = connect()
empty = 0
for ticker in TICKERS:
last = con.execute('SELECT max(day) FROM prices WHERE ticker = ?',
[ticker]).fetchone()[0]
rows_df = fetch(ticker, str(last) if last else FIRST_DAY)
if rows_df is None:
print(ticker + ': feed returned no rows', file=sys.stderr)
empty += 1
continue
con.register('rows_df', rows_df)
con.execute('INSERT OR REPLACE INTO prices '
'SELECT ticker, day, open, high, low, close, volume FROM rows_df')
print(ticker + ': ' + str(len(rows_df)) + ' rows')
con.close()
if empty == len(TICKERS):
print('every ticker returned nothing: the feed is broken', file=sys.stderr)
sys.exit(1)
main()수동으로 한 번 실행합니다:
sudo -u research /opt/research/venv/bin/python /opt/research/refresh.py첫 실행 시에는 수년간의 데이터를 채워 넣으므로 티커당 수천 건의 행이 출력됩니다. 1분 뒤 다시 실행하면 이미 저장된 마지막 날짜부터 작업을 시작하므로 각 줄에 1~2개의 행만 표시됩니다. 이 두 번째 실행이 실제 테스트입니다. 만약 여전히 수천 건이 출력된다면 max(day)가 아무것도 반환하지 않는 것이며, 매일 밤 전체 기록을 다시 삽입하고 있는 상태입니다.
df.empty 검사는 파일에서 가장 중요한 줄입니다. Ticker.history()은 잘못된 심볼이나 상장 폐지된 심볼에 대해 예외를 발생시키지 않습니다. 대신 가격 데이터를 찾을 수 없다는 경고(라이브러리 버전에 따라 문구는 다를 수 있음)를 출력하고 빈 DataFrame을 반환합니다. 이 검사가 없는 작업은 아무것도 기록하지 않고 종료 코드 0을 반환하며, systemd는 테이블이 멈췄음에도 정상적으로 실행된 것으로 표시합니다. 결국 매일 같은 행만 반환되는 화면을 보고 나서야 몇 주 뒤에야 문제를 발견하게 됩니다.
타임스탬프 처리에도 이유가 있습니다. 피드가 반환하는 인덱스는 거래소의 시간대를 포함할 수 있으며, 오프셋을 단순히 제거하는 것은 UTC로 변환하는 것과 다릅니다. 도쿄 세션의 자정 시간은 UTC로 변환하면 이전 날짜가 됩니다. 따라서 UTC로 변환하면 모든 일본 시장 데이터가 하루씩 밀리게 되어 기본 키(primary key)가 깨집니다. tz_localize(None)은 거래소의 세션 날짜를 그대로 유지하며, 이것이 일봉 데이터가 의미하는 바입니다.
장 마감 시점에 실행되는 타이머
미국 시장은 뉴욕 시간 기준 16:00에 마감하며, 마지막 체결 데이터가 정산되기까지 몇 분이 소요되므로 작업은 16:20에 실행합니다. 이를 UTC가 아닌 뉴욕 시간으로 작성하십시오. 뉴욕은 겨울철 UTC-5, 여름철 UTC-4를 사용하므로, 고정된 UTC 시간으로 작성된 타이머는 1년에 두 번씩 1시간의 오차가 발생하여 장 마감 전에 실행될 수 있습니다. systemd 235 버전 이상부터는 OnCalendar에 시간대를 직접 지정할 수 있으며, Ubuntu 24.04는 255 버전을 제공합니다. systemctl --version으로 본인의 버전을 확인하십시오.
# /etc/systemd/system/research-refresh.service
[Unit]
Description=Refresh market data and run the daily screen
Wants=network-online.target
After=network-online.target
[Service]
Type=oneshot
User=research
Group=research
WorkingDirectory=/opt/research
EnvironmentFile=/etc/research/env
ExecStart=/opt/research/venv/bin/python /opt/research/refresh.py
ExecStart=/opt/research/venv/bin/python /opt/research/screen.py# /etc/systemd/system/research-refresh.timer
[Unit]
Description=Run the refresh after the US market close
[Timer]
OnCalendar=Mon-Fri 16:20 America/New_York
Persistent=true
RandomizedDelaySec=180
[Install]
WantedBy=timers.targetType=oneshot는 여러 개의 ExecStart를 허용하는 유일한 서비스 유형이며, 순차적으로 실행하다가 0이 아닌 종료 코드가 발생하면 중단합니다. 이는 정확히 의도한 동작입니다. 새로 고침에 실패했을 때 오래된 데이터를 화면에 표시해서는 안 되기 때문입니다. 커널 업데이트로 재부팅이 잦은 VPS 환경에서는 Persistent=true 설정이 중요합니다. 이 설정 없이 16:15에 재부팅되면 해당 작업은 누락되지만, 설정이 있으면 시스템이 복구되는 즉시 작업이 실행됩니다. 실행 후 종료되는 방식이 아니라 계속 유지되어야 하는 에이전트는 이와 반대되는 형태의 유닛이 필요합니다. 즉, Restart= 규칙을 가진 장기 실행 프로세스여야 하며 타이머는 필요하지 않습니다. 이에 관한 상세 내용은 systemd를 사용하여 VPS에서 dsh를 헤드리스로 실행하기에서 다룹니다.
sudo systemctl daemon-reload
sudo systemctl enable --now research-refresh.timer
systemctl list-timers research-refresh.timerlist-timers을 실행하면 다음 평일 실행 시간이 서버의 현지 시간으로 변환되어 NEXT 열에 표시되어야 합니다. 목록이 비어 있다면 타이머가 활성화되지 않았거나, 유닛에 [Install] 섹션이 누락되어 enable이 timers.target에 연결할 대상이 없다는 의미입니다.
화면: SQL을 먼저, 모델을 나중에
SQL은 정확하며 실행 비용이 발생하지 않습니다. 모델은 둘 다 해당하지 않습니다. 따라서 쿼리를 통해 데이터 범위를 좁히고, 살아남은 데이터만 모델로 전송합니다. 이를 /opt/research/screen.sql로 저장하십시오.
WITH ma AS (
SELECT ticker, day, close,
avg(close) OVER w20 AS ma20,
avg(close) OVER w50 AS ma50,
row_number() OVER (PARTITION BY ticker ORDER BY day) AS n
FROM prices
WINDOW
w20 AS (PARTITION BY ticker ORDER BY day ROWS BETWEEN 19 PRECEDING AND CURRENT ROW),
w50 AS (PARTITION BY ticker ORDER BY day ROWS BETWEEN 49 PRECEDING AND CURRENT ROW)
)
SELECT ticker, day, close, round(ma20, 2) AS ma20, round(ma50, 2) AS ma50
FROM ma
WHERE n > 50 AND ma20 > ma50
ORDER BY day DESC, ticker
LIMIT 20;n > 50 필터는 장식이 아닙니다. ROWS BETWEEN 49 PRECEDING AND CURRENT ROW은 존재하는 모든 행의 평균을 계산하므로, 티커의 세 번째 행은 3일 치의 평균을 계산하고도 이를 ma50이라 부릅니다. 이를 ma20와 비교하면 모든 티커 기록의 시작 부분에서 실제로는 발생하지 않은 교차 지점이 생성됩니다. 행 번호로 필터링하면 윈도우가 채워지지 않은 행들이 제거됩니다.
모델이 인식하는 정보와 인식하지 못하는 정보
# /opt/research/screen.py
from anthropic import Anthropic
from store import connect
SYSTEM = (
'You are a research assistant. Use only the rows in the message. '
'If a number is not in the rows, say that it is not available. '
'Do not give investment advice, price targets or buy and sell calls. '
'Write at most 200 words.'
)
con = connect()
sql = open('/opt/research/screen.sql').read()
rows = con.execute(sql).fetchall()
block = '\n'.join(' / '.join(str(v) for v in row) for row in rows)
client = Anthropic(max_retries=5) # reads ANTHROPIC_API_KEY from the environment
resp = client.messages.create(
model='claude-sonnet-5',
max_tokens=600,
thinking={'type': 'disabled'},
system=SYSTEM,
messages=[{'role': 'user', 'content': 'Screen hits, ticker / day / close / ma20 / ma50:\n' + block}],
)
print(resp.content[0].text)
con.execute('INSERT INTO runs VALUES (now(), ?, ?, ?, ?)',
['claude-sonnet-5', resp.usage.input_tokens,
resp.usage.output_tokens, len(rows)])
con.close()이 모델에서는 thinking 줄이 중요합니다. Claude Sonnet 5는 기본적으로 사고 과정을 거치며, 이때 발생하는 사고 토큰은 출력으로 청구되어 max_tokens에 포함되고, 텍스트 앞에 하나의 블록 형태로 반환됩니다. 해당 줄이 없으면 resp.content[0]가 사고 블록으로 인식되어 .text 조회가 실패할 수 있습니다. Claude Sonnet 5는 이제 레거시 모델입니다. 후속 모델인 claude-sonnet-5-5은 disabled을 거부하며, 이 경우 답변 전 사고 과정을 끄기 위해 thinking={'type': 'between_tools'} 설정을 사용해야 합니다.
시스템 프롬프트의 단어 제한은 비용이 많이 발생하는 청구 항목의 절반을 제한합니다. 제공된 행만 사용하라는 지침은 신뢰하기보다 직접 검증해야 합니다. 블록에서 열 하나를 삭제한 뒤 다시 실행하여 출력을 확인하십시오. 해당 열의 수치가 여전히 나타난다면 모델이 빈칸을 임의로 채운 것이며, 이는 프롬프트가 충분히 엄격하지 않다는 의미입니다. 이 테스트는 2분이면 완료되며, 모델의 동작을 확인하는 유일한 정직한 방법입니다.
모델은 API key를 절대 볼 수 없으며, 데이터베이스 경로를 확인하거나 쿼리를 직접 실행하지도 않습니다. 모델은 행 데이터를 받아 산문을 반환할 뿐입니다. 이러한 경계 덕분에 출력 결과를 검증할 수 있습니다. 메모에 포함된 모든 숫자는 전송한 블록 내에 존재해야 하므로, 이를 줄 단위로 대조할 수 있습니다. 웹에서 가져온 페이지를 입력에 포함하는 순간, 헤드라인이나 포럼 게시물이 사용자에게 전달할 사실이 아닌 모델을 향한 지침을 담을 수 있으므로 경계의 형태가 변합니다. 이것이 바로 에이전트에게 SearXNG 검색 백엔드 제공하기가 노출하는 공격 표면입니다. 프롬프트 측면에서 더 자세한 내용은 금융 분석에 Claude 활용하기에서 모델이 읽기에 적합한 정보가 무엇인지 다룹니다.
단일 실행, 엔드 투 엔드
뉴욕 시간 기준 16:20에 타이머가 서비스를 시작합니다. refresh.py은 마지막으로 저장된 날짜부터 각 티커의 피드를 요청하고, 티커당 한두 개의 새로운 바(bar)를 작성한 뒤 티커별로 한 줄씩 출력합니다. screen.py은 동일한 파일을 열어 이동 평균 쿼리를 실행하고, 몇 개의 행을 반환받습니다. 이 행들은 수백 토큰 분량의 텍스트 블록이 됩니다. 단 한 번의 API 호출로 이 텍스트는 짧은 메모로 변환되어 저널에 기록되며, 토큰 수와 적중 횟수를 포함한 한 행이 runs에 저장됩니다.
journalctl -u research-refresh.service -n 50 --no-pager정상적인 로그라면 티커당 한 줄, 그 뒤에 메모, 그리고 research-refresh.service: Deactivated successfully이 기록됩니다. 서버에서 Claude Code 세션을 통해 로그를 읽는 동안 다른 세션이 화면 쿼리 작업을 수행 중이라면, 두 세션 간에 텍스트를 직접 전달할 수 있으므로 터미널 사이에서 줄을 복사할 필요가 없습니다. 일주일이 지난 후, 데이터베이스에서 직접 비용을 확인하십시오.
SELECT count(*) AS runs,
sum(input_tokens) AS in_tokens,
sum(output_tokens) AS out_tokens
FROM runs;해당 합계에 조회 당일 모델이 명시한 백만 토큰당 가격을 곱하십시오. 그러면 타인의 추정치가 아닌 실제 비용을 산출할 수 있습니다. 게시된 가격은 변하지만, 산술 방식은 변하지 않습니다.
백테스트가 과적합되는 이유와 이를 확인하는 방법
두 윈도우 길이를 변수로 하는 함수로 스크린을 재작성하고, 쌍의 그리드를 전수 조사하여 수익률 순으로 정렬하십시오. 가장 좋은 성과를 낸 쌍은 매우 훌륭해 보일 것입니다. 이것은 결과가 아니라 문제입니다. 200개의 쌍을 조사했다면 200번의 실험을 한 것이며, 그중 가장 운이 좋았던 것을 선택한 셈입니다.
이 현상은 10분 만에 확인할 수 있습니다. 데이터를 날짜 기준으로 절반으로 나눕니다. 첫 번째 절반에 대해서만 그리드를 조사하여 승자를 기록하십시오. 두 번째 절반에 대해서도 동일한 그리드를 조사하십시오. 두 승자의 결과가 크게 다르다면, 해당 매개변수는 노이즈에 맞춰진 것이며 튜닝한 기간에만 수익을 내는 쌍은 미래에 대해 아무런 정보를 주지 못합니다.
생존 편향(Survivorship bias)은 튜닝으로 해결할 수 없기 때문에 과적합보다 더 위험합니다. 티커 목록이 오늘날의 지수 구성 종목이라면, 살아남은 기업들만 포함되어 있습니다. 2019년에 상장 폐지된 티커를 데이터 피드에 요청하면 빈 프레임이 반환되는데, 이는 해당 기업이 저장소에 들어오지 않아 테스트에 포함되지 않았음을 의미합니다. 실행하는 모든 백테스트는 이미 실패한 기업들을 제외하고 있습니다.
수정된 펀더멘털 데이터는 타임라인을 왜곡합니다. 오늘 API가 반환하는 2019년 분기의 매출 수치는 2019년 당시 발표된 수치와 항상 일치하지는 않습니다. 오늘의 펀더멘털과 2019년의 가격을 혼합한 스크린은 당시에는 존재하지 않았던 정보를 사용하고 있는 것입니다. 가격 데이터는 대개 안전하지만, 펀더멘털 데이터는 그렇지 않은 경우가 많습니다.
수정 주가는 데이터의 기준을 바꿉니다. auto_adjust=True을 사용하면 종가는 배당과 액면분할을 반영하여 과거로 소급 조정되므로, 다음 달에 동일한 쿼리를 실행하면 약간 다른 이력이 반환됩니다. 실제로 사용한 행을 저장하는 것이 결과의 재현성을 보장하며, 로컬 저장소가 존재하는 또 다른 이유이기도 합니다.
또한 백테스트는 수수료와 슬리피지를 무시하며, 주문이 가격에 영향을 주지 않는다고 가정합니다. 이는 실행(execution)의 영역으로, 본 문서의 범위를 벗어나며 VPS에서 트레이딩 봇 실행하기에서 다룹니다.
실패 유형과 표시되는 문자열
error: externally-managed-environment (pip 실행 시). 가상 환경 외부에서 실행 중입니다. /opt/research/venv/bin/pip를 전체 경로로 호출하십시오.
Could not set lock on file (뒤에 PID가 표시됨). 다른 프로세스가 DuckDB 파일을 쓰기 모드로 열어둔 상태입니다. 보통 잊고 켜둔 대화형 셸이 원인입니다. 해당 프로세스를 종료하십시오. 쓰기 프로세스가 열려 있는 동안 read_only=True로 두 번째 연결을 시도해도 소용없습니다. DuckDB는 여러 프로세스의 읽기 또는 단일 프로세스의 쓰기만 허용하며, 두 작업을 동시에 수행할 수 없기 때문입니다.
Main process exited, code=killed, status=9/KILL (systemctl status 내). 커널이 메모리 부족으로 작업을 강제 종료했습니다. journalctl -k | grep -i oom으로 이를 확인한 뒤, store.py에서 memory_limit 값을 낮추십시오.
데이터를 기록하지 않는 성공적인 실행. systemctl status이 active (exited)를 읽었으나 테이블에 변화가 없습니다. 피드에서 빈 프레임만 반환된 경우입니다. 이 실패는 가장 발견하기 어려우므로, 모든 티커의 응답이 비어 있을 때 작업이 0이 아닌 종료 코드를 반환하도록 설정하십시오.
시장 휴장일에 타이머가 작동함. systemd는 거래소 달력을 알지 못하므로 Mon-Fri에는 휴장일이 포함됩니다. 작업이 실행되어도 피드에 새로운 데이터가 없는 것은 정상이며, 이를 오류로 처리해서는 안 됩니다.
API에서 발생하는 429. 속도 제한(rate limit)을 초과했습니다. Anthropic SDK는 자체적으로 백오프(backoff)를 수행하며 재시도하고, Anthropic(max_retries=5)은 시도 횟수를 증가시킵니다. 매일 실패가 반복된다면 한 번에 너무 많은 요청을 보내고 있는 것입니다.
이 도구가 아닌 것
이 도구는 연구 보조 도구입니다. 모델이 문서를 요약하면 해당 문서에 대한 해석을 생성하는 것이며, 텍스트에 인쇄된 숫자에 대해 확신을 가지고 틀릴 수 있습니다. 따라서 이 노트의 모든 수치는 사용자가 보낸 행(row)으로 추적 가능해야 합니다. 출력 결과는 직접 읽어봐야 할 항목의 목록으로 취급하십시오. 여기에 포함된 내용은 재정적 조언이 아니며, 어떠한 신호(signal)도 아닙니다.
백테스트는 아이디어를 거부하는 데는 유용하지만, 아이디어를 확증하는 데는 취약합니다. 사용자의 데이터에서 실패한 전략은 실제로 죽은 전략입니다. 통과한 전략은 단지 사용자의 데이터를 견뎌낸 것일 뿐이며, 이는 새벽 1시에 느껴지는 것보다 훨씬 작은 주장입니다.
실행(Execution)은 의도적으로 범위에서 제외했습니다. 주문 및 브로커 자격 증명은 읽기 전용 연구 환경과는 다른 위험 프로필을 가지며, 이를 혼합하면 LLM 프롬프트와 동일한 머신에 트레이딩 키를 두게 됩니다. 이 패턴이 개인 서버에서 실행할 가치가 있는 다른 항목들과 어떻게 배치되는지 확인하려면, 직접 호스팅할 가치가 있는 AI 에이전트에서 더 넓은 내용을 살펴보십시오.
FAQ
유료 시장 데이터 피드가 필요한가요?
프로토타입 단계에서는 필요하지 않습니다. 학습용으로 시스템의 구조를 파악하는 데는 무료 비공식 피드로도 충분합니다. 다만, 이러한 피드는 아무런 의무가 없는 웹사이트에 의존하므로 언제든 중단될 수 있습니다. 보통 예외를 발생시키기보다 빈 프레임을 반환하는 방식으로 실패하므로, 작업 시 행 개수를 반드시 확인해야 합니다. 데이터가 의사결정에 활용되기 시작하면 문서화된 API와 지원 창구를 갖춘 유료 피드로 전환하십시오. 스토어(store) 구조를 잘 설계하면 교체 비용이 저렴합니다. 가져오기(fetch) 함수만 수정하면 되며, 스케줄, 스키마, 화면 구성은 그대로 유지할 수 있습니다.
가격 데이터를 SQLite에 저장해야 하나요, DuckDB에 저장해야 하나요?
DuckDB는 컬럼 기반 저장소로, 10년 치 데이터의 이동 평균과 같은 대량의 행을 스캔하여 집계하는 작업에 최적화되어 있습니다. SQLite는 행 기반 저장소로, 여러 프로세스에서 동시에 발생하는 소규모 읽기 및 쓰기 작업에 더 적합합니다. 수백 개의 행을 추가하고 수백만 개의 행을 스캔하는 단일 스케줄 작업에는 DuckDB가 더 나은 선택입니다. 만약 여러 프로세스가 동시에 데이터를 써야 한다면, SQLite의 WAL 모드를 사용하십시오. 읽기 작업이 진행되는 동안에도 쓰기 작업이 가능하며, busy timeout 설정을 통해 다른 쓰기 작업이 실패하지 않고 대기하도록 할 수 있습니다.
LLM 호출 비용은 한 달에 얼마나 나오나요?
모든 응답에서 usage.input_tokens와 usage.output_tokens을 테이블에 기록한 뒤, 주간 합계에 모델별 백만 토큰당 가격을 곱하여 계산하십시오. 이 수치만이 다음 분기에도 유효합니다. 일일 스크린 작업은 호출 횟수가 적으며, 비용은 노트의 길이를 조절하여 제어할 수 있습니다. Claude 모델은 출력 토큰의 단가가 입력 토큰보다 높게 책정되어 있으므로, 행 수를 줄이는 것보다 요약을 200단어 이내로 제한하는 것이 비용 절감에 더 효과적입니다.
작업은 성공했는데 왜 새로운 행이 기록되지 않나요?
두 가지 일반적인 원인이 있습니다. 첫째, 시장이 휴장일 경우입니다. systemd Mon-Fri 스케줄은 거래소 휴일을 포함하지 않기 때문입니다. 둘째, 피드가 모든 티커에 대해 빈 프레임을 반환한 경우입니다. 클라이언트 라이브러리는 이를 예외가 아닌 단순 경고 메시지로 출력하는 경우가 많아, 프로세스는 0으로 종료되고 systemd는 성공으로 표시합니다. SELECT max(day) FROM prices를 마지막 실제 거래일과 비교하여 문제를 구분하고, 모든 티커의 데이터가 비어 있을 경우 작업이 0이 아닌 값으로 종료되도록 설정하십시오.
에이전트가 무엇을 살지 결정할 수 있나요?
아니요. 에이전트에게 매수 결정을 맡기려는 시도는 프로젝트가 실패하는 주된 원인입니다. 모델은 시장에 접근할 수 없고, 사용자의 포지션이나 세금 상황을 알 수 없으며, 자신의 계산 결과를 검증할 방법도 없습니다. 모델은 방대한 텍스트를 읽고 오늘 주의 깊게 살펴봐야 할 항목을 추려내는 데 탁월합니다. 모델이 작성하는 내용은 금융 조언이 아니며, 결정과 그에 따른 책임은 전적으로 사용자에게 있습니다.