3개 에이전트가 협업해서 코스피 매매하는 법

들어가며 — "에이전트 하나로 다 시켜봤더니" 실패담

처음에는 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에서 나온다.

4
3개의 답글
밀어주고 끌어주는

온·오프라인 AI 스터디

AI로 어디까지 할 수 있는지
직접 확인하실 분만 신청하세요.