들어가며 — "에이전트 하나로 다 시켜봤더니" 실패담
처음 에는 LLM 하나에 다 시켰다. 종목·시장·외부 뉴스·내 포트폴리오·매매 시점을 한 prompt에 다 넣고 "사야 할지 팔아야 할지 판단해줘."
결과는 처참했다.
문제 1. 트레이싱이 안 된다
손실이 나면 "왜?"를 알 수 없다. 종목 차트가 약했나? 시장이 안 좋았나? 텔레그램 뉴스를 잘못 해석했나? 전부 한 LLM 응답에 섞여 있어서 블랙박스다.
문제 2. 책임이 흐릿하다
같은 종목에 BUY 신호가 떴는데, 한 번은 50주를 사고 한 번은 5주를 산다. LLM이 매번 다른 정보를 다른 비중으로 가중치를 두기 때문이다. 일관성이 없다.
문제 3. 비용이 폭발한다
한 호출에 모든 컨텍스트를 다 때려넣으니 5분마다 토큰 수만 개씩 발생한다. Claude Code CLI 정액제 quota를 금방 소진한다.
그래서 3개 에이전트로 책임을 분리했다. 오늘은 이 3-에이전트 아키텍처가 어떻게 협업하는지, 그리고 왜 분리해야만 트레이싱이 가능한지를 공유한다.
RACI — 한 에이전트 = 하나의 질문
핵심 설계 원칙: 각 에이전트는 단 하나의 질문만 책임진다. 책임이 겹치지 않게 분리한 것이 다였다.
에이전트
책임지는 질문
산출물
입력
🔍 scout_agent
흥미로운 후보인가?
conviction (0~1) + 단계별 근거
텔레그램 raw 뉴스
🎯 ticker_agent
이 종목, 지금 얼마나 강한가?
signal_strength (0~1) / sell_ratio
차트 5지표만
🧭 strategy_agent
얼마나(비중) 살 것인가?
dynamic_anchor 조정
포트폴리오 전체
┌─────────────────────────────────────────────────────────────┐
│ 🔍 scout_agent ───► "공급자" │
│ 텔레그램 raw 뉴스를 검증해 후보만 공급. 매매 결정 X. │
├─────────────────────────────────────────────────────────────┤
│ 🎯 ticker_agent ───► "실행자" │
│ 한 종목의 차트 5지표만 보고 진입 강도 결정. 비중 신경 X. │
├─────────────────────────────────────────────────────────────┤
│ 🧭 strategy_agent ──► "자원 배분자" │
│ 포트폴리오 전체 보고 anchor 조정. 직접 매매 X (옵션 A). │
└─────────────────────────────────────────────────────────────┘
+
📐 rule (vwap_ema) — BUY/SELL "시점" (룰 기반, LLM X)
🛡 risk (Guard) — 차단 결정 (7-Layer SELL Guard)
이 분리가 가져온 효과: 손실이 났을 때 "어떤 뉴스가 → 어떤 검증을 거쳐 → 어떤 비중으로 → 어떤 매매로" 이어졌는지 한 줄로 추적할 수 있다.
① scout_agent — "이 뉴스 진짜인가?"
scout는 가장 앞단이다. 텔레그램 4채널에서 5분마다 raw 메시지를 수집해서 단계별로 검증한다.
4단계 검증 파이프라인
1. 메시지 수집 (poller, 5분 cron)
└─ t.me/s/<channel> 웹뷰 스크래핑
→ telegram_messages (raw_html / links / image_urls / forwarded_from)
2. 신뢰성 평가 (룰 기반, 비용 0) ★ 핵심 비용 게이트
├─ 채널 weight × 본문 구체성(숫자·%) × 링크 유무
└─ reliability < 0.4 → REJECT (web·judge 단계 진입 차단)
3. web 검증 (LLM)
├─ linked URL 원문 fetch
└─ Claude WebSearch 교차검증 → web_verified ∈ {1, 0, -1}
4. 차트 평가 (ticker_agent에 위임)
└─ chart_strength = ticker_agent.decide(BUY, chart_only=True).signal_strength
5. judge (LLM, 종합)
└─ conviction = (신뢰성 + web) × 0.55 + 차트 × 0.45
recommendation ∈ {BUY_CANDIDATE, WATCH, REJECT}
게이트가 비용을 막는다
텔레그램은 하루 수백 개 메시지가 들어온다. 그걸 다 LLM에 보내면 quota가 1시간 만에 소진된다. 1차 신뢰성 게이트는 룰 기반(비용 0)으로 80% 이상을 거른다. web·judge LLM은 통과한 20%에만 호출된다.
산출 — scout_signals 테이블
각 단계 결과를 별도 컬럼으로 보존한다. 트레이싱을 위해서다.
CREATE TABLE scout_signals (
message_id, ticker, company_name,
source_reliability, reliability_reason, -- ① 룰 점수 + 근거
web_verified, web_evidence, -- ② web 검증
chart_strength, chart_summary, -- ③ 차트 (ticker 위임)
conviction, recommendation, evidence_kr, -- ④ 종합
llm_provider, llm_model,
mode, session_id, as_of, -- live/backtest 구분
delivered_to_strategy, created_at
);
이 컬럼들 덕분에 한 달 뒤에도 "왜 이 종목이 후보였는가?"를 1행으로 답할 수 있다.
conviction 임계 + calibration gate
scout가 conviction 0.80 이상으로 평가해도 바로 매매로 가지 않는다. 채널 적중률 검증을 한 번 더 거친다.
calibration gate (채널별):
- 최근 60일 / 측정 표본 ≥ 20 / T+7d +3% hit rate ≥ 60%
→ verified ✅ (urgent_chain 진입 허용)
→ cold_start (표본 부족) → preview 평가만
→ quarantined (적중률 미달) → preview 평가만
→ trusted ✅ (수동 검증 채널, calibration 면제)
이게 있어서 신생 채널이나 적중률 낮은 채널이 운영을 흔들지 못한다. 신뢰가 쌓인 채널만 매매로 이어진다.
② ticker_agent — "지금 얼마나 강한가?"
ticker_agent는 단일 종목의 차트만 본다. 시장 분위기, 다른 종목 보유 현황, 현금 비중 — 전부 입력에서 의도적으로 제거했다.
왜 입력을 줄였는가
처음에는 다 넣었다. 결과: ticker_agent가 "삼성전자가 약세니까 이 종목도 약세겠지" 같은 추론 비약을 했다. 종목 평가는 그 종목의 차트만 보고 해야 한다.
차트 5지표 (입력)
지표
의미
산출
적정주가
평가절하 여부
현재가 vs fair_value composite
매집도
자금 유입 추세
OBV + 누적 거래대금
ADX
추세 강도
14일 ADX (강세 ≥ 20)
상대 거래량
관심도 급증
당일 / 20일 평균
매물대 공백
저항 부재
Volume Profile 빈 구간
입출력 명세
# 입력 prompt 블록 (system + user)
[B] 종목 메타 # 코드 / 전략 / 발생 시그널
[C] 현재 컨텍스트 # 현재가 / 목표가 / 보유 / 텔레그램 sentiment
[C-1] 적정가 7지수 # PER/PBR/DCF/지지·저항/flow/IV/업종/뉴스
[C-2] 거래주체 5일 # 외인/기관/개인 + dominant + trend
[D] 사용자 매매 철학 # ticker_prompts.text (active version)
[E] 종목별 손절선 # stop_loss_pct
[F] 빌더 dry-run # self-consistency 유도
# 출력 (BUY)
{ "decision": "BUY"|"HOLD",
"signal_strength": 0.0~1.0, # 0.25 / 0.5 / 1.0 중
"reasoning": "한 문장",
"confidence": 0.0~1.0 }
# 출력 (SELL)
{ "decision": "SELL"|"HOLD",
"sell_ratio": 0.0~1.0,
"reasoning": "한 문장",
"confidence": 0.0~1.0 }
signal_strength → 실제 매수 수량
# core/portfolio.py
qty = calculate_buy_size(
target_weight_pct = plan.target_weight_pct, # anchor (사용자 설정)
signal_strength = ticker_agent.signal_strength,
)
# → 매수 비중 = anchor × signal_strength
# → 예: anchor 30%, strength 1.0 → equity의 30% 매수
# → 예: anchor 30%, strength 0.5 → equity의 15% 매수
ticker_agent는 거부 옵션이 없다. 0.25 / 0.5 / 1.0 셋 중 하나로 강도만 결정한다 (2026-05-07 정책). 이유: AI가 매번 HOLD로 도망가서 cash가 70%+ 묶이는 문제를 해결.
③ strategy_agent — "포트폴리오에 넣을 자리인가?"
strategy_agent는 직접 매매하지 않는다. 대 신 dynamic_anchor를 조정한다.
dynamic_anchor — anchor 재정의 (Phase 5, 2026-05-06)
기존: allocation_pct = 고정 target weight.
신규: allocation_pct = anchor (자본 배분 prior) — strategy_agent가 시장 따라 ±조정.
@dataclass
class StrategyBudget:
ts: datetime
per_ticker_cap_pct: dict[str, float] # 종목당 권고 상한
per_ticker_floor_pct: dict[str, float] # 종목당 권고 하한
total_cash_target_pct: float
market_sentiment: str # bullish | neutral | bearish | risk_off
portfolio_health: str # good | warning | critical
rationale: str
rotation_approve: bool # sell_ticker → buy_ticker 회전 승인 여부
옵션 A — 강제 BUY 트리거 (2026-05-06)
strategy_agent가 "이 watchlist 종목을 자동매매로 승격하자"고 판단했을 때, 다음 시그널을 기다리지 않고 같은 봉에서 강제로 BUY 트리거를 추가한다.
04-24 09:30 📐 RULE 267260 BUY 3 240만 → 시그널 발생
↓ (strategy_agent 호출)
🧭 전략AI PORTFOLIO 비중조정 — 267260 anchor 40%→24%
+ 084370 anchor 0%→8% (승격)
↓ (강제 BUY 트리거)
04-24 09:30 🎯 종목AI 084370 BUY 6 133만 → strategy 트리거로 신규 매수
같은 봉에서 자원을 회수해 새 종목에 배분. "선수 교체"가 자연 흐름으로 들어간다.
watchlist 승격 매수 흐름 (확정 명세)
이 부분이 가장 자주 뒤틀려서 docs에 명세를 못 박았다.
[trigger 조건 — 둘 다 만족]
1. watchlist 종목(anchor=0)에 vwap_ema BUY 시그널
2. ticker_agent 1차 평가 signal_strength ≥ 0.7 ★ strategy 호출 게이트
[흐름]
[pass 1] 시그널 평가
├ active 종목(anchor>0) BUY/SELL → 시그널 목록 (매수 보류)
└ watchlist 종목(anchor=0) BUY:
→ ticker_agent 1차 호출 → signal_strength
├ strength ≥ 0.7 → "승격 후보" 등록
└ strength < 0.7 → monitoring 로그만, skip
[gate] 승격 후보 ≥ 1건 ? → YES일 때만 strategy 호출
[strategy_agent]
입력: [S] scout conviction + [T] ticker strength
→ 승격 후보 buy_ticker 선정, anchor 0 → >0
→ 약세·연속 BUY 안 한 종목 sell_ticker 회수
→ rotation.py가 anchor 합계 100% 비례 정규화
[pass 2] 매수 실행
└ 승격 종목: ticker_agent 2차 (조정된 anchor 기준)
1차 strength 재사용 가능 (5분 cache hit)
순서가 중요하다: ticker(게이트) → strategy → 매수. strategy를 매수 후에 호출하면 anchor 조정이 다음 cron으로 1 cron 밀린다. 금지.
urgent_chain — scout 발 신규 종목 진입 경로
scout가 발견한 신규 종목은 watchlist 승격 경로와 다른 경로로 들어온다. urgent_chain이다.
texas (scout candidate, conviction ≥ 0.80)
│
▼ ① source calibration gate (verified만 통과)
│
▼ ② urgent_chain ticker gate (strength ≥ 0.8)
│
▼ ③ urgent_chain strategy gate (capacity OK)
│
▼ 셋 다 YES → ai_trade_plan 자동 생성 (PLAN_CREATED)
(둘 중 하나 reject → REJECT, audit만 기록)
3중 gate가 있어서 scout가 흥분해도 운영 진 입 가능성이 낮다. 안전망 설계의 핵심.
④ 4 source 분류 — 모든 매매에 라벨
매매 결정은 4개 출처 중 하나로 자동 분류된다 (portfolio_engine.py:_classify_source).
source
label
색
트리거
rule
📐 RULE
회색
rebalance / rotation / cooldown
ticker_agent
🎯 종목AI
cyan
ticker_agent ai_payload 있음
strategy_agent
🧭 전략AI
violet
strategy_agent rotation_approve
risk
🛡 RISK
red
"강제/익절/손절/TP/SL/만기" 키워드
UI의 ActionsLog와 PortfolioCharts가 이 4색으로 마커를 분리한다. 손실이 났을 때 "어떤 source가 결정한 매매가 손실로 이어졌는가?"를 즉시 확인할 수 있다.
⑤ Multi-Provider ProviderChain — quota 소진 대비
3개 에이전트가 다 LLM을 부른다. quota가 소진되면? 4종 provider를 1~4순위로 fallback.
# core/agents/llm_runtime.py
PROVIDERS = {
"claude_cli": ClaudeCodeCLI(), # 구독 정액제 (1순위)
"codex_cli": CodexCLI(), # 구독 정액제
"gemini_cli": GeminiCLI(), # 구독 정액제
"claude_sdk": AnthropicSDK(), # 종량제 (최후 fallback)
}
# config — 에이전트별 우선순위
scout_agent_provider_chain = ["claude_cli", "codex_cli", "gemini_cli", "claude_sdk"]
ticker_agent_provider_chain = ["claude_cli", "claude_sdk"]
strategy_agent_provider_chain = ["claude_sdk", "claude_cli"] # tool use 필요
claude CLI quota 소진 → codex CLI 자동 전환 → 그것도 소진 → gemini → 최후로 종량제 SDK. 시스템이 멈추지 않는다.
특수 케이스: strategy_agent는 multi-turn tool calling이 필요해서 Anthropic SDK 1순위 (CLI는 tool use 미지원).
⑥ 7-Layer SELL Safety Guard — "오매도 절대 막기"
매수보다 매도가 무섭다. 잘못 매도하면 손실 확정이다. 2026-05-12 incident(test fixture가 운영 DB에 가짜 SELL row를 INSERT한 사고) 이후 7-Layer 가드 체인을 도입했다.
SELL 신호 ─┐
├─→ [L1 가격] ─→ [L2 봉신선도] ─→ [L3 포지션] ─→ [L4 중복] ─→ [L5 모드] ─→ broker.order
│ ↓ ↓ ↓ ↓ ↓
│ 차단 시 → [L6 Audit] → sell_blocks INSERT + 텔레그램 alert
│
└─→ 매도 체결 후 → [L7 Reconcile] → KIS 잔고 ↔ DB sync
Layer
차단 조건
어제 incident 차단?
L1 Price Sanity
avg×30% 미만 / 분봉 close ±5% 괴리
✅ 277,000×0.30=83,100 > 45,000
L2 Bar Freshness
15분+ stale / 0 volume
(장 외라 skip)
L3 Position
qty<=0 / oversell / manual / DB↔KIS mismatch
(통과)
L4 Idempotency
signal_id 60초 / 같은 봉 SELL 2건
✅ 같은 ts 2건
L5 Mode
auto_trade_enabled / 장외 caller
✅ 16:03 ≥ 15:30
L6 Audit
sell_blocks 테이블 + 텔레그램 throttled
(자동)
L7 Reconcile
매도 후 KIS 잔고 ↔ DB sync
(사후 cron)
L1+L4+L5 셋 다 발화. 첫 단계에서 short-circuit으로 차단 → 운영 DB에 가짜 매도 INSERT 없음.
백테스트와 일원화
같은 SellGuardChain을 백테스트에서도 호출한다 (ModeGuard만 skip — 시뮬 시간 ≠ 실시간). 백테스트가 "현실에서 실제 차단될 매도"를 가짜로 통과하지 못한다. 백테스트와 운영의 mismatch를 원천 차단.
⑦ 5분 cron 한 사이클의 전체 흐름
intraday_monitor (09:05~15:55, 5분)
│
├─ _holiday_guard (휴장일 skip)
│
├─ live_log_buffered(): with 안에서 모든 log_live 배치
│
├─ _scan_and_trade_picks
│ │
│ ├─ [pass 1] 모든 plan의 시그널 평가 (vwap_ema, rule)
│ │ ├─ active 종목 BUY/SELL → 보류
│ │ └─ watchlist 종목 BUY → ticker_agent 1차 → 승격 후보 등록
│ │
│ ├─ [gate] 승격 후보 ≥ 1건 ? → strategy_agent 호출
│ │ ├─ MIN_GAP_SEC (300) debounce
│ │ ├─ rotation_approve 시 dynamic_anchor_overrides UPDATE
│ │ └─ 옵션 A: buy_ticker 강제 트리거 추가
│ │
│ └─ [pass 2] 매매 실행 (직렬, cash race 방지)
│ ├─ ticker_agent (BUY 시그널 종목)
│ ├─ 7-Layer SELL Guard (SELL 시그널 종목)
│ └─ KIS API 주문
│
└─ commit (executemany 1회)
5분 한 사이클에 ticker_agent 호출 0~2개, strategy_agent 호출 0~1개. LLM 비용 폭증 X.
결론 — "분리해야 진화한다"
3개 에이전트 분리가 가져온 효과를 정리하면:
분리 전
분리 후
손실 원인 트레이싱 불가
scout_signals 4단계 보존 → 1행으로 추적
같은 신호에 매번 다른 비중
ticker_agent: 0.25/0.5/1.0 일관성
LLM 호출 비용 폭증
scout 1차 게이트 + cron debounce
시장 영향 반영 어려움
strategy_agent dynamic_anchor
오매도 위험
7-Layer SELL Guard
다음 주(3주차)에는 이 에이전트들을 어떻게 길들이는가를 다룬다. 백테스트 + 일일 분석으로 ticker_prompts와 scout judge 프롬프트를 진화시키는 과정. 알파의 마지막 한 자릿수는 prompt tuning에서 나온다.