From 548902c22a1e6fbcda5622af326b59948bd7ffb3 Mon Sep 17 00:00:00 2001
From: Ryzenserver
Date: Tue, 24 Mar 2026 00:47:22 +0900
Subject: [PATCH] feat: add trade history management with SQLite
- Implemented functions to record trades and PnL (profit and loss) logs.
- Created database schema for trades, PnL logs, daily state, and budget anchors.
- Added functionality to aggregate PnL by market and currency.
- Included methods to check and mark daily actions to prevent duplicate processing.
- Initialized the database on module load.
---
.dockerignore | 17 +
.env.example | 64 +
.github/copilot-instructions.md | 81 +
.github/instructions/git-push.instructions.md | 31 +
.github/workflows/docker-ghcr.yml | 58 +
.gitignore | 2 +-
Dockerfile | 15 +
README.md | 534 ++-
bot.py | 3397 +++++++++++++++++
docker-compose.yml | 16 +
kis_client.py | 1271 ++++++
requirements.txt | 24 +-
trade_history.py | 446 +++
13 files changed, 5805 insertions(+), 151 deletions(-)
create mode 100644 .dockerignore
create mode 100644 .github/copilot-instructions.md
create mode 100644 .github/instructions/git-push.instructions.md
create mode 100644 .github/workflows/docker-ghcr.yml
create mode 100644 Dockerfile
create mode 100644 bot.py
create mode 100644 docker-compose.yml
create mode 100644 kis_client.py
create mode 100644 trade_history.py
diff --git a/.dockerignore b/.dockerignore
new file mode 100644
index 00000000..d061630f
--- /dev/null
+++ b/.dockerignore
@@ -0,0 +1,17 @@
+.git
+.gitignore
+.venv
+__pycache__/
+*.pyc
+*.pyo
+*.pyd
+.ruff_cache/
+.vscode/
+.env
+data/
+results/
+reports/
+eval_results/
+assets/
+README.md
+LICENSE
diff --git a/.env.example b/.env.example
index 1328b838..f1699742 100644
--- a/.env.example
+++ b/.env.example
@@ -4,3 +4,67 @@ GOOGLE_API_KEY=
ANTHROPIC_API_KEY=
XAI_API_KEY=
OPENROUTER_API_KEY=
+
+# Discord Bot
+DISCORD_BOT_TOKEN=
+# 봇이 동작할 채널 ID (쉼표로 여러 개 지정, 비워두면 모든 채널에서 동작)
+# 채널 ID 확인: Discord 개발자 모드 켜고 → 채널 우클릭 → "채널 ID 복사"
+# DISCORD_CHANNEL_IDS=123456789012345678,987654321098765432
+
+# 한국투자증권 API (모의투자/실전 모두 지원)
+# 발급: https://apiportal.koreainvestment.com → API신청 → 앱키 발급
+KIS_APP_KEY=
+KIS_APP_SECRET=
+KIS_ACCOUNT_NO=12345678-01
+KIS_VIRTUAL=true
+# 수동(/분석, /대형주, /매수) 1회 매수 예산 상한 (원)
+KIS_MAX_ORDER_AMOUNT=1000000
+# 한국 대형주/ETF 워치리스트 (비우면 응답 가능한 랭킹으로 대체)
+KR_WATCHLIST=005930,000660,005380,005490,035420,105560,069500,114800,226490,229200
+
+# ─── 미국(US) 거래 설정 ───────────────────────────────────────
+# 미국 자동주문 기능 활성화 (기본: false)
+ENABLE_US_TRADING=false
+# 수동(/분석, /매수) 1회 매수 예산 상한 (USD)
+US_MAX_ORDER_AMOUNT=5000
+# 해외 종목 거래소 탐색 순서
+US_EXCHANGE_SEARCH_ORDER=NASD,NYSE,AMEX
+# 미국 대형주/ETF 워치리스트
+US_WATCHLIST=AAPL,MSFT,NVDA,AMZN,GOOGL,META,TSLA,AMD,AVGO,QQQ,SPY
+
+# ─── 데이 트레이딩 설정 ───────────────────────────────────
+# 매일 매수할 종목 수 (기본: 5)
+DAY_TRADE_PICKS=5
+# 자동매수 기준 자금에서 사용할 비율 (0.5, 50% 둘 다 가능 / 기본: 100%)
+AUTO_BUY_BUDGET_RATIO=1.0
+# 자동 매수 시각 (KST, HH:MM) — 기본: 09:30 (스코어링→AI분석→매수)
+AUTO_BUY_TIME=09:30
+# 자동 매도 시각 (KST, HH:MM) — 기본: 15:20
+AUTO_SELL_TIME=15:20
+# 분석 보고서 저장 디렉터리 (도커 기본: /app/reports)
+REPORTS_DIR=reports
+# 자동매매 분석 보고서 디스코드 업로드 여부 (저장은 항상 수행)
+AUTO_REPORT_UPLOAD=true
+
+# ─── 미국 데이 트레이딩 설정 (뉴욕시간 ET) ────────────────────
+# 매일 매수할 종목 수 (기본: 5)
+US_DAY_TRADE_PICKS=5
+# 미국 자동매수 기준 자금 비율 (미설정 시 AUTO_BUY_BUDGET_RATIO 사용)
+US_AUTO_BUY_BUDGET_RATIO=1.0
+# 자동 매수 시각 (ET, HH:MM) — 기본: 09:35
+US_AUTO_BUY_TIME=09:35
+# 자동 매도 시각 (ET, HH:MM) — 기본: 15:50
+US_AUTO_SELL_TIME=15:50
+
+# ─── 손절/익절 설정 ────────────────────────────────────────
+# 손절 라인 (%, 음수) — 기본: -5%
+STOP_LOSS_PCT=-5.0
+# 익절 라인 (%, 양수) — 기본: 10%
+TAKE_PROFIT_PCT=10.0
+# 모니터링 간격 (분) — 기본: 30분
+MONITOR_INTERVAL_MIN=30
+
+# (Optional) LLM model overrides for Discord bot
+# DEEP_THINK_LLM=gemini-3-flash-preview
+# QUICK_THINK_LLM=gemini-3-flash-preview
+# MAX_DEBATE_ROUNDS=1
diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md
new file mode 100644
index 00000000..e66300ed
--- /dev/null
+++ b/.github/copilot-instructions.md
@@ -0,0 +1,81 @@
+# TradingAgents 프로젝트 가이드라인
+
+## 프로젝트 개요
+
+멀티 에이전트 LLM 데이 트레이딩 프레임워크. LangGraph 기반 5단계 파이프라인(분석→토론→트레이딩→리스크→포트폴리오)으로 주식 매매 의사결정을 수행한다. Discord 봇 + 한국투자증권 API 연동으로 실제 자동매매를 지원한다.
+
+## 빌드 및 실행
+
+```bash
+pip install -e . # 개발 설치
+tradingagents analyze # CLI 대화형 분석
+python bot.py # Discord 봇 (자동 데이트레이딩)
+python main.py # 직접 분석 예제
+docker-compose up # 컨테이너 배포
+```
+
+- Python 3.10+ 필수
+- `.env` 파일에 API 키 설정 필요 (LLM, Discord, KIS)
+
+## 아키텍처
+
+### 핵심 디렉터리
+
+| 디렉터리 | 역할 |
+|----------|------|
+| `tradingagents/agents/` | 에이전트 팀 (analysts, researchers, trader, risk_mgmt, managers) |
+| `tradingagents/dataflows/` | 데이터 벤더 추상화 (yfinance/AlphaVantage) |
+| `tradingagents/graph/` | LangGraph 오케스트레이션, 상태 관리 |
+| `tradingagents/llm_clients/` | LLM 프로바이더 팩토리 (openai, anthropic, google 등) |
+| `cli/` | Typer 기반 대화형 CLI |
+
+### 진입점
+
+| 파일 | 용도 |
+|------|------|
+| `bot.py` | Discord 봇 (슬래시 커맨드 + 자동 스케줄) |
+| `main.py` | Python 직접 사용 예제 |
+| `kis_client.py` | 한국투자증권 REST API 클라이언트 |
+| `trade_history.py` | SQLite 거래 기록 관리 |
+
+### 5단계 파이프라인
+
+1. **애널리스트** (병렬): 시장/소셜/뉴스/펀더멘털 → 각 리포트
+2. **리서치 토론**: 강세 vs 약세 연구원 → 리서치 매니저 중재
+3. **트레이더**: 리포트 + BM25 메모리 기반 투자 계획 수립
+4. **리스크 토론**: 공격/중립/보수 → 리스크 심판
+5. **포트폴리오 매니저**: 최종 BUY/HOLD/SELL 결정
+
+## 코드 컨벤션
+
+### 에이전트 패턴
+
+- 에이전트 생성: `create_*` 팩토리 함수 (예: `create_market_analyst(llm)`)
+- 그래프 노드: `node_func(state) → updated_state` 형태
+- 도구: LangChain 도구 바인딩, 벤더 추상화 레이어로 라우팅
+- 상태: `AgentState` TypedDict로 단계 간 데이터 전달
+
+### 설정 관리
+
+- `tradingagents/default_config.py`의 `DEFAULT_CONFIG` dict를 복사하여 사용
+- LLM 프로바이더, 토론 라운드, 데이터 벤더 등 중앙 관리
+- 환경변수: `.env` 파일 참조 (API 키, 스케줄, 한도 등)
+
+### LLM 클라이언트
+
+- `create_llm_client(provider, model, **kwargs)` 팩토리로 생성
+- 지원: openai, anthropic, google, xai, ollama, openrouter
+- Google: `thinking_level`, OpenAI: `reasoning_effort` 파라미터
+
+### 데이터 벤더
+
+- `dataflows/interface.py`에서 도구별 벤더 라우팅
+- 기본: yfinance (무료), 대안: AlphaVantage (유료)
+- 벤더 실패 시 자동 폴백
+
+## 주의사항
+
+- KIS API는 `KIS_VIRTUAL=true`로 모의투자 먼저 테스트
+- `bot.py`의 자동매매 스케줄(09:30/15:20 KST)은 실제 주문 실행 — 신중하게 수정
+- 커밋 메시지는 한국어로 간결하게 작성
+- Docker 배포 시 `data/`, `results/`, `reports/` 볼륨 마운트 필수
diff --git a/.github/instructions/git-push.instructions.md b/.github/instructions/git-push.instructions.md
new file mode 100644
index 00000000..cc503154
--- /dev/null
+++ b/.github/instructions/git-push.instructions.md
@@ -0,0 +1,31 @@
+---
+description: "코드 변경 작업 완료 후 항상 git add, commit, push를 수행하도록 지시합니다."
+---
+
+# Git Push 자동 수행
+
+코드 변경 작업을 완료한 후, 반드시 다음 단계를 순서대로 수행하세요:
+
+1. **버전 업데이트** — `bot.py`의 `BOT_VERSION` 값을 변경 내용에 맞게 올린다
+2. **git add** — GitKraken MCP 도구(`mcp_gitkraken_git_add_or_commit` action: add)로 스테이징
+3. **git commit** — GitKraken MCP 도구(`mcp_gitkraken_git_add_or_commit` action: commit)로 커밋 (한국어 메시지)
+4. **git push** — GitKraken MCP 도구(`mcp_gitkraken_git_push`)로 푸시
+
+## 중요: 도구 사용 규칙
+
+- **터미널 명령(`git add`, `git commit`, `git push`) 대신 반드시 MCP 또는 전용 도구를 사용한다.**
+- GitKraken MCP 도구를 우선 사용하고, 사용 불가 시에만 터미널을 대안으로 사용한다.
+
+## 버전 규칙 (semver)
+
+- `bot.py` 상단의 `BOT_VERSION = "X.Y.Z"` 를 반드시 업데이트한다.
+- **patch (+0.0.1)**: 버그 수정, 작은 변경, 리팩토링
+- **minor (+0.1.0)**: 새 기능 추가, 기존 기능 개선
+- **major (+1.0.0)**: 사용자가 명시적으로 요청한 경우에만
+- 판단이 어려우면 patch를 올린다.
+
+## 일반 규칙
+
+- 커밋 메시지는 변경 내용을 명확하게 설명해야 합니다.
+- 이미 커밋/푸시할 변경 사항이 없으면 건너뜁니다.
+- 사용자가 명시적으로 "푸시하지 마" 또는 "커밋하지 마"라고 요청하면 이 규칙을 따르지 않습니다.
diff --git a/.github/workflows/docker-ghcr.yml b/.github/workflows/docker-ghcr.yml
new file mode 100644
index 00000000..497fa825
--- /dev/null
+++ b/.github/workflows/docker-ghcr.yml
@@ -0,0 +1,58 @@
+name: Docker CI/CD (GHCR)
+
+on:
+ pull_request:
+ branches: [main]
+ push:
+ branches: [main]
+ tags:
+ - "v*"
+ workflow_dispatch:
+
+permissions:
+ contents: read
+ packages: write
+
+jobs:
+ docker:
+ runs-on: ubuntu-latest
+
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ - name: Set image name
+ run: echo "IMAGE_NAME=ghcr.io/${GITHUB_REPOSITORY,,}" >> $GITHUB_ENV
+
+ - name: Set up Docker Buildx
+ uses: docker/setup-buildx-action@v3
+
+ - name: Extract Docker metadata
+ id: meta
+ uses: docker/metadata-action@v5
+ with:
+ images: ${{ env.IMAGE_NAME }}
+ tags: |
+ type=ref,event=branch
+ type=ref,event=tag
+ type=sha
+ type=raw,value=latest,enable={{is_default_branch}}
+
+ - name: Login to GHCR
+ if: github.event_name != 'pull_request'
+ uses: docker/login-action@v3
+ with:
+ registry: ghcr.io
+ username: ${{ github.actor }}
+ password: ${{ secrets.GITHUB_TOKEN }}
+
+ - name: Build and push
+ uses: docker/build-push-action@v6
+ with:
+ context: .
+ file: Dockerfile
+ push: ${{ github.event_name != 'pull_request' }}
+ tags: ${{ steps.meta.outputs.tags }}
+ labels: ${{ steps.meta.outputs.labels }}
+ cache-from: type=gha
+ cache-to: type=gha,mode=max
diff --git a/.gitignore b/.gitignore
index 9a2904a9..332cf7b6 100644
--- a/.gitignore
+++ b/.gitignore
@@ -216,4 +216,4 @@ __marimo__/
.streamlit/secrets.toml
# Cache
-**/data_cache/
+**/data_cache/
\ No newline at end of file
diff --git a/Dockerfile b/Dockerfile
new file mode 100644
index 00000000..55665926
--- /dev/null
+++ b/Dockerfile
@@ -0,0 +1,15 @@
+FROM python:3.12-slim
+
+ENV PYTHONDONTWRITEBYTECODE=1 \
+ PYTHONUNBUFFERED=1 \
+ PIP_NO_CACHE_DIR=1 \
+ TZ=Asia/Seoul
+
+WORKDIR /app
+
+COPY requirements.txt .
+RUN pip install --upgrade pip && pip install -r requirements.txt
+
+COPY . .
+
+CMD ["python", "-u", "bot.py"]
diff --git a/README.md b/README.md
index 4c4856d1..ebd731cc 100644
--- a/README.md
+++ b/README.md
@@ -2,220 +2,456 @@
-
+---
-
+# TradingAgents: Discord 기반 멀티 에이전트 자동매매 봇
+
+> TradingAgents 프레임워크에 Discord 봇, 한국투자증권 Open API, KR/US 워치리스트 전략을 결합한 자동매매 프로젝트입니다.
---
-# TradingAgents: Multi-Agents LLM Financial Trading Framework
+## 목차
-## News
-- [2026-03] **TradingAgents v0.2.2** released with GPT-5.4/Gemini 3.1/Claude 4.6 model coverage, five-tier rating scale, OpenAI Responses API, Anthropic effort control, and cross-platform stability.
-- [2026-02] **TradingAgents v0.2.0** released with multi-provider LLM support (GPT-5.x, Gemini 3.x, Claude 4.x, Grok 4.x) and improved system architecture.
-- [2026-01] **Trading-R1** [Technical Report](https://arxiv.org/abs/2509.11420) released, with [Terminal](https://github.com/TauricResearch/Trading-R1) expected to land soon.
+- [한눈에 보기](#한눈에-보기)
+- [전략 개요](#전략-개요)
+- [에이전트 아키텍처](#에이전트-아키텍처)
+- [설치](#설치)
+- [환경 설정](#환경-설정)
+- [사용법](#사용법)
+- [KIS API 연동](#kis-api-연동)
+- [LLM과 데이터 소스](#llm과-데이터-소스)
+- [프로젝트 구조](#프로젝트-구조)
+- [운영 메모](#운영-메모)
+- [Citation](#citation)
-
+---
-> 🎉 **TradingAgents** officially released! We have received numerous inquiries about the work, and we would like to express our thanks for the enthusiasm in our community.
->
-> So we decided to fully open-source the framework. Looking forward to building impactful projects with you!
+## 한눈에 보기
-
+- Discord 슬래시 명령 11개로 분석, 잔고조회, 수동 주문, 스코어링, 손익 조회를 제공합니다.
+- 한국(KR)과 미국(US) 모두 워치리스트 기반 스코어링 뒤 상위 후보만 AI 분석합니다.
+- 자동매매는 `룰 기반 후보 선정 -> 상위 N개 AI 분석 -> BUY 종목만 균등매수 -> 오후 점검 -> 손절/익절 감시` 흐름으로 동작합니다.
+- 자동매수 예산은 `기준 자금(anchor) × 비율`로 계산할 수 있어, 예를 들어 `50%` 설정 시 절반씩 회전 투자할 수 있습니다.
+- 분석 보고서는 Markdown 파일로 저장되며, 필요하면 Discord에도 자동 업로드합니다.
+- 매매 이력과 실현손익은 SQLite로 누적 관리합니다.
-🚀 [TradingAgents](#tradingagents-framework) | ⚡ [Installation & CLI](#installation-and-cli) | 🎬 [Demo](https://www.youtube.com/watch?v=90gr5lwjIho) | 📦 [Package Usage](#tradingagents-package) | 🤝 [Contributing](#contributing) | 📄 [Citation](#citation)
+---
-
+## 전략 개요
-## TradingAgents Framework
+### 전체 흐름
-TradingAgents is a multi-agent trading framework that mirrors the dynamics of real-world trading firms. By deploying specialized LLM-powered agents: from fundamental analysts, sentiment experts, and technical analysts, to trader, risk management team, the platform collaboratively evaluates market conditions and informs trading decisions. Moreover, these agents engage in dynamic discussions to pinpoint the optimal strategy.
+```text
+워치리스트/랭킹 후보 수집
+-> 룰 기반 점수 계산
+-> 상위 후보만 TradingAgentsGraph AI 분석
+-> BUY 종목만 균등분할 매수
+-> 오후 매도 점검
+-> 손절/익절 모니터링
+```
-
-
-
+### 시장별 자동매매 흐름
-> TradingAgents framework is designed for research purposes. Trading performance may vary based on many factors, including the chosen backbone language models, model temperature, trading periods, the quality of data, and other non-deterministic factors. [It is not intended as financial, investment, or trading advice.](https://tauric.ai/disclaimer/)
+| 시장 | 후보 풀 | 자동 매수 | 오후 점검 | 장중 감시 |
+|------|---------|-----------|-----------|-----------|
+| KR | `KR_WATCHLIST` 우선, 비어 있으면 시총/거래량 랭킹 fallback | `AUTO_BUY_TIME` (기본 `09:30` KST), 예산 `AUTO_BUY_BUDGET_RATIO` 적용 | `AUTO_SELL_TIME` (기본 `15:20` KST) | `MONITOR_INTERVAL_MIN` 간격 |
+| US | `US_WATCHLIST` 우선, 비어 있으면 시총/거래량 랭킹 fallback | `US_AUTO_BUY_TIME` (기본 `09:35` ET), 예산 `US_AUTO_BUY_BUDGET_RATIO` 적용 | `US_AUTO_SELL_TIME` (기본 `15:50` ET) | `MONITOR_INTERVAL_MIN` 간격 |
-Our framework decomposes complex trading tasks into specialized roles. This ensures the system achieves a robust, scalable approach to market analysis and decision-making.
+### 현재 스코어링 규칙
-### Analyst Team
-- Fundamentals Analyst: Evaluates company financials and performance metrics, identifying intrinsic values and potential red flags.
-- Sentiment Analyst: Analyzes social media and public sentiment using sentiment scoring algorithms to gauge short-term market mood.
-- News Analyst: Monitors global news and macroeconomic indicators, interpreting the impact of events on market conditions.
-- Technical Analyst: Utilizes technical indicators (like MACD and RSI) to detect trading patterns and forecast price movements.
+KR/US 공통 골격은 같습니다.
-
-
-
+- 워치리스트 기본 점수: `+30`
+- 등락률 `0~2%`: `+25`
+- 등락률 `2~5%`: `+15`
+- 시가총액 랭크 진입: `+10`
+- 거래량 랭크 진입: `+5`
+- 필터: 등락률 `> 8%` 또는 `< -5%`면 제외
-### Researcher Team
-- Comprises both bullish and bearish researchers who critically assess the insights provided by the Analyst Team. Through structured debates, they balance potential gains against inherent risks.
+추가 메모:
-
-
-
+- KR은 KIS 현재가와 yfinance 전일 종가를 함께 써서 점수를 계산합니다.
+- US는 yfinance 가격 이력을 기본으로 쓰고, KIS 미국 현재가가 가능하면 현재가를 보정합니다.
+- 랭킹 보너스는 응답이 있을 때만 붙습니다.
+- 현재 코드 기준으로 미국 시총/거래량 랭킹은 모의투자에서 비활성 처리되어, 실전 환경에서만 반영됩니다.
-### Trader Agent
-- Composes reports from the analysts and researchers to make informed trading decisions. It determines the timing and magnitude of trades based on comprehensive market insights.
+### 오후 매도 정책
-
-
-
+- 워치리스트에 포함된 종목은 강제 청산하지 않고 스윙 보유합니다.
+- 워치리스트 밖 보유 종목만 오후 점검 시 시장가 매도합니다.
+- 손절/익절 조건은 워치리스트 여부와 관계없이 계속 감시합니다.
-### Risk Management and Portfolio Manager
-- Continuously evaluates portfolio risk by assessing market volatility, liquidity, and other risk factors. The risk management team evaluates and adjusts trading strategies, providing assessment reports to the Portfolio Manager for final decision.
-- The Portfolio Manager approves/rejects the transaction proposal. If approved, the order will be sent to the simulated exchange and executed.
+### 손절/익절 규칙
-
-
-
+- 손절: `STOP_LOSS_PCT` 이하
+- 익절: `TAKE_PROFIT_PCT` 이상
+- 감시 주기: `MONITOR_INTERVAL_MIN` 분
-## Installation and CLI
+---
-### Installation
+## 에이전트 아키텍처
+
+TradingAgentsGraph는 아래 구조로 동작합니다.
+
+### 1. 애널리스트 팀
+
+- 시장 애널리스트: 기술적 지표와 차트 흐름 분석
+- 소셜 미디어 애널리스트: 투자 심리와 센티먼트 분석
+- 뉴스 애널리스트: 뉴스와 이벤트 리스크 분석
+- 펀더멘털 애널리스트: 재무와 사업 체력 분석
+
+### 2. 리서치 팀
+
+- 강세 리서처
+- 약세 리서처
+- 리서치 매니저
+
+### 3. 트레이딩/리스크 팀
+
+- 트레이더
+- 공격적 리스크 매니저
+- 보수적 리스크 매니저
+- 중립적 리스크 매니저
+
+### 4. 최종 의사결정
+
+- 포트폴리오 매니저가 `BUY / SELL / HOLD`를 확정합니다.
+
+---
+
+## 설치
+
+### 1. 레포지토리 클론
-Clone TradingAgents:
```bash
git clone https://github.com/TauricResearch/TradingAgents.git
cd TradingAgents
```
-Create a virtual environment in any of your favorite environment managers:
-```bash
-conda create -n tradingagents python=3.13
-conda activate tradingagents
-```
-
-Install the package and its dependencies:
-```bash
-pip install .
-```
-
-### Required APIs
-
-TradingAgents supports multiple LLM providers. Set the API key for your chosen provider:
+### 2. 가상환경
```bash
-export OPENAI_API_KEY=... # OpenAI (GPT)
-export GOOGLE_API_KEY=... # Google (Gemini)
-export ANTHROPIC_API_KEY=... # Anthropic (Claude)
-export XAI_API_KEY=... # xAI (Grok)
-export OPENROUTER_API_KEY=... # OpenRouter
-export ALPHA_VANTAGE_API_KEY=... # Alpha Vantage
+python -m venv .venv
+source .venv/bin/activate
```
-For local models, configure Ollama with `llm_provider: "ollama"` in your config.
+Windows는 아래를 사용하세요.
+
+```bash
+.venv\Scripts\activate
+```
+
+### 3. 의존성 설치
+
+Discord 봇을 바로 실행하려면 아래 조합이 가장 안전합니다.
+
+```bash
+pip install -r requirements.txt python-dotenv
+pip install -e .
+```
+
+### 4. 환경변수 파일 준비
-Alternatively, copy `.env.example` to `.env` and fill in your keys:
```bash
cp .env.example .env
```
-### CLI Usage
+---
+
+## 환경 설정
+
+기본 템플릿은 [`.env.example`](/home/devuser/projects/TradingAgents2/.env.example)에 있습니다.
+
+```env
+# LLM Providers
+OPENAI_API_KEY=
+GOOGLE_API_KEY=
+ANTHROPIC_API_KEY=
+XAI_API_KEY=
+OPENROUTER_API_KEY=
+
+# Discord
+DISCORD_BOT_TOKEN=
+# DISCORD_CHANNEL_IDS=123456789012345678,987654321098765432
+
+# KIS
+KIS_APP_KEY=
+KIS_APP_SECRET=
+KIS_ACCOUNT_NO=12345678-01
+KIS_VIRTUAL=true
+KIS_MAX_ORDER_AMOUNT=1000000
+KR_WATCHLIST=005930,000660,005380,005490,035420,105560,069500,114800,226490,229200
+
+# US
+ENABLE_US_TRADING=false
+US_MAX_ORDER_AMOUNT=5000
+US_EXCHANGE_SEARCH_ORDER=NASD,NYSE,AMEX
+US_WATCHLIST=AAPL,MSFT,NVDA,AMZN,GOOGL,META,TSLA,AMD,AVGO,QQQ,SPY
+
+# Schedule
+DAY_TRADE_PICKS=5
+AUTO_BUY_BUDGET_RATIO=1.0
+AUTO_BUY_TIME=09:30
+AUTO_SELL_TIME=15:20
+US_DAY_TRADE_PICKS=5
+US_AUTO_BUY_BUDGET_RATIO=1.0
+US_AUTO_BUY_TIME=09:35
+US_AUTO_SELL_TIME=15:50
+
+# Risk / reports
+STOP_LOSS_PCT=-5.0
+TAKE_PROFIT_PCT=10.0
+MONITOR_INTERVAL_MIN=30
+REPORTS_DIR=reports
+AUTO_REPORT_UPLOAD=true
+
+# Optional bot model overrides
+DEEP_THINK_LLM=gemini-3-flash-preview
+QUICK_THINK_LLM=gemini-3-flash-preview
+MAX_DEBATE_ROUNDS=1
+```
+
+### 설정 메모
+
+- `DISCORD_CHANNEL_IDS`를 비워두면 수동 명령은 모든 채널에서 사용할 수 있습니다.
+- 자동매매 스케줄은 `DISCORD_CHANNEL_IDS`가 설정된 경우에만 실제로 동작합니다.
+- 미국 수동/자동 주문은 `ENABLE_US_TRADING=true`가 아니면 막힙니다.
+- `KIS_VIRTUAL=true`면 모의투자, `false`면 실전투자입니다.
+- `AUTO_BUY_BUDGET_RATIO=0.5`처럼 설정하면 KR 자동매수는 기준 자금의 50%만 사용합니다. `50%` 형식도 가능합니다.
+- `US_AUTO_BUY_BUDGET_RATIO`를 비워두면 미국 자동매수도 같은 비율을 사용합니다.
+- 기준 자금(anchor)은 시장별로 저장되는 자동매수 기준 예수금이며, 더 큰 예수금을 확인하면 자동으로 상향 갱신됩니다.
+- Discord 봇은 모델명만 환경변수로 덮어쓰고, 기본 provider는 [`tradingagents/default_config.py`](/home/devuser/projects/TradingAgents2/tradingagents/default_config.py) 설정을 따릅니다.
+
+### KIS 앱키 발급
+
+1. [한국투자증권 API 포털](https://apiportal.koreainvestment.com)에 로그인합니다.
+2. 앱키를 발급합니다.
+3. 모의투자와 실전투자는 앱키가 다릅니다.
+4. 계좌번호는 `12345678-01` 형식으로 입력합니다.
+
+### Discord 봇 토큰 발급
+
+1. [Discord Developer Portal](https://discord.com/developers/applications)에서 애플리케이션을 만듭니다.
+2. `Bot` 탭에서 토큰을 발급합니다.
+3. OAuth2에서 `bot`, `applications.commands` 스코프를 추가해 서버에 초대합니다.
+
+---
+
+## 사용법
+
+### Discord 봇 실행
-Launch the interactive CLI:
```bash
-tradingagents # installed command
-python -m cli.main # alternative: run directly from source
-```
-You will see a screen where you can select your desired tickers, analysis date, LLM provider, research depth, and more.
-
-
-
-
-
-An interface will appear showing results as they load, letting you track the agent's progress as it runs.
-
-
-
-
-
-
-
-
-
-## TradingAgents Package
-
-### Implementation Details
-
-We built TradingAgents with LangGraph to ensure flexibility and modularity. The framework supports multiple LLM providers: OpenAI, Google, Anthropic, xAI, OpenRouter, and Ollama.
-
-### Python Usage
-
-To use TradingAgents inside your code, you can import the `tradingagents` module and initialize a `TradingAgentsGraph()` object. The `.propagate()` function will return a decision. You can run `main.py`, here's also a quick example:
-
-```python
-from tradingagents.graph.trading_graph import TradingAgentsGraph
-from tradingagents.default_config import DEFAULT_CONFIG
-
-ta = TradingAgentsGraph(debug=True, config=DEFAULT_CONFIG.copy())
-
-# forward propagate
-_, decision = ta.propagate("NVDA", "2026-01-15")
-print(decision)
+python bot.py
```
-You can also adjust the default configuration to set your own choice of LLMs, debate rounds, etc.
+정상 실행 시 봇은 아래 정보를 콘솔에 출력합니다.
+
+- 동기화된 슬래시 명령 수
+- KR/US 자동매매 시각
+- 손절/익절 기준
+- 허용 채널 여부
+- 모의/실전 모드
+
+### 슬래시 명령어
+
+현재 등록되는 명령은 11개입니다.
+
+| 명령 | 설명 |
+|------|------|
+| `/분석 <티커> [날짜]` | 단일 종목 멀티 에이전트 AI 분석, 보고서 파일 첨부 |
+| `/대형주 [날짜]` | KR 스코어링 TOP5를 순차 분석하고 BUY 종목에 버튼 제공 |
+| `/잔고` | KRW/USD 계좌 요약과 보유 종목 조회 |
+| `/매수 <티커> [수량]` | 시장별 수동 예산 상한 기준 매수 확인 버튼 표시 |
+| `/매도 <티커> [수량]` | 보유 수량 기준 매도 확인 버튼 표시 |
+| `/상태` | 오늘 자동매매 실행 상태 조회 |
+| `/봇정보` | 스케줄, 설정, 계좌 요약, 오늘 이력을 한 번에 조회 |
+| `/스코어링 [시장] [count] [exclude_held]` | 실시간 후보 점수 조회 |
+| `/스코어규칙 [시장]` | 현재 코드 기준 스코어링 규칙 조회 |
+| `/수익` | 누적 실현손익, 승률, 종목별 요약 조회 |
+| `/수익초기화 [통화]` | 손익 집계 기준 시점 초기화 |
+
+### 수동 주문 동작 방식
+
+- `/매수`는 수량을 생략하면 `KIS_MAX_ORDER_AMOUNT` 또는 `US_MAX_ORDER_AMOUNT` 기준으로 자동 수량을 계산합니다.
+- `/매도`는 수량을 생략하면 전량 매도로 동작합니다.
+- 장이 닫혀 있으면 매수 버튼을 띄우지 않습니다.
+- 매수 확인 버튼은 5분, 매도 확인 버튼은 2분 뒤 만료됩니다.
+
+### 자동매매 스케줄
+
+자동매매는 허용 채널이 있을 때만 실행됩니다.
+
+#### KR 자동매수
+
+1. 워치리스트 점수 계산
+2. 보유 종목 제외
+3. 상위 `DAY_TRADE_PICKS`만 AI 분석
+4. BUY 종목만 `기준 자금(anchor) × AUTO_BUY_BUDGET_RATIO` 예산 안에서 균등분할 매수
+5. 장 시작 전 분석이 끝나면 개장까지 대기 후 주문
+
+#### KR 오후 점검
+
+1. 보유 종목 조회
+2. `KR_WATCHLIST` 밖 종목만 시장가 매도
+3. 워치리스트 종목은 유지
+4. 결과를 Discord와 손익 DB에 반영
+
+#### US 자동매매
+
+- `ENABLE_US_TRADING=true`일 때만 실행됩니다.
+- KR과 동일한 흐름으로 동작하되 시간대만 ET 기준이며, 예산은 `US_AUTO_BUY_BUDGET_RATIO`를 따릅니다.
+- 오후 점검도 `US_WATCHLIST` 밖 종목만 정리합니다.
+
+#### 손절/익절 모니터링
+
+- KR/US 보유 종목 전체를 감시합니다.
+- 손절 또는 익절 조건에 도달하면 확인 없이 자동 매도합니다.
+
+### 분석 보고서
+
+- 보고서는 `REPORTS_DIR` 아래 Markdown 파일로 저장됩니다.
+- `AUTO_REPORT_UPLOAD=true`면 자동매매 중 생성된 보고서도 Discord로 업로드합니다.
+
+### Python에서 직접 사용
```python
from tradingagents.graph.trading_graph import TradingAgentsGraph
from tradingagents.default_config import DEFAULT_CONFIG
config = DEFAULT_CONFIG.copy()
-config["llm_provider"] = "openai" # openai, google, anthropic, xai, openrouter, ollama
-config["deep_think_llm"] = "gpt-5.2" # Model for complex reasoning
-config["quick_think_llm"] = "gpt-5-mini" # Model for quick tasks
-config["max_debate_rounds"] = 2
+config["deep_think_llm"] = "gemini-3-flash-preview"
+config["quick_think_llm"] = "gemini-3-flash-preview"
ta = TradingAgentsGraph(debug=True, config=config)
-_, decision = ta.propagate("NVDA", "2026-01-15")
+final_state, decision = ta.propagate("AAPL", "2026-03-18")
print(decision)
```
-See `tradingagents/default_config.py` for all configuration options.
+### CLI 사용
-## Contributing
+```bash
+python -m cli.main
+```
-We welcome contributions from the community! Whether it's fixing a bug, improving documentation, or suggesting a new feature, your input helps make this project better. If you are interested in this line of research, please consider joining our open-source financial AI research community [Tauric Research](https://tauric.ai/).
+또는 설치 후:
+
+```bash
+tradingagents
+```
+
+---
+
+## KIS API 연동
+
+핵심 구현은 [`kis_client.py`](/home/devuser/projects/TradingAgents2/kis_client.py)에 있습니다.
+
+### 현재 실제로 쓰는 API
+
+| 기능 | 엔드포인트 | 비고 |
+|------|------------|------|
+| OAuth 토큰 | `POST /oauth2/tokenP` | 실전/모의 공통 |
+| KR 잔고 조회 | `GET /uapi/domestic-stock/v1/trading/inquire-balance` | KRW 요약 포함 |
+| US 잔고 조회 | `GET /uapi/overseas-stock/v1/trading/inquire-balance` | 거래소별 합산 |
+| KR 현재가 | `GET /uapi/domestic-stock/v1/quotations/inquire-price` | 국내 6자리 코드 |
+| US 현재가 | `GET /uapi/overseas-price/v1/quotations/price` | 거래소 탐색 포함 |
+| KR 주문 | `POST /uapi/domestic-stock/v1/trading/order-cash` | 시장가 매수/매도 |
+| US 주문 | `POST /uapi/overseas-stock/v1/trading/order` | 시장가 매수/매도 |
+| KR 시총 랭킹 | `GET /uapi/domestic-stock/v1/ranking/market-cap` | 스코어링 보너스 |
+| KR 거래량 랭킹 | `GET /uapi/domestic-stock/v1/quotations/volume-rank` | 스코어링 보너스 |
+| US 시총 랭킹 | `GET /uapi/overseas-stock/v1/ranking/market-cap` | 실전에서만 사용 |
+| US 거래량 랭킹 | `GET /uapi/overseas-stock/v1/ranking/trade-vol` | 실전에서만 사용 |
+
+### 코드에 남아 있는 KR 보조 랭킹 유틸리티
+
+현재 기본 전략은 사용하지 않지만, 아래 API 래퍼도 구현돼 있습니다.
+
+- 체결강도 순위: `get_volume_power()`
+- 등락률 순위: `get_fluctuation_rank()`
+- 대량체결 순위: `get_bulk_trans()`
+
+### 중요한 운영 차이
+
+- KR 워치리스트가 비어 있으면 시총 랭킹, 그다음 거래량 랭킹으로 후보를 보완합니다.
+- US 워치리스트가 비어 있어도 같은 순서로 fallback 합니다.
+- 미국 시총/거래량 랭킹은 현재 코드에서 모의투자 시 빈 결과를 반환하도록 되어 있습니다.
+
+---
+
+## LLM과 데이터 소스
+
+### 프레임워크가 지원하는 LLM 제공자
+
+- OpenAI
+- Google
+- Anthropic
+- xAI
+- OpenRouter
+- Ollama
+
+### 현재 Discord 봇 기본값
+
+- provider 기본값: [`tradingagents/default_config.py`](/home/devuser/projects/TradingAgents2/tradingagents/default_config.py)
+- 봇 환경변수로 덮는 값: `DEEP_THINK_LLM`, `QUICK_THINK_LLM`, `MAX_DEBATE_ROUNDS`
+- 데이터 수집: yfinance
+- 주문/잔고/랭킹: KIS Open API
+
+---
+
+## 프로젝트 구조
+
+```text
+TradingAgents/
+├── bot.py
+├── kis_client.py
+├── trade_history.py
+├── main.py
+├── README.md
+├── .env.example
+├── requirements.txt
+├── pyproject.toml
+├── reports/
+├── data/
+├── cli/
+└── tradingagents/
+ ├── agents/
+ ├── dataflows/
+ ├── graph/
+ ├── llm_clients/
+ └── default_config.py
+```
+
+주요 파일:
+
+- [`bot.py`](/home/devuser/projects/TradingAgents2/bot.py): Discord 봇, 명령어, 자동매매 스케줄
+- [`kis_client.py`](/home/devuser/projects/TradingAgents2/kis_client.py): KIS REST API 래퍼
+- [`trade_history.py`](/home/devuser/projects/TradingAgents2/trade_history.py): SQLite 기반 매매/손익 기록
+- [`tradingagents/graph/trading_graph.py`](/home/devuser/projects/TradingAgents2/tradingagents/graph/trading_graph.py): 멀티 에이전트 분석 그래프
+
+---
+
+## 운영 메모
+
+- 수동 주문은 예산 상한을 넘기면 차단됩니다.
+- 자동매매는 `daily_state` 기록으로 중복 실행을 막습니다.
+- 보고서는 저장 실패 시에도 Discord 전송을 가능한 형태로 fallback 합니다.
+- 분석은 `asyncio.Lock`으로 직렬화되어 동시에 여러 건이 돌지 않습니다.
+- 모의투자에서는 실전과 일부 API 응답 차이가 있을 수 있습니다.
+
+> 이 프로젝트는 연구/자동화 실험용입니다. 실제 주문 전에는 반드시 모의투자로 충분히 검증하세요.
+
+---
## Citation
-Please reference our work if you find *TradingAgents* provides you with some help :)
-
-```
+```bibtex
@misc{xiao2025tradingagentsmultiagentsllmfinancial,
- title={TradingAgents: Multi-Agents LLM Financial Trading Framework},
+ title={TradingAgents: Multi-Agents LLM Financial Trading Framework},
author={Yijia Xiao and Edward Sun and Di Luo and Wei Wang},
year={2025},
eprint={2412.20138},
archivePrefix={arXiv},
primaryClass={q-fin.TR},
- url={https://arxiv.org/abs/2412.20138},
+ url={https://arxiv.org/abs/2412.20138},
}
```
diff --git a/bot.py b/bot.py
new file mode 100644
index 00000000..d7bc876b
--- /dev/null
+++ b/bot.py
@@ -0,0 +1,3397 @@
+"""
+TradingAgents Discord Bot
+- 슬래시 명령: /분석, /대형주, /잔고, /매수, /매도, /상태, /봇정보, /스코어링, /스코어규칙, /수익, /수익초기화
+- 대형주+ETF 워치리스트 자동매매 / 손절·익절 감시
+- 한국투자증권 API 연동 매매
+"""
+
+import os
+import asyncio
+import datetime
+import re
+from pathlib import Path
+from io import BytesIO
+from zoneinfo import ZoneInfo
+
+import discord
+import yfinance as yf
+from discord import app_commands
+from discord.ext import tasks
+from dotenv import load_dotenv
+
+from tradingagents.graph.trading_graph import TradingAgentsGraph
+from tradingagents.default_config import DEFAULT_CONFIG
+from kis_client import KISClient, format_krw, format_usd
+from trade_history import (
+ record_trade,
+ record_pnl,
+ get_total_pnl,
+ get_total_pnl_by_currency,
+ get_recent_pnl,
+ get_ticker_summary,
+ reset_pnl_history,
+ is_action_done, mark_action_done, get_daily_state,
+ ensure_budget_anchor,
+ get_budget_anchor,
+)
+
+load_dotenv()
+
+# ─── Version ───────────────────────────────────────────────────
+BOT_VERSION = "2.2.0"
+
+# ─── Config ────────────────────────────────────────────────────
+DISCORD_TOKEN = os.getenv("DISCORD_BOT_TOKEN")
+if not DISCORD_TOKEN:
+ raise RuntimeError("DISCORD_BOT_TOKEN이 .env에 설정되어 있지 않습니다.")
+
+# 봇이 동작할 채널 ID (쉼표로 여러 개 지정 가능, 비워두면 모든 채널에서 동작)
+# 예: DISCORD_CHANNEL_IDS=123456789012345678,987654321098765432
+_channel_ids_raw = os.getenv("DISCORD_CHANNEL_IDS", "")
+ALLOWED_CHANNEL_IDS: set[int] = {
+ int(cid.strip()) for cid in _channel_ids_raw.split(",") if cid.strip()
+}
+
+
+def _parse_budget_ratio(env_name: str, default: str = "1.0") -> float:
+ """0~1 또는 0~100(%) 형식의 비율 값을 정규화한다."""
+ raw = os.getenv(env_name, default).strip()
+ if raw.endswith("%"):
+ raw = raw[:-1].strip()
+
+ try:
+ ratio = float(raw)
+ except ValueError as exc:
+ raise RuntimeError(
+ f"{env_name} 값이 올바르지 않습니다. 예: 0.5 또는 50%"
+ ) from exc
+
+ if ratio > 1.0:
+ ratio /= 100.0
+
+ if not (0.0 < ratio <= 1.0):
+ raise RuntimeError(f"{env_name} 값은 0보다 크고 1 이하(또는 100% 이하)여야 합니다.")
+ return ratio
+
+
+def _is_allowed_channel(channel_id: int | None) -> bool:
+ """채널 제한이 설정되어 있으면 허용된 채널인지 확인."""
+ if channel_id is None:
+ return False
+ if not ALLOWED_CHANNEL_IDS:
+ return True # 설정 안 하면 모든 채널 허용
+ return channel_id in ALLOWED_CHANNEL_IDS
+
+# 손절/익절 임계값 (%)
+STOP_LOSS_PCT = float(os.getenv("STOP_LOSS_PCT", "-5.0"))
+TAKE_PROFIT_PCT = float(os.getenv("TAKE_PROFIT_PCT", "10.0"))
+MONITOR_INTERVAL_MIN = int(os.getenv("MONITOR_INTERVAL_MIN", "30"))
+
+# 데이 트레이딩 설정
+DAY_TRADE_PICKS = int(os.getenv("DAY_TRADE_PICKS", "5")) # 매일 매수할 종목 수
+AUTO_BUY_TIME = os.getenv("AUTO_BUY_TIME", "09:30") # 자동 매수 시각 (HH:MM)
+AUTO_SELL_TIME = os.getenv("AUTO_SELL_TIME", "15:20") # 자동 매도 시각 (HH:MM)
+AUTO_BUY_BUDGET_RATIO = _parse_budget_ratio("AUTO_BUY_BUDGET_RATIO", "1.0")
+_buy_h, _buy_m = (int(x) for x in AUTO_BUY_TIME.split(":"))
+_sell_h, _sell_m = (int(x) for x in AUTO_SELL_TIME.split(":"))
+
+# 미국 데이 트레이딩 설정
+ENABLE_US_TRADING = os.getenv("ENABLE_US_TRADING", "false").lower() == "true"
+US_DAY_TRADE_PICKS = int(os.getenv("US_DAY_TRADE_PICKS", "5"))
+US_AUTO_BUY_TIME = os.getenv("US_AUTO_BUY_TIME", "09:35")
+US_AUTO_SELL_TIME = os.getenv("US_AUTO_SELL_TIME", "15:50")
+US_AUTO_BUY_BUDGET_RATIO = _parse_budget_ratio(
+ "US_AUTO_BUY_BUDGET_RATIO",
+ os.getenv("AUTO_BUY_BUDGET_RATIO", "1.0"),
+)
+_us_buy_h, _us_buy_m = (int(x) for x in US_AUTO_BUY_TIME.split(":"))
+_us_sell_h, _us_sell_m = (int(x) for x in US_AUTO_SELL_TIME.split(":"))
+
+config = DEFAULT_CONFIG.copy()
+config["deep_think_llm"] = os.getenv("DEEP_THINK_LLM", "gemini-3-flash-preview")
+config["quick_think_llm"] = os.getenv("QUICK_THINK_LLM", "gemini-3-flash-preview")
+config["max_debate_rounds"] = int(os.getenv("MAX_DEBATE_ROUNDS", "1"))
+config["data_vendors"] = {
+ "core_stock_apis": "yfinance",
+ "technical_indicators": "yfinance",
+ "fundamental_data": "yfinance",
+ "news_data": "yfinance",
+}
+
+
+# ─── Bot Setup ─────────────────────────────────────────────────
+intents = discord.Intents.default()
+intents.message_content = True
+
+bot = discord.Client(intents=intents)
+tree = app_commands.CommandTree(bot)
+
+_analysis_lock = asyncio.Lock()
+
+# ─── KIS 클라이언트 초기화 ──────────────────────────────────
+kis = KISClient()
+KST = ZoneInfo("Asia/Seoul")
+NY_TZ = ZoneInfo("America/New_York")
+TICKER_PATTERN = re.compile(r"^[A-Z0-9][A-Z0-9.\-]{0,14}$")
+REPORTS_DIR = Path(os.getenv("REPORTS_DIR", "reports"))
+REPORTS_DIR.mkdir(parents=True, exist_ok=True)
+AUTO_REPORT_UPLOAD = os.getenv("AUTO_REPORT_UPLOAD", "true").lower() == "true"
+_analysis_symbol_cache: dict[str, str] = {}
+
+
+def _log(level: str, event: str, message: str):
+ now = datetime.datetime.now(KST).strftime("%Y-%m-%d %H:%M:%S")
+ print(f"[{now}] [{level}] [{event}] {message}")
+
+
+def _interaction_actor(interaction: discord.Interaction) -> str:
+ user = interaction.user
+ user_label = str(user) if user else "unknown"
+ return f"user={user_label} channel={interaction.channel_id}"
+
+
+def _latest_yf_close(symbol: str) -> float:
+ """yfinance 심볼의 최근 종가 조회 (실패 시 0)."""
+ try:
+ hist = yf.Ticker(symbol).history(period="7d", interval="1d")
+ if hist.empty or "Close" not in hist.columns:
+ return 0.0
+ close = hist["Close"].dropna()
+ if close.empty:
+ return 0.0
+ return float(close.iloc[-1])
+ except Exception:
+ return 0.0
+
+
+def _resolve_analysis_symbol(
+ ticker: str,
+ market: str | None = None,
+ reference_price: float | None = None,
+) -> str:
+ """분석용 심볼 정규화.
+
+ - US: 티커 그대로 사용
+ - KR 6자리: .KS/.KQ 중 yfinance 데이터/가격 근접도로 자동 판별
+ """
+ t = (ticker or "").upper().strip()
+ m = (market or kis.detect_market(t)).upper()
+ if m != "KR":
+ return t
+ if t.endswith((".KS", ".KQ")):
+ return t
+ if not (t.isdigit() and len(t) == 6):
+ return t
+
+ if t in _analysis_symbol_cache:
+ return _analysis_symbol_cache[t]
+
+ ref = float(reference_price or 0)
+ if ref <= 0 and kis.is_configured:
+ try:
+ ref = float(kis.get_price(t, market="KR"))
+ except Exception:
+ ref = 0.0
+
+ candidates = [f"{t}.KS", f"{t}.KQ"]
+ prices = {sym: _latest_yf_close(sym) for sym in candidates}
+ available = {sym: px for sym, px in prices.items() if px > 0}
+
+ if not available:
+ resolved = f"{t}.KS"
+ elif len(available) == 1:
+ resolved = next(iter(available))
+ elif ref > 0:
+ resolved = min(
+ available.keys(),
+ key=lambda sym: abs(available[sym] - ref) / max(ref, 1.0),
+ )
+ else:
+ resolved = max(available.keys(), key=lambda sym: available[sym])
+
+ _analysis_symbol_cache[t] = resolved
+ if resolved != f"{t}.KS":
+ _log("INFO", "ANALYSIS_SYMBOL_RESOLVED", f"ticker={t} resolved={resolved}")
+ return resolved
+
+
+def _yf_ticker(ticker: str, reference_price: float | None = None) -> str:
+ """TradingAgents에 전달할 yfinance 심볼 반환."""
+ t = (ticker or "").upper().strip()
+ market = kis.detect_market(t)
+ if market == "KR":
+ return _resolve_analysis_symbol(t, market="KR", reference_price=reference_price)
+ return t
+
+
+def _market_of_ticker(ticker: str) -> str:
+ return kis.detect_market(ticker)
+
+
+def _currency_of_market(market: str) -> str:
+ return "USD" if market == "US" else "KRW"
+
+
+def _format_money(amount: float, currency: str) -> str:
+ if currency == "USD":
+ return format_usd(amount)
+ return f"{amount:,.0f}원"
+
+
+def _save_report_markdown(
+ report_text: str,
+ *,
+ market: str,
+ ticker: str,
+ trade_date: str,
+ scope: str,
+) -> Path:
+ """분석 보고서를 reports 디렉터리에 저장."""
+ safe_market = re.sub(r"[^A-Z0-9_-]", "_", (market or "NA").upper())
+ safe_ticker = re.sub(r"[^A-Z0-9._-]", "_", (ticker or "UNKNOWN").upper())
+ safe_scope = re.sub(r"[^A-Z0-9_-]", "_", (scope or "analysis").upper())
+ stamp = datetime.datetime.now(KST).strftime("%Y%m%d_%H%M%S")
+ filename = f"{stamp}_{safe_scope}_{safe_market}_{safe_ticker}_{trade_date}.md"
+ path = REPORTS_DIR / filename
+ path.write_text(report_text, encoding="utf-8")
+ _log("INFO", "REPORT_SAVED", f"path={path}")
+ return path
+
+
+def _prepare_report_attachment(
+ report_text: str,
+ *,
+ market: str,
+ ticker: str,
+ trade_date: str,
+ scope: str,
+) -> tuple[discord.File, Path | None]:
+ """디스코드 업로드용 파일 객체와 (가능하면) 로컬 저장 경로를 반환."""
+ try:
+ saved_path = _save_report_markdown(
+ report_text,
+ market=market,
+ ticker=ticker,
+ trade_date=trade_date,
+ scope=scope,
+ )
+ return discord.File(str(saved_path), filename=saved_path.name), saved_path
+ except Exception as e:
+ _log(
+ "ERROR",
+ "REPORT_SAVE_FAIL",
+ f"scope={scope} market={market} ticker={ticker} error={str(e)[:160]}",
+ )
+ fallback_name = (
+ f"{scope}_{market}_{re.sub(r'[^A-Z0-9._-]', '_', ticker.upper())}_{trade_date}.md"
+ )
+ return discord.File(fp=BytesIO(report_text.encode("utf-8")), filename=fallback_name), None
+
+
+def _parse_trade_date(date_text: str | None) -> str:
+ """사용자 입력 날짜를 YYYY-MM-DD로 정규화."""
+ if not date_text:
+ return str(datetime.date.today())
+ try:
+ parsed = datetime.datetime.strptime(date_text.strip(), "%Y-%m-%d").date()
+ return parsed.isoformat()
+ except ValueError as exc:
+ raise ValueError("날짜 형식이 올바르지 않습니다. YYYY-MM-DD 형식으로 입력하세요.") from exc
+
+
+def _validate_ticker_format(ticker: str) -> str | None:
+ """티커 문자열 형식 검증."""
+ if not ticker:
+ return "티커를 입력해주세요."
+ if not TICKER_PATTERN.fullmatch(ticker):
+ return "티커 형식이 올바르지 않습니다. 예: AAPL, BRK-B, 005930"
+ return None
+
+
+def _ticker_has_market_data(ticker: str) -> bool:
+ """실제 종목 데이터가 존재하는지 확인."""
+ market = _market_of_ticker(ticker)
+ kr_price = 0.0
+
+ # 한국 6자리 종목은 KIS 시세를 우선 확인
+ if market == "KR" and kis.is_configured:
+ try:
+ kr_price = float(kis.get_price(ticker, market="KR"))
+ if kr_price <= 0:
+ return False
+ except Exception as e:
+ _log("WARN", "TICKER_VALIDATE_KIS_FAIL", f"ticker={ticker} error={str(e)[:160]}")
+
+ # 글로벌 티커 포함 yfinance로 최종 확인
+ try:
+ yf_symbol = _yf_ticker(ticker, reference_price=kr_price if market == "KR" else None)
+ hist = yf.Ticker(yf_symbol).history(period="1mo", interval="1d")
+ if hist.empty or "Close" not in hist.columns:
+ return False
+ return not hist["Close"].dropna().empty
+ except Exception:
+ return False
+
+
+async def _validate_analysis_ticker(ticker: str) -> tuple[bool, str]:
+ """분석 요청 전에 티커 유효성 검증."""
+ format_error = _validate_ticker_format(ticker)
+ if format_error:
+ return False, format_error
+
+ loop = asyncio.get_running_loop()
+ has_data = await loop.run_in_executor(None, _ticker_has_market_data, ticker)
+ if not has_data:
+ return (
+ False,
+ f"`{ticker}` 종목 데이터를 찾지 못했습니다. "
+ "오타 여부와 거래소 접미사(예: 005930, AAPL, 7203.T)를 확인해주세요.",
+ )
+ return True, ""
+
+
+def _is_market_day(market: str = "KR") -> bool:
+ """시장 거래일 여부 확인."""
+ market = market.upper()
+ now = datetime.datetime.now(NY_TZ if market == "US" else KST).date()
+ if market == "US":
+ return kis.is_market_open(now, market="US")
+
+ if now.weekday() >= 5:
+ return False
+ if kis.is_configured:
+ return kis.is_market_open(now, market="KR")
+ return True
+
+
+def _is_market_open_now(market: str = "KR") -> bool:
+ """시장 정규장 시간 여부 확인."""
+ return kis.is_market_open_now(market=market.upper())
+
+
+def _market_open_context(market: str = "KR") -> tuple[ZoneInfo, datetime.time, str, str]:
+ """시장별 정규장 시작 시각과 표시용 타임존 라벨."""
+ market = market.upper()
+ if market == "US":
+ return NY_TZ, datetime.time(9, 30), "09:30", "ET"
+ return KST, datetime.time(9, 0), "09:00", "KST"
+
+
+def _is_before_market_open(market: str = "KR") -> bool:
+ """시장 개장 전인지 확인."""
+ tz, open_time, _, _ = _market_open_context(market)
+ now = datetime.datetime.now(tz)
+ return now.time() < open_time
+
+
+async def _wait_for_market_open(
+ channel: discord.abc.Messageable,
+ market: str = "KR",
+) -> bool:
+ """개장 전이면 개장까지 대기하고, 장 마감 후면 False를 반환."""
+ market = market.upper()
+ if _is_market_open_now(market):
+ return True
+ if not _is_before_market_open(market):
+ return False
+
+ _, _, open_label, _ = _market_open_context(market)
+ market_label = "미국 장" if market == "US" else "장"
+ await channel.send(f"⏳ {market_label}이 아직 열리지 않았습니다. {open_label} 개장까지 대기 중…")
+ _log("INFO", f"{market}_AUTO_BUY_WAIT_MARKET", "장 전 분석 완료, 개장 대기")
+ while not _is_market_open_now(market):
+ await asyncio.sleep(10)
+ await channel.send("🔔 **장이 열렸습니다!** 매수 주문을 진행합니다.")
+ _log("INFO", f"{market}_AUTO_BUY_MARKET_OPENED", "개장 확인, 매수 진행")
+ return True
+
+
+def _resolve_scoring_watchlist(
+ configured_watchlist: list[str],
+ cap_rank: list[dict],
+ volume_rank: list[dict],
+ *,
+ market: str,
+) -> list[str]:
+ """워치리스트 미설정 시 공식 랭킹 결과로 후보 풀을 보완한다."""
+ market = market.upper()
+ if configured_watchlist:
+ return configured_watchlist
+
+ prefix = "US_" if market == "US" else ""
+ watchlist_name = f"{market}_WATCHLIST"
+ if cap_rank:
+ watchlist = [item["ticker"] for item in cap_rank]
+ _log("INFO", f"{prefix}SCORING_FALLBACK_CAP", f"{watchlist_name} 미설정 → 시총 TOP{len(watchlist)} 사용")
+ return watchlist
+ if volume_rank:
+ watchlist = [item["ticker"] for item in volume_rank]
+ _log(
+ "INFO",
+ f"{prefix}SCORING_FALLBACK_VOLUME",
+ f"{watchlist_name} 미설정 → 거래량 TOP{len(watchlist)} 사용",
+ )
+ return watchlist
+
+ reason = f"{watchlist_name} 미설정 + 시총/거래량 조회 실패"
+ _log("WARN", f"{prefix}SCORING_NO_CANDIDATES", reason)
+ return []
+
+
+def _auto_buy_budget_ratio(market: str = "KR") -> float:
+ return US_AUTO_BUY_BUDGET_RATIO if market.upper() == "US" else AUTO_BUY_BUDGET_RATIO
+
+
+def _compute_auto_buy_budget(market: str, available_cash: float) -> dict[str, float]:
+ """자동매수에 사용할 오늘 예산을 계산한다.
+
+ - 기준 자금(anchor): 시장별로 저장되는 최대 확인 예수금
+ - 일일 예산: anchor × 설정 비율
+ - 실제 사용 가능 예산: min(현재 예수금, 일일 예산)
+ """
+ market = market.upper()
+ cash = max(float(available_cash), 0.0)
+ ratio = _auto_buy_budget_ratio(market)
+ anchor = ensure_budget_anchor(market, cash) if cash > 0 else get_budget_anchor(market)
+ anchor = max(float(anchor), cash)
+ target_budget = anchor * ratio if anchor > 0 else cash * ratio
+ usable_budget = min(cash, target_budget) if cash > 0 else 0.0
+
+ return {
+ "market": market,
+ "available_cash": cash,
+ "anchor": anchor,
+ "ratio": ratio,
+ "target_budget": target_budget,
+ "usable_budget": usable_budget,
+ }
+
+
+# ─── Helper: 보고서 생성 ──────────────────────────────────────
+def _build_report_text(
+ final_state: dict,
+ ticker: str,
+ *,
+ market: str | None = None,
+ analysis_symbol: str | None = None,
+) -> str:
+ """final_state에서 Markdown 보고서 텍스트 생성."""
+ sections: list[str] = []
+
+ analyst_parts = []
+ if final_state.get("market_report"):
+ analyst_parts.append(("📊 시장 애널리스트", final_state["market_report"]))
+ if final_state.get("sentiment_report"):
+ analyst_parts.append(("💬 소셜 미디어 애널리스트", final_state["sentiment_report"]))
+ if final_state.get("news_report"):
+ analyst_parts.append(("📰 뉴스 애널리스트", final_state["news_report"]))
+ if final_state.get("fundamentals_report"):
+ analyst_parts.append(("📈 펀더멘털 애널리스트", final_state["fundamentals_report"]))
+ if analyst_parts:
+ content = "\n\n".join(f"### {name}\n{text}" for name, text in analyst_parts)
+ sections.append(f"## I. 애널리스트팀 보고서\n\n{content}")
+
+ if final_state.get("investment_debate_state"):
+ debate = final_state["investment_debate_state"]
+ research_parts = []
+ if debate.get("bull_history"):
+ research_parts.append(("🟢 강세 애널리스트", debate["bull_history"]))
+ if debate.get("bear_history"):
+ research_parts.append(("🔴 약세 애널리스트", debate["bear_history"]))
+ if debate.get("judge_decision"):
+ research_parts.append(("⚖️ 리서치 매니저", debate["judge_decision"]))
+ if research_parts:
+ content = "\n\n".join(f"### {name}\n{text}" for name, text in research_parts)
+ sections.append(f"## II. 리서치팀 판단\n\n{content}")
+
+ if final_state.get("trader_investment_plan"):
+ sections.append(
+ f"## III. 트레이딩팀 계획\n\n### 🏦 트레이더\n{final_state['trader_investment_plan']}"
+ )
+
+ if final_state.get("risk_debate_state"):
+ risk = final_state["risk_debate_state"]
+ risk_parts = []
+ if risk.get("aggressive_history"):
+ risk_parts.append(("🔥 공격적 애널리스트", risk["aggressive_history"]))
+ if risk.get("conservative_history"):
+ risk_parts.append(("🛡️ 보수적 애널리스트", risk["conservative_history"]))
+ if risk.get("neutral_history"):
+ risk_parts.append(("⚖️ 중립적 애널리스트", risk["neutral_history"]))
+ if risk_parts:
+ content = "\n\n".join(f"### {name}\n{text}" for name, text in risk_parts)
+ sections.append(f"## IV. 리스크 관리팀 결정\n\n{content}")
+
+ if risk.get("judge_decision"):
+ sections.append(
+ f"## V. 포트폴리오 매니저 결정\n\n### 💼 포트폴리오 매니저\n{risk['judge_decision']}"
+ )
+
+ now = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
+ header_lines = [f"# 📋 트레이딩 분석 보고서: {ticker}", "", f"생성일시: {now}"]
+ if market:
+ header_lines.append(f"시장: {market}")
+ if analysis_symbol and analysis_symbol.upper() != ticker.upper():
+ header_lines.append(f"분석 심볼: {analysis_symbol}")
+ header = "\n".join(header_lines) + "\n\n"
+ return header + "\n\n".join(sections)
+
+
+def _extract_decision_summary(
+ final_state: dict,
+ decision: str,
+ ticker: str,
+ market: str | None = None,
+) -> str:
+ """Discord Embed에 넣을 요약 문자열 생성."""
+ market = (market or _market_of_ticker(ticker)).upper()
+ lines = [f"**시장:** {market}", f"**종목:** {ticker}", f"**최종 결정:** {decision}"]
+ if final_state.get("investment_plan"):
+ plan = final_state["investment_plan"]
+ if len(plan) > 300:
+ plan = plan[:300] + "…"
+ lines.append(f"**투자 계획 요약:**\n{plan}")
+ return "\n".join(lines)
+
+
+async def _show_trade_button(
+ channel: discord.abc.Messageable,
+ ticker: str,
+ decision: str,
+ market: str | None = None,
+):
+ """개별 분석 결과에 따라 BUY/SELL 확인 버튼을 표시한다."""
+ if not kis.is_configured:
+ return
+
+ market = (market or _market_of_ticker(ticker)).upper()
+ currency = _currency_of_market(market)
+ if market == "US" and not kis.enable_us_trading:
+ await channel.send(
+ "ℹ️ 미국 자동주문은 비활성화되어 있습니다. `.env`의 "
+ "`ENABLE_US_TRADING=true` 설정 후 사용하세요."
+ )
+ return
+ mode_label = "🧪 모의투자" if kis.virtual else "💰 실전투자"
+ loop = asyncio.get_running_loop()
+
+ if decision.upper() == "BUY":
+ if not _is_market_open_now(market):
+ _log("INFO", "MANUAL_BUY_BLOCKED", f"market={market} ticker={ticker}")
+ await channel.send(
+ f"ℹ️ `{ticker}`({market}) BUY 신호이지만 현재 장외/휴장이라 "
+ "수동 매수 버튼을 표시하지 않습니다."
+ )
+ return
+ try:
+ price = await loop.run_in_executor(None, kis.get_price, ticker, market)
+ if price <= 0:
+ return
+ budget = kis.us_max_order_amount if market == "US" else kis.max_order_amount
+ qty = int(budget // price)
+ if qty <= 0:
+ await channel.send(
+ f"⚠️ {ticker} — 예산({_format_money(budget, currency)}) 대비 "
+ f"현재가({_format_money(price, currency)})가 높아 매수 불가"
+ )
+ return
+ view = BuyConfirmView(
+ ticker=ticker,
+ name=ticker,
+ qty=qty,
+ price=price,
+ market=market,
+ currency=currency,
+ )
+ embed = discord.Embed(
+ title=f"🛒 {ticker} 매수 확인",
+ description=(
+ f"**시장:** {market}\n"
+ f"**종목:** `{ticker}`\n"
+ f"**현재가:** {_format_money(price, currency)}\n"
+ f"**매수 수량:** {qty}주\n"
+ f"**예상 금액:** {_format_money(qty * price, currency)}\n\n"
+ f"매수하시겠습니까?"
+ ),
+ color=0x00FF00,
+ )
+ embed.set_footer(text=f"{mode_label} | {currency}")
+ await channel.send(embed=embed, view=view)
+ except Exception:
+ pass
+
+ elif decision.upper() == "SELL":
+ try:
+ balance_data = await loop.run_in_executor(None, kis.get_balance, market)
+ holding = next(
+ (
+ h for h in balance_data["holdings"]
+ if h["ticker"] == ticker and h.get("market", market) == market
+ ),
+ None,
+ )
+ if not holding or holding["qty"] <= 0:
+ return
+ view = SellConfirmView(
+ ticker=ticker,
+ name=holding["name"],
+ qty=holding["qty"],
+ avg_price=holding["avg_price"],
+ market=market,
+ currency=currency,
+ exchange=holding.get("exchange", ""),
+ )
+ embed = discord.Embed(
+ title=f"🔴 {holding['name']} 매도 확인",
+ description=(
+ f"**시장:** {market}\n"
+ f"**종목:** {holding['name']} (`{ticker}`)\n"
+ f"**보유:** {holding['qty']}주 "
+ f"(평균 {_format_money(holding['avg_price'], currency)})\n"
+ f"**현재가:** {_format_money(holding['current_price'], currency)}\n"
+ f"**손익:** {_format_money(holding['pnl'], currency)} "
+ f"({holding['pnl_rate']:+.2f}%)\n\n"
+ f"AI가 SELL을 권고합니다. 전량 매도하시겠습니까?"
+ ),
+ color=0xFF0000,
+ )
+ embed.set_footer(text=f"{mode_label} | {currency}")
+ await channel.send(embed=embed, view=view)
+ except Exception:
+ pass
+
+
+# ─── Helper: 대형주+ETF 워치리스트 스코어링 ─────────────────
+async def _compute_stock_scores(count: int = 10) -> list[dict]:
+ """
+ KR_WATCHLIST(대형주+ETF) 기반 스코어링.
+
+ 후보 풀: .env의 KR_WATCHLIST에 등록된 종목만 사용
+ 스코어링 기준:
+ - 워치리스트 기본 점수: +30 (대형주/ETF 신뢰 보너스)
+ - 등락률 0%8% 또는 <-5% → 제외 (대형주에는 여유롭게)
+
+ Returns:
+ [{"ticker", "name", "price", "score", "signals": [str]}, ...] 점수 내림차순
+ """
+ loop = asyncio.get_running_loop()
+
+ # 보조 데이터: 시총/거래량 랭킹 (가능하면 조회)
+ try:
+ cap_list = await loop.run_in_executor(None, kis.get_top_market_cap, 30)
+ except Exception:
+ cap_list = []
+ try:
+ volume_list = await loop.run_in_executor(None, kis.get_volume_rank, 30)
+ except Exception:
+ volume_list = []
+
+ watchlist = _resolve_scoring_watchlist(
+ kis.kr_watchlist,
+ cap_list,
+ volume_list,
+ market="KR",
+ )
+ if not watchlist:
+ return []
+
+ cap_map = {s["ticker"]: s for s in cap_list}
+ volume_map = {s["ticker"]: s for s in volume_list}
+
+ scored: list[dict] = []
+ for ticker in watchlist:
+ try:
+ price = await loop.run_in_executor(None, kis.get_price, ticker, "KR")
+ except Exception:
+ price = 0
+ if price <= 0:
+ continue
+
+ # yfinance로 전일 대비 등락률 계산
+ yf_sym = _yf_ticker(ticker, reference_price=price)
+ try:
+ hist = await loop.run_in_executor(
+ None,
+ lambda sym=yf_sym: yf.Ticker(sym).history(period="5d", interval="1d"),
+ )
+ closes = hist["Close"].dropna() if not hist.empty and "Close" in hist.columns else None
+ if closes is not None and len(closes) >= 2:
+ prev = float(closes.iloc[-2])
+ prdy_ctrt = (price - prev) / prev * 100 if prev > 0 else 0.0
+ else:
+ prdy_ctrt = 0.0
+ except Exception:
+ prdy_ctrt = 0.0
+
+ # 필터: 급등/급락 제외
+ if prdy_ctrt > 8.0 or prdy_ctrt < -5.0:
+ continue
+
+ score = 30 # 워치리스트 기본 점수 (대형주/ETF 신뢰)
+ signals: list[str] = ["워치리스트"]
+
+ # 등락률 시그널
+ if 0 < prdy_ctrt <= 2:
+ score += 25
+ signals.append(f"등락률 +{prdy_ctrt:.1f}%(안정상승)")
+ elif 2 < prdy_ctrt <= 5:
+ score += 15
+ signals.append(f"등락률 +{prdy_ctrt:.1f}%")
+
+ # 시가총액 랭킹 보너스
+ if ticker in cap_map:
+ score += 10
+ signals.append(f"시총 {cap_map[ticker]['rank']}위")
+
+ # 거래량 랭킹 보너스
+ if ticker in volume_map:
+ score += 5
+ signals.append(f"거래량 {volume_map[ticker]['rank']}위")
+
+ # 이름 조회: 랭킹 데이터에 있으면 가져오고, 없으면 티커 사용
+ name = ""
+ for m in (cap_map, volume_map):
+ if ticker in m:
+ name = m[ticker].get("name", "")
+ break
+ if not name:
+ name = ticker
+
+ scored.append({
+ "ticker": ticker,
+ "name": name,
+ "price": price,
+ "prdy_ctrt": prdy_ctrt,
+ "score": score,
+ "signals": signals,
+ })
+
+ scored.sort(key=lambda x: x["score"], reverse=True)
+ return scored[:count]
+
+
+def _compute_us_scores_from_yfinance(
+ watchlist: list[str],
+ count: int = 10,
+ cap_map: dict[str, dict] | None = None,
+ volume_map: dict[str, dict] | None = None,
+) -> list[dict]:
+ """US_WATCHLIST(대형주+ETF) 기반 스코어링."""
+ cap_map = cap_map or {}
+ volume_map = volume_map or {}
+ scored: list[dict] = []
+ for ticker in watchlist:
+ try:
+ hist = yf.Ticker(ticker).history(period="5d", interval="1d")
+ if hist.empty or "Close" not in hist.columns:
+ continue
+ closes = hist["Close"].dropna()
+ if len(closes) < 2:
+ continue
+
+ prev = float(closes.iloc[-2])
+ if prev <= 0:
+ continue
+
+ price = 0.0
+ if kis.enable_us_trading and kis.is_configured:
+ try:
+ price = float(kis.get_price(ticker, "US"))
+ except Exception:
+ price = 0.0
+ if price <= 0:
+ price = float(closes.iloc[-1])
+
+ pct = (price - prev) / prev * 100
+
+ if pct > 8.0 or pct < -5.0:
+ continue
+
+ score = 30
+ signals: list[str] = ["워치리스트"]
+ if 0 < pct <= 2:
+ score += 25
+ signals.append(f"등락률 +{pct:.1f}%(안정상승)")
+ elif 2 < pct <= 5:
+ score += 15
+ signals.append(f"등락률 +{pct:.1f}%")
+
+ cap_info = cap_map.get(ticker)
+ if cap_info:
+ score += 10
+ signals.append(f"시총 {cap_info.get('rank', 0)}위")
+
+ volume_info = volume_map.get(ticker)
+ if volume_info:
+ score += 5
+ signals.append(f"거래량 {volume_info.get('rank', 0)}위")
+
+ scored.append(
+ {
+ "market": "US",
+ "currency": "USD",
+ "exchange": (volume_info or {}).get(
+ "exchange",
+ (cap_info or {}).get(
+ "exchange",
+ kis._us_exchange_cache.get(ticker, ""),
+ ),
+ ),
+ "ticker": ticker,
+ "name": (cap_info or {}).get(
+ "name",
+ (volume_info or {}).get("name", ticker),
+ ),
+ "price": price,
+ "prdy_ctrt": pct,
+ "score": score,
+ "signals": signals,
+ }
+ )
+ except Exception:
+ continue
+
+ scored.sort(key=lambda x: x["score"], reverse=True)
+ return scored[:count]
+
+
+async def _compute_us_stock_scores(count: int = 10) -> list[dict]:
+ """US_WATCHLIST(대형주+ETF) 기반 미국 후보 스코어링."""
+ loop = asyncio.get_running_loop()
+
+ cap_rank: list[dict] = []
+ volume_rank: list[dict] = []
+ if kis.enable_us_trading and kis.is_configured:
+ try:
+ cap_rank = await loop.run_in_executor(None, kis.get_us_market_cap_rank, 30)
+ except Exception:
+ cap_rank = []
+ try:
+ volume_rank = await loop.run_in_executor(None, kis.get_us_volume_rank, 30)
+ except Exception:
+ volume_rank = []
+
+ watchlist = _resolve_scoring_watchlist(
+ kis.us_watchlist,
+ cap_rank,
+ volume_rank,
+ market="US",
+ )
+ if not watchlist:
+ return []
+
+ cap_map = {item["ticker"]: item for item in cap_rank}
+ volume_map = {item["ticker"]: item for item in volume_rank}
+ return await loop.run_in_executor(
+ None,
+ _compute_us_scores_from_yfinance,
+ watchlist,
+ count,
+ cap_map,
+ volume_map,
+ )
+
+
+# ─── Helper: TOP5 분석 실행 ───────────────────────────────────
+async def _run_top5_analysis(channel: discord.abc.Messageable, trade_date: str):
+ """스코어링 TOP5를 조회하고 각각 AI 분석 실행."""
+ status = await channel.send("📊 **스코어링 TOP5** 조회 중…")
+ loop = asyncio.get_running_loop()
+ top5 = await _compute_stock_scores(count=5)
+
+ if not top5:
+ await status.edit(content="❌ 스코어링 후보가 없습니다. (휴장일?)")
+ return
+
+ # TOP5 목록 Embed
+ desc_lines = []
+ for i, s in enumerate(top5, 1):
+ sig_str = ", ".join(s.get("signals", []))
+ desc_lines.append(
+ f"**{i}.** {s['name']} (`{s['ticker']}`) "
+ f"— {s['price']:,}원 | **{s['score']}점** | {sig_str}"
+ )
+ list_embed = discord.Embed(
+ title=f"🏆 스코어링 TOP {len(top5)}",
+ description="\n".join(desc_lines),
+ color=0x0066FF,
+ timestamp=datetime.datetime.now(),
+ )
+ mode_label = "🧪 모의투자" if kis.virtual else "💰 실전투자"
+ list_embed.set_footer(text=f"TradingAgents | {mode_label}")
+ await status.edit(content=None, embed=list_embed)
+
+ # 각 종목 AI 분석
+ buy_targets = []
+ sell_targets = []
+ total = len(top5)
+ for i, stock_info in enumerate(top5):
+ ticker = stock_info["ticker"]
+ name = stock_info["name"]
+ progress = await channel.send(
+ f"🔍 [{i+1}/{total}] **{name}** (`{ticker}`) 분석 중… (약 2~5분)"
+ )
+ try:
+ ta = TradingAgentsGraph(debug=False, config=config)
+ analysis_symbol = _yf_ticker(ticker, reference_price=stock_info["price"])
+ final_state, decision = await loop.run_in_executor(
+ None, ta.propagate, analysis_symbol, trade_date
+ )
+
+ color_map = {"BUY": 0x00FF00, "SELL": 0xFF0000, "HOLD": 0xFFAA00}
+ summary = _extract_decision_summary(final_state, decision, ticker)
+ emoji = "🟢" if decision == "BUY" else "🔴" if decision == "SELL" else "🟡"
+ embed = discord.Embed(
+ title=f"{emoji} {name} ({ticker}) → {decision}",
+ description=summary,
+ color=color_map.get(decision.upper(), 0x808080),
+ )
+ await progress.edit(content=None, embed=embed)
+
+ report_text = _build_report_text(
+ final_state,
+ ticker,
+ market="KR",
+ analysis_symbol=analysis_symbol,
+ )
+ report_file, report_path = _prepare_report_attachment(
+ report_text,
+ market="KR",
+ ticker=ticker,
+ trade_date=trade_date,
+ scope="TOP5",
+ )
+ if AUTO_REPORT_UPLOAD:
+ try:
+ await channel.send(file=report_file)
+ except Exception as e:
+ _log(
+ "WARN",
+ "TOP5_REPORT_UPLOAD_FAIL",
+ f"ticker={ticker} error={str(e)[:160]} path={report_path or 'N/A'}",
+ )
+ await channel.send(
+ "⚠️ 보고서 파일 업로드에 실패했습니다. "
+ + (f"로컬 저장 파일: `{report_path}`" if report_path else "로컬 저장도 실패했습니다.")
+ )
+
+ if decision.upper() == "BUY":
+ buy_targets.append({
+ "ticker": ticker,
+ "name": name,
+ "price": stock_info["price"],
+ })
+ elif decision.upper() == "SELL":
+ sell_targets.append({
+ "ticker": ticker,
+ "name": name,
+ })
+ except Exception as e:
+ await progress.edit(
+ content=f"❌ {name} ({ticker}) 분석 실패: {str(e)[:200]}"
+ )
+
+ # ── SELL 종목: 보유 중이면 매도 버튼 표시 ──────────────────
+ if sell_targets and kis.is_configured:
+ try:
+ loop = asyncio.get_running_loop()
+ balance_data = await loop.run_in_executor(None, kis.get_balance, "KR")
+ holdings_map = {h["ticker"]: h for h in balance_data["holdings"]}
+ except Exception:
+ holdings_map = {}
+
+ for target in sell_targets:
+ holding = holdings_map.get(target["ticker"])
+ if holding and holding["qty"] > 0:
+ view = SellConfirmView(
+ ticker=target["ticker"],
+ name=target["name"],
+ qty=holding["qty"],
+ avg_price=holding["avg_price"],
+ market="KR",
+ currency="KRW",
+ exchange=holding.get("exchange", "KRX"),
+ )
+ embed = discord.Embed(
+ title=f"🔴 {target['name']} 매도 확인",
+ description=(
+ f"**종목:** {target['name']} (`{target['ticker']}`)\n"
+ f"**보유:** {holding['qty']}주 (평균 {_format_money(holding['avg_price'], 'KRW')})\n"
+ f"**현재가:** {_format_money(holding['current_price'], 'KRW')}\n"
+ f"**손익:** {_format_money(holding['pnl'], 'KRW')} ({holding['pnl_rate']:+.2f}%)\n\n"
+ f"AI가 SELL을 권고합니다. 전량 매도하시겠습니까?"
+ ),
+ color=0xFF0000,
+ )
+ embed.set_footer(text=mode_label)
+ await channel.send(embed=embed, view=view)
+
+ # ── BUY 종목: 매수 버튼 표시 ──────────────────────────────
+ if not buy_targets and not sell_targets:
+ await channel.send("📋 **분석 완료** — BUY/SELL 추천 종목이 없습니다. 모두 HOLD입니다.")
+ return
+ elif not buy_targets:
+ await channel.send("📋 **분석 완료** — BUY 추천 종목이 없습니다.")
+ return
+
+ if not kis.is_configured:
+ buy_list = ", ".join(f"{t['name']}" for t in buy_targets)
+ await channel.send(
+ f"📋 **분석 완료** — BUY 추천: {buy_list}\n"
+ f"⚠️ KIS API가 설정되지 않아 자동 매매를 사용할 수 없습니다."
+ )
+ return
+
+ if not _is_market_open_now("KR"):
+ buy_list = ", ".join(f"{t['name']}({t['ticker']})" for t in buy_targets)
+ await channel.send(
+ "ℹ️ **장외/휴장 상태**라 `/대형주` 수동 매수 버튼을 비활성화했습니다.\n"
+ f"추천 BUY 종목: {buy_list}"
+ )
+ _log("INFO", "TOP5_BUY_BUTTON_BLOCKED", "market closed")
+ return
+
+ per_stock_budget = int(kis.max_order_amount // len(buy_targets))
+ await channel.send(
+ f"🧪 **테스트 모드 예산(수동 /대형주)**\n"
+ f"총 상한: {_format_money(kis.max_order_amount, 'KRW')} | "
+ f"종목당: {_format_money(per_stock_budget, 'KRW')}"
+ )
+ for target in buy_targets:
+ qty = int(per_stock_budget // target["price"]) if target["price"] > 0 else 0
+ if qty <= 0:
+ await channel.send(
+ f"⚠️ {target['name']} — 예산({_format_money(per_stock_budget, 'KRW')}) 부족으로 매수 불가"
+ )
+ continue
+ view = BuyConfirmView(
+ ticker=target["ticker"],
+ name=target["name"],
+ qty=qty,
+ price=target["price"],
+ market="KR",
+ currency="KRW",
+ )
+ embed = discord.Embed(
+ title=f"🛒 {target['name']} 매수 확인",
+ description=(
+ f"**종목:** {target['name']} (`{target['ticker']}`)\n"
+ f"**현재가:** {_format_money(target['price'], 'KRW')}\n"
+ f"**매수 수량:** {qty}주\n"
+ f"**예산 규칙:** 수동 /대형주 테스트 상한({_format_money(per_stock_budget, 'KRW')})\n"
+ f"**예상 금액:** {_format_money(qty * target['price'], 'KRW')}\n\n"
+ f"매수하시겠습니까?"
+ ),
+ color=0x00FF00,
+ )
+ embed.set_footer(text=mode_label)
+ await channel.send(embed=embed, view=view)
+
+
+# ─── Discord UI: 매수/매도 확인 버튼 ──────────────────────────
+class BuyConfirmView(discord.ui.View):
+ """매수 확인/건너뛰기 버튼"""
+
+ def __init__(
+ self,
+ ticker: str,
+ name: str,
+ qty: int,
+ price: float,
+ market: str = "KR",
+ currency: str = "KRW",
+ reason: str = "AI BUY 신호",
+ ):
+ super().__init__(timeout=300)
+ self.ticker = ticker
+ self.name = name
+ self.qty = qty
+ self.price = float(price)
+ self.market = market.upper()
+ self.currency = currency.upper()
+ self.reason = reason
+
+ @discord.ui.button(label="✅ 매수 확인", style=discord.ButtonStyle.green)
+ async def confirm_buy(self, interaction: discord.Interaction, button: discord.ui.Button):
+ await interaction.response.defer()
+ try:
+ loop = asyncio.get_running_loop()
+ result = await loop.run_in_executor(
+ None, kis.buy_stock, self.ticker, self.qty, 0, self.market
+ )
+ if result["success"]:
+ record_trade(
+ self.ticker, self.name, "BUY",
+ self.qty, self.price,
+ order_no=result.get("order_no", ""),
+ reason=self.reason,
+ market=self.market,
+ currency=self.currency,
+ )
+ embed = discord.Embed(
+ title=f"✅ {self.name} 매수 완료",
+ description=(
+ f"**시장:** {self.market}\n"
+ f"**주문번호:** {result['order_no']}\n"
+ f"**수량:** {self.qty}주\n"
+ f"**평균 단가:** {_format_money(self.price, self.currency)}\n"
+ f"**메시지:** {result['message']}"
+ ),
+ color=0x00FF00,
+ )
+ else:
+ embed = discord.Embed(
+ title=f"❌ {self.name} 매수 실패",
+ description=f"**사유:** {result['message']}",
+ color=0xFF0000,
+ )
+ await interaction.followup.send(embed=embed)
+ except Exception as e:
+ await interaction.followup.send(f"❌ 매수 오류: {str(e)[:500]}")
+ self.stop()
+
+ @discord.ui.button(label="⏭️ 건너뛰기", style=discord.ButtonStyle.grey)
+ async def skip_buy(self, interaction: discord.Interaction, button: discord.ui.Button):
+ await interaction.response.send_message(
+ f"⏭️ {self.name} 매수를 건너뛰었습니다.", ephemeral=True
+ )
+ self.stop()
+
+
+class SellConfirmView(discord.ui.View):
+ """매도 확인/취소 버튼"""
+
+ def __init__(
+ self,
+ ticker: str,
+ name: str,
+ qty: int,
+ avg_price: float = 0,
+ market: str = "KR",
+ currency: str = "KRW",
+ exchange: str = "",
+ ):
+ super().__init__(timeout=120)
+ self.ticker = ticker
+ self.name = name
+ self.qty = qty
+ self.avg_price = float(avg_price) # 평균 매수가 (실현손익 계산용)
+ self.market = market.upper()
+ self.currency = currency.upper()
+ self.exchange = exchange
+
+ @discord.ui.button(label="🔴 매도 확인", style=discord.ButtonStyle.danger)
+ async def confirm_sell(self, interaction: discord.Interaction, button: discord.ui.Button):
+ await interaction.response.defer()
+ try:
+ loop = asyncio.get_running_loop()
+ result = await loop.run_in_executor(
+ None, kis.sell_stock, self.ticker, self.qty, 0, self.market
+ )
+ if result["success"]:
+ # 현재가 조회하여 실현손익 기록
+ try:
+ sell_price = await loop.run_in_executor(None, kis.get_price, self.ticker, self.market)
+ except Exception:
+ sell_price = 0
+ record_trade(
+ self.ticker, self.name, "SELL",
+ self.qty, sell_price,
+ order_no=result.get("order_no", ""),
+ reason="매도",
+ market=self.market,
+ currency=self.currency,
+ )
+ if self.avg_price > 0 and sell_price > 0:
+ record_pnl(
+ self.ticker,
+ self.name,
+ self.avg_price,
+ sell_price,
+ self.qty,
+ market=self.market,
+ currency=self.currency,
+ )
+ embed = discord.Embed(
+ title=f"✅ {self.name} 매도 완료",
+ description=(
+ f"**시장:** {self.market}\n"
+ f"**종목:** `{self.ticker}`\n"
+ f"**수량:** {self.qty}주\n"
+ f"**체결 단가:** {_format_money(sell_price, self.currency)}\n"
+ f"**주문번호:** {result['order_no']}\n"
+ f"**메시지:** {result['message']}"
+ ),
+ color=0x00FF00,
+ )
+ else:
+ embed = discord.Embed(
+ title="❌ 매도 실패",
+ description=f"**사유:** {result['message']}",
+ color=0xFF0000,
+ )
+ await interaction.followup.send(embed=embed)
+ except Exception as e:
+ await interaction.followup.send(f"❌ 매도 오류: {str(e)[:500]}")
+ self.stop()
+
+ @discord.ui.button(label="취소", style=discord.ButtonStyle.grey)
+ async def cancel_sell(self, interaction: discord.Interaction, button: discord.ui.Button):
+ await interaction.response.send_message("🚫 매도를 취소했습니다.", ephemeral=True)
+ self.stop()
+
+# ─── Slash Command: /분석 ──────────────────────────────────────
+@tree.command(name="분석", description="멀티 에이전트 AI 투자 분석 보고서를 생성합니다")
+@app_commands.describe(
+ ticker="분석할 종목 티커 (예: AAPL, MSFT, 005930)",
+ date="분석 기준일 (YYYY-MM-DD, 기본: 오늘)",
+)
+async def analyze(
+ interaction: discord.Interaction,
+ ticker: str,
+ date: str | None = None,
+):
+ ticker = ticker.upper().strip()
+ market = _market_of_ticker(ticker)
+ try:
+ trade_date = _parse_trade_date(date)
+ except ValueError as e:
+ await interaction.response.send_message(f"❌ {str(e)}", ephemeral=True)
+ return
+
+ await interaction.response.defer(thinking=True)
+ _log(
+ "INFO",
+ "SLASH_ANALYZE_START",
+ f"{_interaction_actor(interaction)} market={market} ticker={ticker} date={trade_date}",
+ )
+
+ if not _is_allowed_channel(interaction.channel_id):
+ _log("WARN", "SLASH_ANALYZE_BLOCKED", f"허용되지 않은 채널 channel={interaction.channel_id}")
+ await interaction.followup.send(
+ "❌ 이 채널에서는 분석 명령을 사용할 수 없습니다."
+ )
+ return
+
+ if _analysis_lock.locked():
+ _log("WARN", "SLASH_ANALYZE_BUSY", "analysis lock already acquired")
+ await interaction.followup.send(
+ "⏳ 이미 다른 분석이 진행 중입니다. 잠시 후 다시 시도해주세요."
+ )
+ return
+
+ is_valid_ticker, ticker_error = await _validate_analysis_ticker(ticker)
+ if not is_valid_ticker:
+ _log("WARN", "SLASH_ANALYZE_INVALID_TICKER", f"ticker={ticker} reason={ticker_error}")
+ await interaction.followup.send(f"❌ {ticker_error}")
+ return
+
+ async with _analysis_lock:
+ status_msg = await interaction.followup.send(
+ f"🔍 **{ticker} ({market})** 분석을 시작합니다… (약 2~5분 소요)\n"
+ f"📅 기준일: {trade_date}",
+ wait=True,
+ )
+
+ try:
+ loop = asyncio.get_running_loop()
+ ta = TradingAgentsGraph(debug=False, config=config)
+ analysis_ref_price = None
+ if market == "KR" and kis.is_configured:
+ try:
+ analysis_ref_price = await loop.run_in_executor(
+ None, kis.get_price, ticker, "KR"
+ )
+ except Exception:
+ analysis_ref_price = None
+ analysis_symbol = _yf_ticker(ticker, reference_price=analysis_ref_price)
+ final_state, decision = await loop.run_in_executor(
+ None, ta.propagate, analysis_symbol, trade_date
+ )
+
+ report_text = _build_report_text(
+ final_state,
+ ticker,
+ market=market,
+ analysis_symbol=analysis_symbol,
+ )
+ summary = _extract_decision_summary(final_state, decision, ticker, market)
+
+ color_map = {"BUY": 0x00FF00, "SELL": 0xFF0000, "HOLD": 0xFFAA00}
+ embed = discord.Embed(
+ title=f"📋 {ticker} ({market}) 분석 완료",
+ description=summary,
+ color=color_map.get(decision.upper(), 0x808080),
+ timestamp=datetime.datetime.now(),
+ )
+ embed.set_footer(text="TradingAgents 멀티 에이전트 분석")
+
+ await status_msg.edit(content=None, embed=embed)
+
+ report_file, report_path = _prepare_report_attachment(
+ report_text,
+ market=market,
+ ticker=ticker,
+ trade_date=trade_date,
+ scope="SLASH",
+ )
+ try:
+ await interaction.followup.send(
+ f"📄 **{ticker} ({market})** 전체 보고서:",
+ file=report_file,
+ )
+ except Exception as e:
+ _log(
+ "WARN",
+ "SLASH_ANALYZE_REPORT_UPLOAD_FAIL",
+ f"ticker={ticker} error={str(e)[:160]} path={report_path or 'N/A'}",
+ )
+ await interaction.followup.send(
+ "⚠️ 보고서 파일 업로드에 실패했습니다. "
+ + (f"로컬 저장 파일: `{report_path}`" if report_path else "로컬 저장도 실패했습니다.")
+ )
+
+ # BUY/SELL 판정 시 자동매매 버튼
+ ch = interaction.channel
+ if isinstance(ch, discord.abc.Messageable):
+ await _show_trade_button(ch, ticker, decision, market=market)
+
+ _log(
+ "INFO",
+ "SLASH_ANALYZE_DONE",
+ f"market={market} ticker={ticker} decision={decision}",
+ )
+
+ except Exception as e:
+ _log("ERROR", "SLASH_ANALYZE_ERROR", f"ticker={ticker} error={str(e)[:200]}")
+ await status_msg.edit(
+ content=f"❌ 분석 중 오류가 발생했습니다:\n```\n{str(e)[:1500]}\n```"
+ )
+
+
+# ─── Slash Command: /대형주 ─────────────────────────────────────
+@tree.command(name="대형주", description="스코어링 TOP5 분석 + 매수 추천")
+@app_commands.describe(date="분석 기준일 (YYYY-MM-DD, 기본: 오늘)")
+async def top_stocks(interaction: discord.Interaction, date: str | None = None):
+ try:
+ trade_date = _parse_trade_date(date)
+ except ValueError as e:
+ await interaction.response.send_message(f"❌ {str(e)}", ephemeral=True)
+ return
+
+ await interaction.response.defer(thinking=True)
+ _log("INFO", "SLASH_TOP5_START", f"{_interaction_actor(interaction)} date={trade_date}")
+
+ if not _is_allowed_channel(interaction.channel_id):
+ _log("WARN", "SLASH_TOP5_BLOCKED", f"허용되지 않은 채널 channel={interaction.channel_id}")
+ await interaction.followup.send("❌ 이 채널에서는 사용할 수 없습니다.")
+ return
+
+ if _analysis_lock.locked():
+ _log("WARN", "SLASH_TOP5_BUSY", "analysis lock already acquired")
+ await interaction.followup.send("⏳ 이미 다른 분석이 진행 중입니다.")
+ return
+
+ await interaction.followup.send(f"🚀 **스코어링 TOP5 분석**을 시작합니다 (기준일: {trade_date})")
+ async with _analysis_lock:
+ ch = interaction.channel
+ if isinstance(ch, discord.abc.Messageable):
+ await _run_top5_analysis(ch, trade_date)
+ _log("INFO", "SLASH_TOP5_DONE", f"date={trade_date}")
+ else:
+ _log("WARN", "SLASH_TOP5_INVALID_CHANNEL", "interaction channel is not Messageable")
+ await interaction.followup.send("❌ 이 채널에서는 분석을 실행할 수 없습니다.")
+
+
+# ─── Slash Command: /잔고 ──────────────────────────────────────
+@tree.command(name="잔고", description="한국투자증권 계좌 잔고를 조회합니다")
+async def balance_cmd(interaction: discord.Interaction):
+ await interaction.response.defer(thinking=True)
+ _log("INFO", "SLASH_BALANCE_START", _interaction_actor(interaction))
+
+ if not kis.is_configured:
+ _log("WARN", "SLASH_BALANCE_BLOCKED", "KIS API not configured")
+ await interaction.followup.send("⚠️ KIS API가 설정되지 않았습니다. `.env`에 KIS 인증 정보를 추가하세요.")
+ return
+
+ try:
+ loop = asyncio.get_running_loop()
+ data = await loop.run_in_executor(None, kis.get_balance, "ALL")
+ holdings = data["holdings"]
+ summary = data["summary"]
+
+ if not holdings:
+ desc = "보유 종목이 없습니다."
+ else:
+ lines = []
+ for h in holdings:
+ pnl_emoji = "🟢" if h["pnl"] >= 0 else "🔴"
+ currency = h.get("currency", _currency_of_market(h.get("market", "KR")))
+ lines.append(
+ f"**[{h.get('market', 'KR')}] {h['name']}** (`{h['ticker']}`) — {h['qty']}주\n"
+ f" 평균가 {_format_money(h['avg_price'], currency)} → "
+ f"현재 {_format_money(h['current_price'], currency)} "
+ f"{pnl_emoji} {_format_money(h['pnl'], currency)} ({h['pnl_rate']:+.2f}%)"
+ )
+ desc = "\n".join(lines)
+
+ mode_label = "🧪 모의투자" if kis.virtual else "💰 실전투자"
+ embed = discord.Embed(
+ title=f"💰 계좌 잔고 ({mode_label})",
+ description=desc,
+ color=0x0066FF,
+ timestamp=datetime.datetime.now(),
+ )
+ if summary:
+ krw = summary.get("KRW", {})
+ usd = summary.get("USD", {})
+ embed.add_field(
+ name="KRW 요약",
+ value=(
+ f"평가액: {_format_money(krw.get('total_eval', 0), 'KRW')}\n"
+ f"손익: {_format_money(krw.get('total_pnl', 0), 'KRW')}\n"
+ f"예수금: {_format_money(krw.get('cash', 0), 'KRW')}"
+ ),
+ inline=True,
+ )
+ embed.add_field(
+ name="USD 요약",
+ value=(
+ f"평가액: {_format_money(usd.get('total_eval', 0), 'USD')}\n"
+ f"손익: {_format_money(usd.get('total_pnl', 0), 'USD')}\n"
+ f"예수금: {_format_money(usd.get('cash', 0), 'USD')}"
+ ),
+ inline=True,
+ )
+ embed.add_field(name="보유 종목 수", value=f"{len(holdings)}개", inline=True)
+
+ await interaction.followup.send(embed=embed)
+ _log(
+ "INFO",
+ "SLASH_BALANCE_DONE",
+ f"holdings={len(holdings)} krw_eval={summary.get('KRW', {}).get('total_eval', 0)} "
+ f"usd_eval={summary.get('USD', {}).get('total_eval', 0)}",
+ )
+ except Exception as e:
+ _log("ERROR", "SLASH_BALANCE_ERROR", str(e)[:200])
+ await interaction.followup.send(f"❌ 잔고 조회 실패: {str(e)[:500]}")
+
+
+# ─── Slash Command: /매수 ──────────────────────────────────────
+@tree.command(name="매수", description="종목을 매수합니다 (수량 생략 시 예산 상한 기준 자동 계산)")
+@app_commands.describe(
+ ticker="매수할 종목 코드 (예: 005930, AAPL)",
+ qty="매수 수량 (생략 시 시장별 수동 예산 상한 기준)",
+)
+async def buy_cmd(
+ interaction: discord.Interaction,
+ ticker: str,
+ qty: int | None = None,
+):
+ await interaction.response.defer(thinking=True)
+ _log("INFO", "SLASH_BUY_START", f"{_interaction_actor(interaction)} ticker={ticker} qty={qty}")
+
+ if not _is_allowed_channel(interaction.channel_id):
+ _log("WARN", "SLASH_BUY_BLOCKED", f"허용되지 않은 채널 channel={interaction.channel_id}")
+ await interaction.followup.send("❌ 이 채널에서는 사용할 수 없습니다.")
+ return
+
+ if not kis.is_configured:
+ _log("WARN", "SLASH_BUY_BLOCKED", "KIS API not configured")
+ await interaction.followup.send("⚠️ KIS API가 설정되지 않았습니다.")
+ return
+
+ ticker = ticker.strip().upper()
+ format_error = _validate_ticker_format(ticker)
+ if format_error:
+ _log("WARN", "SLASH_BUY_INVALID_TICKER", f"ticker={ticker} reason={format_error}")
+ await interaction.followup.send(f"❌ {format_error}")
+ return
+
+ market = _market_of_ticker(ticker)
+ if market == "US" and not kis.enable_us_trading:
+ _log("WARN", "SLASH_BUY_US_DISABLED", f"ticker={ticker}")
+ await interaction.followup.send(
+ "ℹ️ 미국 주문은 비활성화되어 있습니다. `.env`의 "
+ "`ENABLE_US_TRADING=true` 설정 후 사용하세요."
+ )
+ return
+
+ if qty is not None and qty <= 0:
+ _log("WARN", "SLASH_BUY_INVALID_QTY", f"ticker={ticker} qty={qty}")
+ await interaction.followup.send("❌ 수량은 1 이상이어야 합니다.")
+ return
+
+ if not _is_market_open_now(market):
+ _log("INFO", "SLASH_BUY_MARKET_CLOSED", f"market={market} ticker={ticker}")
+ await interaction.followup.send(
+ f"ℹ️ `{ticker}`({market}) 현재 장외/휴장 상태라 주문 버튼을 표시하지 않습니다."
+ )
+ return
+
+ normalized = kis.normalize_ticker(ticker, market)
+ currency = _currency_of_market(market)
+ budget_cap = kis.us_max_order_amount if market == "US" else kis.max_order_amount
+ loop = asyncio.get_running_loop()
+
+ try:
+ price = await loop.run_in_executor(None, kis.get_price, normalized, market)
+ except Exception as e:
+ _log("ERROR", "SLASH_BUY_PRICE_ERROR", f"ticker={normalized} error={str(e)[:200]}")
+ await interaction.followup.send(f"❌ 현재가 조회 실패: {str(e)[:300]}")
+ return
+
+ if price <= 0:
+ _log("WARN", "SLASH_BUY_INVALID_PRICE", f"market={market} ticker={normalized} price={price}")
+ await interaction.followup.send(
+ f"❌ `{normalized}`({market}) 현재가를 확인할 수 없습니다. 티커를 다시 확인해주세요."
+ )
+ return
+
+ auto_qty = False
+ buy_qty = qty
+ if buy_qty is None:
+ auto_qty = True
+ buy_qty = int(budget_cap // price)
+ if buy_qty <= 0:
+ _log("WARN", "SLASH_BUY_BUDGET_TOO_LOW", f"market={market} ticker={normalized} price={price}")
+ await interaction.followup.send(
+ f"⚠️ 예산 상한({_format_money(budget_cap, currency)}) 대비 "
+ f"현재가({_format_money(price, currency)})가 높아 1주도 매수할 수 없습니다."
+ )
+ return
+
+ expected_amount = buy_qty * price
+ if expected_amount > budget_cap:
+ _log(
+ "WARN",
+ "SLASH_BUY_OVER_CAP",
+ f"market={market} ticker={normalized} qty={buy_qty} amount={expected_amount} cap={budget_cap}",
+ )
+ await interaction.followup.send(
+ f"❌ 주문 예상금액({_format_money(expected_amount, currency)})이 "
+ f"수동 예산 상한({_format_money(budget_cap, currency)})을 초과합니다.\n"
+ "수량을 줄이거나 예산 상한 환경변수를 조정하세요."
+ )
+ return
+
+ view = BuyConfirmView(
+ ticker=normalized,
+ name=normalized,
+ qty=buy_qty,
+ price=price,
+ market=market,
+ currency=currency,
+ reason="수동 /매수 주문",
+ )
+ mode_label = "🧪 모의투자" if kis.virtual else "💰 실전투자"
+ qty_rule_text = (
+ f"자동 계산(상한 {_format_money(budget_cap, currency)} 기준)"
+ if auto_qty
+ else f"사용자 입력 ({buy_qty}주)"
+ )
+ embed = discord.Embed(
+ title="🛒 매수 확인",
+ description=(
+ f"**시장:** {market}\n"
+ f"**종목:** `{normalized}`\n"
+ f"**현재가:** {_format_money(price, currency)}\n"
+ f"**매수 수량:** {buy_qty}주\n"
+ f"**수량 기준:** {qty_rule_text}\n"
+ f"**예상 금액:** {_format_money(expected_amount, currency)}\n"
+ f"**수동 예산 상한:** {_format_money(budget_cap, currency)}\n\n"
+ f"매수하시겠습니까?"
+ ),
+ color=0x00FF00,
+ )
+ embed.set_footer(text=f"{mode_label} | {currency}")
+ await interaction.followup.send(embed=embed, view=view)
+ _log(
+ "INFO",
+ "SLASH_BUY_PROMPT",
+ f"market={market} ticker={normalized} qty={buy_qty} price={price}",
+ )
+
+
+# ─── Slash Command: /매도 ──────────────────────────────────────
+@tree.command(name="매도", description="보유 종목을 매도합니다 (수량 생략 시 전량 매도)")
+@app_commands.describe(
+ ticker="매도할 종목 코드 (예: 005930)",
+ qty="매도 수량 (생략 시 전량 매도)",
+)
+async def sell_cmd(
+ interaction: discord.Interaction,
+ ticker: str,
+ qty: int | None = None,
+):
+ await interaction.response.defer(thinking=True)
+ _log("INFO", "SLASH_SELL_START", f"{_interaction_actor(interaction)} ticker={ticker} qty={qty}")
+
+ if not _is_allowed_channel(interaction.channel_id):
+ _log("WARN", "SLASH_SELL_BLOCKED", f"허용되지 않은 채널 channel={interaction.channel_id}")
+ await interaction.followup.send("❌ 이 채널에서는 사용할 수 없습니다.")
+ return
+
+ if not kis.is_configured:
+ _log("WARN", "SLASH_SELL_BLOCKED", "KIS API not configured")
+ await interaction.followup.send("⚠️ KIS API가 설정되지 않았습니다.")
+ return
+
+ ticker = ticker.strip().upper()
+ market = _market_of_ticker(ticker)
+ normalized = kis.normalize_ticker(ticker, market)
+ holding: dict | None = None
+ loop = asyncio.get_running_loop()
+
+ if qty is not None and qty <= 0:
+ _log("WARN", "SLASH_SELL_INVALID_QTY", f"ticker={ticker} qty={qty}")
+ await interaction.followup.send("❌ 수량은 1 이상이어야 합니다.")
+ return
+
+ # 잔고에서 보유 정보 조회
+ try:
+ balance_data = await loop.run_in_executor(None, kis.get_balance, "ALL")
+ holding = next(
+ (
+ h
+ for h in balance_data["holdings"]
+ if h["ticker"] == normalized and h.get("market", market) == market
+ ),
+ None,
+ )
+ except Exception as e:
+ _log("ERROR", "SLASH_SELL_BALANCE_ERROR", str(e)[:200])
+ await interaction.followup.send(f"❌ 잔고 조회 실패: {str(e)[:300]}")
+ return
+
+ if not holding:
+ _log("WARN", "SLASH_SELL_NO_HOLDING", f"market={market} ticker={normalized}")
+ await interaction.followup.send(f"⚠️ `{normalized}`({market}) 보유 내역이 없습니다.")
+ return
+
+ sell_qty = qty if qty is not None else holding["qty"]
+ stock_name = holding["name"]
+ avg_price = holding["avg_price"]
+ currency = holding.get("currency", _currency_of_market(market))
+
+ view = SellConfirmView(
+ ticker=holding["ticker"],
+ name=stock_name,
+ qty=sell_qty,
+ avg_price=avg_price,
+ market=market,
+ currency=currency,
+ exchange=holding.get("exchange", ""),
+ )
+ mode_label = "🧪 모의투자" if kis.virtual else "💰 실전투자"
+ embed = discord.Embed(
+ title="🔴 매도 확인",
+ description=(
+ f"**시장:** {market}\n"
+ f"**종목:** {stock_name} (`{holding['ticker']}`)\n"
+ f"**수량:** {sell_qty}주\n\n매도하시겠습니까?"
+ ),
+ color=0xFF0000,
+ )
+ embed.set_footer(text=f"{mode_label} | {currency}")
+ await interaction.followup.send(embed=embed, view=view)
+ _log(
+ "INFO",
+ "SLASH_SELL_PROMPT",
+ f"market={market} ticker={holding['ticker']} qty={sell_qty} avg_price={avg_price}",
+ )
+
+
+# ─── Slash Command: /상태 ──────────────────────────────────────
+@tree.command(name="상태", description="오늘의 자동매매 실행 상태를 확인합니다")
+async def status_cmd(interaction: discord.Interaction):
+ await interaction.response.defer(thinking=True)
+ _log("INFO", "SLASH_STATUS_START", _interaction_actor(interaction))
+
+ if not _is_allowed_channel(interaction.channel_id):
+ _log("WARN", "SLASH_STATUS_BLOCKED", f"허용되지 않은 채널 channel={interaction.channel_id}")
+ await interaction.followup.send("❌ 이 채널에서는 사용할 수 없습니다.")
+ return
+
+ states = get_daily_state()
+ if not states:
+ _log("INFO", "SLASH_STATUS_EMPTY", "today has no auto-trading state")
+ await interaction.followup.send("📋 오늘 실행된 자동매매가 없습니다.")
+ return
+
+ lines = []
+ for s in states:
+ emoji = {
+ "morning_buy": "🌅",
+ "afternoon_sell": "🌇",
+ "us_morning_buy": "🇺🇸🌅",
+ "us_afternoon_sell": "🇺🇸🌇",
+ }.get(
+ s["action"], "🔔"
+ )
+ lines.append(
+ f"{emoji} **{s['action']}** — {s['completed_at'][:16]}\n"
+ f" {s['details']}"
+ )
+
+ embed = discord.Embed(
+ title=f"📋 오늘의 자동매매 상태 ({datetime.date.today()})",
+ description="\n\n".join(lines),
+ color=0x0066FF,
+ timestamp=datetime.datetime.now(),
+ )
+ await interaction.followup.send(embed=embed)
+ _log("INFO", "SLASH_STATUS_DONE", f"state_count={len(states)}")
+
+
+# ─── Slash Command: /봇정보 ────────────────────────────────────
+@tree.command(name="봇정보", description="봇 스케줄 · 설정 · 계좌 · 실행 이력을 확인합니다")
+async def bot_info_cmd(interaction: discord.Interaction):
+ await interaction.response.defer(thinking=True)
+ _log("INFO", "SLASH_BOTINFO_START", _interaction_actor(interaction))
+
+ if not _is_allowed_channel(interaction.channel_id):
+ _log("WARN", "SLASH_BOTINFO_BLOCKED", f"허용되지 않은 채널 channel={interaction.channel_id}")
+ await interaction.followup.send("❌ 이 채널에서는 사용할 수 없습니다.")
+ return
+
+ now = datetime.datetime.now(KST)
+ now_ny = datetime.datetime.now(NY_TZ)
+ mode_label = "🧪 모의투자" if kis.virtual else "💰 실전투자"
+
+ # 버전 정보
+ version_line = f"**버전:** v{BOT_VERSION}"
+
+ # 다음 실행 시각 계산
+ today = now.date()
+ buy_time = datetime.datetime.combine(
+ today, datetime.time(_buy_h, _buy_m), tzinfo=KST
+ )
+ sell_time = datetime.datetime.combine(
+ today, datetime.time(_sell_h, _sell_m), tzinfo=KST
+ )
+ if buy_time <= now:
+ buy_time += datetime.timedelta(days=1)
+ if sell_time <= now:
+ sell_time += datetime.timedelta(days=1)
+
+ buy_remaining = buy_time - now
+ sell_remaining = sell_time - now
+ buy_h_r, buy_m_r = divmod(int(buy_remaining.total_seconds()) // 60, 60)
+ sell_h_r, sell_m_r = divmod(int(sell_remaining.total_seconds()) // 60, 60)
+
+ us_buy_h_r, us_buy_m_r = 0, 0
+ us_sell_h_r, us_sell_m_r = 0, 0
+ if ENABLE_US_TRADING:
+ us_today = now_ny.date()
+ us_buy_time = datetime.datetime.combine(
+ us_today, datetime.time(_us_buy_h, _us_buy_m), tzinfo=NY_TZ
+ )
+ us_sell_time = datetime.datetime.combine(
+ us_today, datetime.time(_us_sell_h, _us_sell_m), tzinfo=NY_TZ
+ )
+ if us_buy_time <= now_ny:
+ us_buy_time += datetime.timedelta(days=1)
+ if us_sell_time <= now_ny:
+ us_sell_time += datetime.timedelta(days=1)
+ us_buy_remaining = us_buy_time - now_ny
+ us_sell_remaining = us_sell_time - now_ny
+ us_buy_h_r, us_buy_m_r = divmod(int(us_buy_remaining.total_seconds()) // 60, 60)
+ us_sell_h_r, us_sell_m_r = divmod(int(us_sell_remaining.total_seconds()) // 60, 60)
+
+ # 오늘 상태
+ states = get_daily_state()
+ morning_done = any(s["action"] == "morning_buy" for s in states)
+ afternoon_done = any(s["action"] == "afternoon_sell" for s in states)
+ us_morning_done = any(s["action"] == "us_morning_buy" for s in states)
+ us_afternoon_done = any(s["action"] == "us_afternoon_sell" for s in states)
+ kr_market_open = _is_market_day("KR")
+ us_market_open = _is_market_day("US")
+
+ status_lines = [
+ f"**📅 KR 오늘:** {today} ({'거래일 ✅' if kr_market_open else '휴장일 ❌'})",
+ f"**📅 US 오늘:** {now_ny.date()} ({'거래일 ✅' if us_market_open else '휴장일 ❌'})",
+ f"**⏰ 현재 시각:** {now.strftime('%H:%M:%S')} KST",
+ f"**⏰ NY 시각:** {now_ny.strftime('%H:%M:%S')} ET",
+ "",
+ "── **KR 자동매매 스케줄** ──",
+ f"🌅 **아침 매수:** {AUTO_BUY_TIME} KST → "
+ f"{'✅ 완료' if morning_done else f'⏳ {buy_h_r}시간 {buy_m_r}분 후'}",
+ f"🌇 **오후 매도:** {AUTO_SELL_TIME} KST → "
+ f"{'✅ 완료' if afternoon_done else f'⏳ {sell_h_r}시간 {sell_m_r}분 후'}",
+ ]
+ if ENABLE_US_TRADING:
+ status_lines.extend(
+ [
+ "",
+ "── **US 자동매매 스케줄** ──",
+ f"🌅 **아침 매수:** {US_AUTO_BUY_TIME} ET → "
+ f"{'✅ 완료' if us_morning_done else f'⏳ {us_buy_h_r}시간 {us_buy_m_r}분 후'}",
+ f"🌇 **오후 매도:** {US_AUTO_SELL_TIME} ET → "
+ f"{'✅ 완료' if us_afternoon_done else f'⏳ {us_sell_h_r}시간 {us_sell_m_r}분 후'}",
+ ]
+ )
+ status_lines.extend(
+ [
+ "",
+ f"🔔 **손절/익절:** {MONITOR_INTERVAL_MIN}분 간격 감시 중",
+ "",
+ "── **설정** ──",
+ f"📊 **KR 매수 종목 수:** {DAY_TRADE_PICKS}개",
+ f"📊 **US 매수 종목 수:** {US_DAY_TRADE_PICKS}개",
+ f"💸 **KR 자동예산 비율:** {AUTO_BUY_BUDGET_RATIO * 100:.1f}%",
+ f"💸 **US 자동예산 비율:** {US_AUTO_BUY_BUDGET_RATIO * 100:.1f}%",
+ f"🏦 **KR 기준 자금(anchor):** {_format_money(get_budget_anchor('KR'), 'KRW')}",
+ f"🏦 **US 기준 자금(anchor):** {_format_money(get_budget_anchor('US'), 'USD')}",
+ f"🧪 **KR 수동 예산:** {_format_money(kis.max_order_amount, 'KRW')}",
+ f"🧪 **US 수동 예산:** {_format_money(kis.us_max_order_amount, 'USD')}",
+ f"🔴 **손절 라인:** {STOP_LOSS_PCT}%",
+ f"🟢 **익절 라인:** {TAKE_PROFIT_PCT}%",
+ f"🏦 **매매 모드:** {mode_label}",
+ f"🤖 **분석 모델:** {config.get('deep_think_llm', 'N/A')}",
+ version_line,
+ ]
+ )
+
+ if kis.is_configured:
+ try:
+ loop = asyncio.get_running_loop()
+ bal = await loop.run_in_executor(None, kis.get_balance, "ALL")
+ sm = bal.get("summary", {})
+ holdings_count = len(bal.get("holdings", []))
+ status_lines.append("")
+ status_lines.append("── **계좌** ──")
+ status_lines.append(f"💵 **KR 예수금:** {_format_money(sm.get('KRW', {}).get('cash', 0), 'KRW')}")
+ status_lines.append(f"💵 **US 예수금:** {_format_money(sm.get('USD', {}).get('cash', 0), 'USD')}")
+ status_lines.append(f"📦 **보유종목:** {holdings_count}개")
+ status_lines.append(
+ f"📈 **KR 평가액:** {_format_money(sm.get('KRW', {}).get('total_eval', 0), 'KRW')}"
+ )
+ status_lines.append(
+ f"📈 **US 평가액:** {_format_money(sm.get('USD', {}).get('total_eval', 0), 'USD')}"
+ )
+ except Exception:
+ pass
+
+ if states:
+ status_lines.append("")
+ status_lines.append("── **오늘 실행 이력** ──")
+ for s in states:
+ emoji = {
+ "morning_buy": "🌅",
+ "afternoon_sell": "🌇",
+ "us_morning_buy": "🇺🇸🌅",
+ "us_afternoon_sell": "🇺🇸🌇",
+ }.get(
+ s["action"], "🔔"
+ )
+ status_lines.append(
+ f"{emoji} {s['action']} — {s['completed_at'][:16]} | {s['details']}"
+ )
+
+ embed = discord.Embed(
+ title="🤖 TradingAgents 봇 정보",
+ description="\n".join(status_lines),
+ color=0x0066FF,
+ timestamp=now,
+ )
+ embed.set_footer(text="TradingAgents 데이 트레이딩 시스템")
+ await interaction.followup.send(embed=embed)
+ _log(
+ "INFO",
+ "SLASH_BOTINFO_DONE",
+ f"kr_open={kr_market_open} us_open={us_market_open} state_count={len(states)}",
+ )
+
+
+# ─── Slash Command: /스코어링 ──────────────────────────────────
+@tree.command(name="스코어링", description="실시간 스코어링 후보를 조회합니다")
+@app_commands.describe(
+ market="조회할 시장 (기본: KR)",
+ count="표시할 개수 (1~15, 기본 10)",
+ exclude_held="보유 종목 제외 여부 (기본: 제외)",
+)
+@app_commands.choices(
+ market=[
+ app_commands.Choice(name="한국 (KR)", value="KR"),
+ app_commands.Choice(name="미국 (US)", value="US"),
+ ]
+)
+async def scoring_cmd(
+ interaction: discord.Interaction,
+ market: app_commands.Choice[str] | None = None,
+ count: int = 10,
+ exclude_held: bool = True,
+):
+ await interaction.response.defer(thinking=True)
+ _log("INFO", "SLASH_SCORING_START", _interaction_actor(interaction))
+
+ if not _is_allowed_channel(interaction.channel_id):
+ _log("WARN", "SLASH_SCORING_BLOCKED", f"허용되지 않은 채널 channel={interaction.channel_id}")
+ await interaction.followup.send("❌ 이 채널에서는 사용할 수 없습니다.")
+ return
+
+ if count < 1 or count > 15:
+ await interaction.followup.send("❌ count는 1~15 범위로 입력해주세요.")
+ return
+
+ selected = market.value if market else "KR"
+ title_market = {"KR": "한국", "US": "미국"}.get(selected, "한국")
+ mode_label = "🧪 모의투자" if kis.virtual else "💰 실전투자"
+ loop = asyncio.get_running_loop()
+
+ if selected == "KR" and not kis.is_configured:
+ await interaction.followup.send(
+ "⚠️ KR 스코어링은 KIS API 설정이 필요합니다. `.env`를 확인해주세요."
+ )
+ return
+
+ status = await interaction.followup.send(
+ f"📊 {title_market} 실시간 스코어링 실행 중…",
+ wait=True,
+ )
+
+ # 보유 종목 제외를 켜도 count를 채우기 위해 여유분을 더 조회한다.
+ request_count = max(10, count * 2) if exclude_held else max(10, count)
+ try:
+ if selected == "KR":
+ candidates = await _compute_stock_scores(count=request_count)
+ else:
+ candidates = await _compute_us_stock_scores(count=request_count)
+ except Exception as e:
+ _log("ERROR", "SLASH_SCORING_ERROR", f"market={selected} error={str(e)[:200]}")
+ await status.edit(content=f"❌ 스코어링 실행 실패: {str(e)[:300]}")
+ return
+
+ if not candidates:
+ await status.edit(content=f"❌ {title_market} 스코어링 후보가 없습니다.")
+ return
+
+ filtered = candidates
+ held_tickers: set[str] = set()
+ if exclude_held and kis.is_configured:
+ try:
+ balance_data = await loop.run_in_executor(None, kis.get_balance, selected)
+ held_tickers = {h["ticker"] for h in balance_data.get("holdings", [])}
+ filtered = [c for c in candidates if c["ticker"] not in held_tickers]
+ except Exception as e:
+ _log("WARN", "SLASH_SCORING_HELD_FETCH_FAIL", f"market={selected} error={str(e)[:160]}")
+ filtered = candidates
+
+ if not filtered:
+ await status.edit(content="📋 후보 종목이 모두 이미 보유 중입니다.")
+ return
+
+ top_list = filtered[:count]
+ currency = "USD" if selected == "US" else "KRW"
+ lines = []
+ for idx, c in enumerate(top_list, 1):
+ sig_str = ", ".join(c.get("signals", []))
+ lines.append(
+ f"**{idx}. {c['name']} (`{c['ticker']}`)** — **{c['score']}점**\n"
+ f"{_format_money(c.get('price', 0), currency)} ({float(c.get('prdy_ctrt', 0)):+.2f}%) | {sig_str}"
+ )
+
+ embed = discord.Embed(
+ title=f"🏆 {title_market} 실시간 스코어링 TOP {len(top_list)}",
+ description="\n".join(lines),
+ color=0x0066FF,
+ timestamp=datetime.datetime.now(NY_TZ if selected == "US" else KST),
+ )
+ held_note = f"ON ({len(held_tickers)}개 제외)" if exclude_held else "OFF"
+ embed.set_footer(text=f"{mode_label} | 보유 제외: {held_note}")
+ await status.edit(content=None, embed=embed)
+ _log(
+ "INFO",
+ "SLASH_SCORING_DONE",
+ f"market={selected} requested={request_count} shown={len(top_list)} exclude_held={exclude_held}",
+ )
+
+
+# ─── Slash Command: /스코어규칙 ─────────────────────────────────
+@tree.command(name="스코어규칙", description="자동매매 스코어링 규칙을 조회합니다")
+@app_commands.describe(market="조회할 시장 (기본: 전체)")
+@app_commands.choices(
+ market=[
+ app_commands.Choice(name="전체", value="ALL"),
+ app_commands.Choice(name="한국 (KR)", value="KR"),
+ app_commands.Choice(name="미국 (US)", value="US"),
+ ]
+)
+async def scoring_rules_cmd(
+ interaction: discord.Interaction,
+ market: app_commands.Choice[str] | None = None,
+):
+ await interaction.response.defer(thinking=True)
+ _log("INFO", "SLASH_SCORING_RULES_START", _interaction_actor(interaction))
+
+ if not _is_allowed_channel(interaction.channel_id):
+ _log("WARN", "SLASH_SCORING_RULES_BLOCKED", f"허용되지 않은 채널 channel={interaction.channel_id}")
+ await interaction.followup.send("❌ 이 채널에서는 사용할 수 없습니다.")
+ return
+
+ selected = market.value if market else "ALL"
+ title_market = {"ALL": "전체", "KR": "한국", "US": "미국"}.get(selected, "전체")
+ mode_label = "🧪 모의투자" if kis.virtual else "💰 실전투자"
+
+ embed = discord.Embed(
+ title=f"📐 스코어링 규칙 ({title_market})",
+ description=(
+ "자동매매는 **룰 기반 스코어링 → 상위 후보만 AI 분석** 순서로 동작합니다.\n"
+ "아래 점수는 현재 코드 기준 고정 규칙입니다."
+ ),
+ color=0x0066FF,
+ timestamp=datetime.datetime.now(),
+ )
+
+ if selected in ("ALL", "KR"):
+ kr_wl = ", ".join(kis.kr_watchlist[:8])
+ if len(kis.kr_watchlist) > 8:
+ kr_wl += f" 외 {len(kis.kr_watchlist) - 8}개"
+ embed.add_field(
+ name="🇰🇷 KR 점수식 (대형주+ETF 워치리스트)",
+ value=(
+ f"후보 풀: `KR_WATCHLIST` ({len(kis.kr_watchlist)}종목)\n"
+ f"`{kr_wl}`\n"
+ "• 워치리스트 기본: `+30` (대형주/ETF 신뢰)\n"
+ "• 등락률 `0~2%`: `+25` (안정상승), `2~5%`: `+15`\n"
+ "• 시총 랭크 진입: `+10` (응답 가능 시)\n"
+ "• 거래량 랭크 진입: `+5` (응답 가능 시)\n"
+ "필터: 등락률 `>8%` 또는 `<-5%` 제외\n"
+ f"AI 분석: 상위 `{DAY_TRADE_PICKS}`개\n"
+ "오후 매도: 워치리스트 종목은 **스윙 보유** (손절/익절만)"
+ ),
+ inline=False,
+ )
+
+ if selected in ("ALL", "US"):
+ us_status = "활성" if ENABLE_US_TRADING else "비활성"
+ us_wl = ", ".join(kis.us_watchlist[:8])
+ if len(kis.us_watchlist) > 8:
+ us_wl += f" 외 {len(kis.us_watchlist) - 8}개"
+ embed.add_field(
+ name="🇺🇸 US 점수식 (대형주+ETF 워치리스트)",
+ value=(
+ f"후보 풀: `US_WATCHLIST` ({len(kis.us_watchlist)}종목)\n"
+ f"`{us_wl}`\n"
+ "• 워치리스트 기본: `+30` (대형주/ETF 신뢰)\n"
+ "• 등락률 `0~2%`: `+25` (안정상승), `2~5%`: `+15`\n"
+ "• 시총 랭크 진입: `+10` (응답 가능 시)\n"
+ "• 거래량 랭크 진입: `+5` (응답 가능 시)\n"
+ "필터: 등락률 `>8%` 또는 `<-5%` 제외\n"
+ f"AI 분석: 상위 `{US_DAY_TRADE_PICKS}`개\n"
+ "오후 매도: 워치리스트 종목은 **스윙 보유** (손절/익절만)\n"
+ f"현재 US 자동매매: **{us_status}** (`ENABLE_US_TRADING={str(ENABLE_US_TRADING).lower()}`)"
+ ),
+ inline=False,
+ )
+
+ embed.set_footer(text=f"{mode_label} | /스코어규칙")
+ await interaction.followup.send(embed=embed)
+ _log("INFO", "SLASH_SCORING_RULES_DONE", f"market={selected}")
+
+
+# ─── Slash Command: /수익 ──────────────────────────────────────
+@tree.command(name="수익", description="누적 매매 수익 현황을 조회합니다")
+async def pnl_cmd(interaction: discord.Interaction):
+ await interaction.response.defer(thinking=True)
+ _log("INFO", "SLASH_PNL_START", _interaction_actor(interaction))
+
+ if not _is_allowed_channel(interaction.channel_id):
+ _log("WARN", "SLASH_PNL_BLOCKED", f"허용되지 않은 채널 channel={interaction.channel_id}")
+ await interaction.followup.send("❌ 이 채널에서는 사용할 수 없습니다.")
+ return
+
+ by_ccy = get_total_pnl_by_currency()
+ krw = by_ccy.get("KRW", get_total_pnl(currency="KRW"))
+ usd = by_ccy.get("USD", get_total_pnl(currency="USD"))
+ ticker_krw = get_ticker_summary(currency="KRW")
+ ticker_usd = get_ticker_summary(currency="USD")
+ recent_krw = get_recent_pnl(5, currency="KRW")
+ recent_usd = get_recent_pnl(5, currency="USD")
+
+ desc_lines = [
+ f"KRW 손익: {_format_money(krw['total_pnl'], 'KRW')} | "
+ f"거래 {krw['trade_count']}회 | 승률 {krw['win_rate']}%",
+ f"USD 손익: {_format_money(usd['total_pnl'], 'USD')} | "
+ f"거래 {usd['trade_count']}회 | 승률 {usd['win_rate']}%",
+ ]
+
+ tone_total = krw["total_pnl"] + usd["total_pnl"]
+ embed = discord.Embed(
+ title="📊 매매 수익 현황 (통화 분리)",
+ description="\n".join(desc_lines),
+ color=0x00FF00 if tone_total >= 0 else 0xFF0000,
+ timestamp=datetime.datetime.now(),
+ )
+
+ if ticker_krw:
+ lines = []
+ for t in ticker_krw[:5]:
+ emoji = "🟢" if t["total_pnl"] >= 0 else "🔴"
+ lines.append(
+ f"{emoji} [{t['market']}] {t['name']} (`{t['ticker']}`) "
+ f"— {t['count']}회 | {_format_money(t['total_pnl'], 'KRW')} | 평균 {t['avg_pnl_rate']:+.1f}%"
+ )
+ embed.add_field(name="🏢 KRW 종목별", value="\n".join(lines), inline=False)
+
+ if ticker_usd:
+ lines = []
+ for t in ticker_usd[:5]:
+ emoji = "🟢" if t["total_pnl"] >= 0 else "🔴"
+ lines.append(
+ f"{emoji} [{t['market']}] {t['name']} (`{t['ticker']}`) "
+ f"— {t['count']}회 | {_format_money(t['total_pnl'], 'USD')} | 평균 {t['avg_pnl_rate']:+.1f}%"
+ )
+ embed.add_field(name="🌎 USD 종목별", value="\n".join(lines), inline=False)
+
+ if recent_krw:
+ lines = []
+ for r in recent_krw[:3]:
+ emoji = "🟢" if r["pnl"] >= 0 else "🔴"
+ lines.append(
+ f"{emoji} {r['name']} — {_format_money(r['pnl'], 'KRW')} "
+ f"({r['pnl_rate']:+.1f}%) | {r['created_at']}"
+ )
+ embed.add_field(name="🕗 최근 KRW 손익", value="\n".join(lines), inline=False)
+
+ if recent_usd:
+ lines = []
+ for r in recent_usd[:3]:
+ emoji = "🟢" if r["pnl"] >= 0 else "🔴"
+ lines.append(
+ f"{emoji} {r['name']} — {_format_money(r['pnl'], 'USD')} "
+ f"({r['pnl_rate']:+.1f}%) | {r['created_at']}"
+ )
+ embed.add_field(name="🕗 최근 USD 손익", value="\n".join(lines), inline=False)
+
+ embed.set_footer(text="TradingAgents 매매 이력 (통화 분리, 마지막 초기화 이후 기준)")
+ await interaction.followup.send(embed=embed)
+ _log(
+ "INFO",
+ "SLASH_PNL_DONE",
+ f"krw_total={krw['total_pnl']} usd_total={usd['total_pnl']}",
+ )
+
+
+@tree.command(name="수익초기화", description="누적 실현손익 집계 기준을 초기화합니다")
+@app_commands.describe(currency="초기화할 통화 범위 (기본: 전체)")
+@app_commands.choices(
+ currency=[
+ app_commands.Choice(name="전체", value="ALL"),
+ app_commands.Choice(name="KRW", value="KRW"),
+ app_commands.Choice(name="USD", value="USD"),
+ ]
+)
+async def pnl_reset_cmd(
+ interaction: discord.Interaction,
+ currency: app_commands.Choice[str] | None = None,
+):
+ await interaction.response.defer(thinking=True, ephemeral=True)
+
+ selected = currency.value if currency else "ALL"
+ _log("INFO", "SLASH_PNL_RESET_START", f"{_interaction_actor(interaction)} currency={selected}")
+
+ if not _is_allowed_channel(interaction.channel_id):
+ _log("WARN", "SLASH_PNL_RESET_BLOCKED", f"허용되지 않은 채널 channel={interaction.channel_id}")
+ await interaction.followup.send("❌ 이 채널에서는 사용할 수 없습니다.", ephemeral=True)
+ return
+
+ target_currency = None if selected == "ALL" else selected
+ summary_targets = ("KRW", "USD") if target_currency is None else (target_currency,)
+ summary = {
+ code: get_total_pnl(currency=code)
+ for code in summary_targets
+ }
+ reset_at = reset_pnl_history(
+ currency=target_currency,
+ reset_by=str(interaction.user),
+ reason=f"discord:/수익초기화 by {getattr(interaction.user, 'id', 'unknown')}",
+ )
+
+ cleared_lines = [
+ f"- {code}: {_format_money(data['total_pnl'], code)} | 거래 {data['trade_count']}회 | 승률 {data['win_rate']}%"
+ for code, data in summary.items()
+ ]
+ target_label = "전체 통화" if target_currency is None else target_currency
+ message = "\n".join(
+ [
+ f"✅ `{target_label}` 수익 집계 기준을 초기화했습니다.",
+ f"기준 시각: `{reset_at}`",
+ *cleared_lines,
+ "과거 손익 로그는 보존되며, 이제 `/수익`은 이 시각 이후 실현손익만 집계합니다.",
+ ]
+ )
+ await interaction.followup.send(message, ephemeral=True)
+ _log("INFO", "SLASH_PNL_RESET_DONE", f"currency={selected} reset_at={reset_at}")
+
+
+# ─── 스케줄: 아침 자동매수 (09:30 KST) ───────────────────
+
+
+@tasks.loop(time=datetime.time(hour=_buy_h, minute=_buy_m, tzinfo=KST))
+async def morning_auto_buy():
+ """매일 아침(기본 09:30) 실시간 스코어링 → 상위 AI 분석 → 자동 매수.
+
+ 1) 실시간 KIS 순위 API 4종으로 멀티시그널 스코어링
+ 2) 상위 DAY_TRADE_PICKS개 후보만 순차 AI 분석 (BUY 판정만 수집)
+ 3) 통장 전액 ÷ BUY 종목수 균등분배 → 시장가 매수
+ """
+ if not ALLOWED_CHANNEL_IDS or not kis.is_configured:
+ _log("INFO", "AUTO_BUY_SKIP", "채널 미설정 또는 KIS 미설정")
+ return
+ if not _is_market_day("KR"):
+ _log("INFO", "AUTO_BUY_SKIP", "오늘은 휴장일")
+ return
+ if _analysis_lock.locked():
+ _log("INFO", "AUTO_BUY_SKIP", "analysis lock 사용 중")
+ return
+ # 재시작 중복 방지: 오늘 이미 매수 완료했으면 스킵
+ if is_action_done("morning_buy"):
+ _log("INFO", "AUTO_BUY_SKIP", "오늘 morning_buy 이미 완료")
+ return
+
+ channel_id = next(iter(ALLOWED_CHANNEL_IDS))
+ channel = bot.get_channel(channel_id)
+ if not isinstance(channel, discord.TextChannel):
+ _log("WARN", "AUTO_BUY_SKIP", f"채널 접근 실패 channel_id={channel_id}")
+ return
+
+ trade_date = str(datetime.date.today())
+ mode_label = "🧪 모의투자" if kis.virtual else "💰 실전투자"
+ loop = asyncio.get_running_loop()
+
+ async with _analysis_lock:
+ _log("INFO", "AUTO_BUY_START", f"date={trade_date} target_picks={DAY_TRADE_PICKS}")
+ await channel.send(
+ f"🌅 **대형주+ETF 자동매수** 시작 ({AUTO_BUY_TIME} KST)"
+ )
+
+ # ── 1) 워치리스트(대형주+ETF) 스코어링 ──
+ try:
+ scoring_msg = await channel.send("📊 워치리스트(대형주+ETF) 스코어링 중…")
+ candidates = await _compute_stock_scores(count=10)
+ except Exception as e:
+ _log("ERROR", "AUTO_BUY_SCORING_ERROR", str(e)[:200])
+ await channel.send(f"❌ 순위 조회 실패: {str(e)[:300]}")
+ return
+
+ if not candidates:
+ _log("INFO", "AUTO_BUY_NO_CANDIDATE", "스코어링 결과 후보 없음")
+ await scoring_msg.edit(content="❌ 매수 후보가 없습니다. (시장 상황 부적합)")
+ return
+
+ # 이미 보유 중인 종목 제외
+ try:
+ balance_data = await loop.run_in_executor(None, kis.get_balance, "KR")
+ held_tickers = {h["ticker"] for h in balance_data.get("holdings", [])}
+ except Exception:
+ held_tickers = set()
+
+ filtered = [c for c in candidates if c["ticker"] not in held_tickers]
+ if not filtered:
+ _log("INFO", "AUTO_BUY_ALL_HELD", "후보가 모두 보유 종목")
+ await scoring_msg.edit(content="📋 스코어링 후보가 모두 이미 보유 중입니다.")
+ return
+
+ _log("INFO", "AUTO_BUY_CANDIDATES", f"raw={len(candidates)} filtered={len(filtered)}")
+
+ # 후보 리스트 임베드
+ desc_lines = []
+ for c in filtered:
+ sig_str = ", ".join(c["signals"])
+ desc_lines.append(
+ f"**{c['score']}점** {c['name']} (`{c['ticker']}`) "
+ f"— {c['price']:,}원 ({c['prdy_ctrt']:+.1f}%) | {sig_str}"
+ )
+ score_embed = discord.Embed(
+ title=f"🏆 멀티시그널 후보 TOP {len(filtered)}",
+ description="\n".join(desc_lines),
+ color=0x0066FF,
+ )
+ score_embed.set_footer(text=mode_label)
+ await scoring_msg.edit(content=None, embed=score_embed)
+
+ # ── 2) 상위 후보 순차 AI 분석 → BUY만 수집 ──
+ buy_targets: list[dict] = []
+ analyzed_count = 0
+ analysis_candidates = filtered[:DAY_TRADE_PICKS]
+ for c in analysis_candidates:
+
+ analyzed_count += 1
+ progress = await channel.send(
+ f"🔍 [{analyzed_count}/{len(analysis_candidates)}] "
+ f"**{c['name']}** (`{c['ticker']}`) AI 분석 중… (약 3~5분)"
+ )
+ try:
+ ta = TradingAgentsGraph(debug=False, config=config)
+ analysis_symbol = _yf_ticker(c["ticker"], reference_price=c["price"])
+ final_state, decision = await loop.run_in_executor(
+ None, ta.propagate, analysis_symbol, trade_date
+ )
+ emoji = "🟢" if decision == "BUY" else "🔴" if decision == "SELL" else "🟡"
+ color_map = {"BUY": 0x00FF00, "SELL": 0xFF0000, "HOLD": 0xFFAA00}
+ summary = _extract_decision_summary(final_state, decision, c["ticker"])
+ embed = discord.Embed(
+ title=f"{emoji} {c['name']} ({c['ticker']}) → {decision}",
+ description=summary,
+ color=color_map.get(decision.upper(), 0x808080),
+ )
+ await progress.edit(content=None, embed=embed)
+
+ report_text = _build_report_text(
+ final_state,
+ c["ticker"],
+ market="KR",
+ analysis_symbol=analysis_symbol,
+ )
+ report_file, report_path = _prepare_report_attachment(
+ report_text,
+ market="KR",
+ ticker=c["ticker"],
+ trade_date=trade_date,
+ scope="AUTO_KR",
+ )
+ if AUTO_REPORT_UPLOAD:
+ try:
+ await channel.send(file=report_file)
+ except Exception as e:
+ _log(
+ "WARN",
+ "AUTO_BUY_REPORT_UPLOAD_FAIL",
+ f"ticker={c['ticker']} error={str(e)[:160]} path={report_path or 'N/A'}",
+ )
+ await channel.send(
+ "⚠️ 보고서 파일 업로드에 실패했습니다. "
+ + (f"로컬 저장 파일: `{report_path}`" if report_path else "로컬 저장도 실패했습니다.")
+ )
+
+ if decision.upper() == "BUY":
+ buy_targets.append({
+ "ticker": c["ticker"],
+ "name": c["name"],
+ "price": c["price"],
+ "score": c["score"],
+ "signals": c["signals"],
+ })
+ _log("INFO", "AUTO_BUY_ANALYZED", f"ticker={c['ticker']} decision={decision}")
+ except Exception as e:
+ _log("ERROR", "AUTO_BUY_ANALYZE_ERROR", f"ticker={c['ticker']} error={str(e)[:160]}")
+ await progress.edit(
+ content=f"❌ {c['name']} 분석 실패: {str(e)[:200]}"
+ )
+
+ if not buy_targets:
+ _log("INFO", "AUTO_BUY_NO_BUY_TARGET", "분석 완료 후 BUY 대상 없음")
+ await channel.send("📋 **AI 분석 완료** — BUY 종목이 없어 매수를 건너뜁니다.")
+ return
+
+ # ── 3) 장 열림 대기 → 통장 전액 균등분배 → 자동 매수 ──
+ if not await _wait_for_market_open(channel, "KR"):
+ _log("INFO", "AUTO_BUY_SKIP", "장 마감 이후라 자동매수 생략")
+ await channel.send("❌ 장 마감 이후라 자동매수를 건너뜁니다.")
+ return
+
+ try:
+ balance_data = await loop.run_in_executor(None, kis.get_balance, "KR")
+ cash = balance_data.get("summary", {}).get("cash", 0)
+ except Exception as e:
+ _log("ERROR", "AUTO_BUY_BALANCE_ERROR", str(e)[:200])
+ await channel.send(f"❌ 잔액 조회 실패: {str(e)[:300]}")
+ return
+
+ if cash <= 0:
+ _log("WARN", "AUTO_BUY_NO_CASH", "예수금 0원")
+ await channel.send("❌ 예수금이 0원입니다. 매수할 수 없습니다.")
+ return
+
+ budget_info = _compute_auto_buy_budget("KR", cash)
+ daily_budget = int(budget_info["usable_budget"])
+ if daily_budget <= 0:
+ _log("WARN", "AUTO_BUY_NO_BUDGET", f"cash={cash} ratio={budget_info['ratio']}")
+ await channel.send("❌ 오늘 사용할 자동매수 예산이 0원이라 매수를 건너뜁니다.")
+ return
+
+ per_stock_budget = int(daily_budget // len(buy_targets))
+ await channel.send(
+ "💸 **KR 자동매수 예산**\n"
+ f"가용 예수금: {format_krw(cash)}\n"
+ f"기준 자금(anchor): {format_krw(budget_info['anchor'])}\n"
+ f"적용 비율: {budget_info['ratio'] * 100:.1f}%\n"
+ f"오늘 사용 예산: {format_krw(daily_budget)}"
+ )
+ buy_results: list[str] = []
+ total_invested = 0
+
+ for target in buy_targets:
+ # 매수 직전 현재가 재조회
+ try:
+ current_price = await loop.run_in_executor(None, kis.get_price, target["ticker"], "KR")
+ except Exception:
+ current_price = target["price"]
+ if current_price <= 0:
+ buy_results.append(f"⚠️ {target['name']} — 현재가 조회 실패")
+ continue
+
+ qty = int(per_stock_budget // current_price)
+ if qty <= 0:
+ buy_results.append(
+ f"⚠️ {target['name']} — 예산({format_krw(per_stock_budget)}) 부족"
+ )
+ continue
+
+ # 잔액 재확인
+ try:
+ fresh_bal = await loop.run_in_executor(None, kis.get_balance, "KR")
+ remaining_cash = fresh_bal.get("summary", {}).get("cash", 0)
+ except Exception:
+ remaining_cash = cash
+
+ remaining_budget = max(daily_budget - total_invested, 0)
+ effective_cash = min(remaining_cash, remaining_budget)
+
+ if qty * current_price > effective_cash:
+ qty = int(effective_cash // current_price)
+ if qty <= 0:
+ buy_results.append(f"⚠️ {target['name']} — 잔액 부족")
+ continue
+
+ try:
+ result = await loop.run_in_executor(
+ None, kis.buy_stock, target["ticker"], qty, 0, "KR"
+ )
+ if result["success"]:
+ amount = qty * current_price
+ total_invested += amount
+ record_trade(
+ target["ticker"], target["name"], "BUY",
+ qty, current_price,
+ order_no=result.get("order_no", ""),
+ reason=f"데이트레이딩 자동매수 (score={target['score']})",
+ market="KR",
+ currency="KRW",
+ )
+ buy_results.append(
+ f"✅ {target['name']} ({target['ticker']}) — "
+ f"{qty}주 × {current_price:,}원 = {format_krw(amount)}"
+ )
+ else:
+ buy_results.append(
+ f"❌ {target['name']} 매수실패: {result['message'][:180]}"
+ )
+ except Exception as e:
+ buy_results.append(f"❌ {target['name']} 매수오류: {str(e)[:180]}")
+
+ # ── 결과 임베드 ──
+ result_embed = discord.Embed(
+ title=f"🌅 자동매수 결과 ({len(buy_targets)}종목)",
+ description="\n".join(buy_results),
+ color=0x00FF00,
+ timestamp=datetime.datetime.now(KST),
+ )
+ result_embed.add_field(
+ name="투자금액", value=format_krw(total_invested), inline=True
+ )
+ result_embed.add_field(
+ name="예산 잔여", value=format_krw(max(daily_budget - total_invested, 0)), inline=True
+ )
+ result_embed.add_field(
+ name="예수금 잔액", value=format_krw(max(cash - total_invested, 0)), inline=True
+ )
+ result_embed.set_footer(text=f"데이 트레이딩 | {mode_label}")
+ await channel.send(embed=result_embed)
+
+ # 매수 완료 상태 기록 (재시작 시 중복 방지)
+ bought_names = ", ".join(t["name"] for t in buy_targets)
+ mark_action_done("morning_buy", details=f"매수: {bought_names}")
+ _log("INFO", "AUTO_BUY_DONE", f"buy_count={len(buy_targets)} invested={total_invested}")
+
+
+@morning_auto_buy.before_loop
+async def before_morning():
+ await bot.wait_until_ready()
+
+
+# ─── 스케줄: 오후 자동매도 (15:20 KST) ───────────────────
+
+
+@tasks.loop(time=datetime.time(hour=_sell_h, minute=_sell_m, tzinfo=KST))
+async def afternoon_auto_sell():
+ """매일 오후(기본 15:20) 워치리스트 외 종목만 전량 매도.
+
+ 워치리스트(대형주/ETF)에 포함된 종목은 당일 강제매도하지 않고
+ 손절/익절 모니터링에만 맡긴다. 스윙 보유를 허용한다.
+ """
+ if not ALLOWED_CHANNEL_IDS or not kis.is_configured:
+ _log("INFO", "AUTO_SELL_SKIP", "채널 미설정 또는 KIS 미설정")
+ return
+ if not _is_market_day("KR"):
+ _log("INFO", "AUTO_SELL_SKIP", "오늘은 휴장일")
+ return
+ # 재시작 중복 방지: 오늘 이미 매도 완료했으면 스킵
+ if is_action_done("afternoon_sell"):
+ _log("INFO", "AUTO_SELL_SKIP", "오늘 afternoon_sell 이미 완료")
+ return
+
+ channel_id = next(iter(ALLOWED_CHANNEL_IDS))
+ channel = bot.get_channel(channel_id)
+ if not isinstance(channel, discord.TextChannel):
+ _log("WARN", "AUTO_SELL_SKIP", f"채널 접근 실패 channel_id={channel_id}")
+ return
+
+ mode_label = "🧪 모의투자" if kis.virtual else "💰 실전투자"
+ loop = asyncio.get_running_loop()
+ _log("INFO", "AUTO_SELL_START", f"time={AUTO_SELL_TIME}")
+
+ await channel.send(
+ f"🌇 **오후 매도 점검** 시작 ({AUTO_SELL_TIME} KST)\n"
+ f"ℹ️ 워치리스트(대형주/ETF) 종목은 스윙 보유 → 손절/익절만 적용"
+ )
+
+ # 보유종목 확인
+ try:
+ balance_data = await loop.run_in_executor(None, kis.get_balance, "KR")
+ holdings = balance_data.get("holdings", [])
+ except Exception as e:
+ _log("ERROR", "AUTO_SELL_BALANCE_ERROR", str(e)[:200])
+ await channel.send(f"❌ 잔고 조회 실패: {str(e)[:300]}")
+ return
+
+ if not holdings:
+ _log("INFO", "AUTO_SELL_EMPTY", "보유 종목 없음")
+ await channel.send("📋 보유 종목이 없습니다. 매도 생략.")
+ return
+
+ # 워치리스트 종목은 당일 강제매도에서 제외
+ kr_watchlist_set = set(kis.kr_watchlist)
+ sell_holdings = [h for h in holdings if h["ticker"] not in kr_watchlist_set]
+ keep_holdings = [h for h in holdings if h["ticker"] in kr_watchlist_set]
+
+ if keep_holdings:
+ keep_names = ", ".join(f"{h['name']}({h['ticker']})" for h in keep_holdings)
+ await channel.send(
+ f"🏦 **스윙 보유 유지** ({len(keep_holdings)}종목): {keep_names}\n"
+ f"→ 손절({STOP_LOSS_PCT}%)/익절({TAKE_PROFIT_PCT}%) 모니터링만 적용"
+ )
+ _log("INFO", "AUTO_SELL_KEEP_WATCHLIST", f"keep={len(keep_holdings)} tickers={keep_names}")
+
+ if not sell_holdings:
+ _log("INFO", "AUTO_SELL_ALL_WATCHLIST", "전종목이 워치리스트 → 매도 생략")
+ await channel.send("📋 **워치리스트 외 매도 대상이 없습니다.** 전종목 스윙 보유.")
+ mark_action_done("afternoon_sell", details="전종목 워치리스트 보유")
+ return
+
+ _log("INFO", "AUTO_SELL_HOLDINGS", f"total={len(holdings)} sell={len(sell_holdings)} keep={len(keep_holdings)}")
+
+ # 워치리스트 외 종목만 전량 매도
+ sell_results = []
+ for h in sell_holdings:
+ try:
+ result = await loop.run_in_executor(
+ None, kis.sell_stock, h["ticker"], h["qty"], 0, "KR"
+ )
+ sell_results.append({
+ "success": result["success"],
+ "ticker": h["ticker"],
+ "name": h["name"],
+ "qty": h["qty"],
+ "avg_price": h.get("avg_price", 0),
+ "sell_price": h.get("current_price", 0),
+ "order_no": result.get("order_no", ""),
+ "message": result.get("message", ""),
+ })
+ except Exception as e:
+ sell_results.append({
+ "success": False,
+ "ticker": h["ticker"],
+ "name": h["name"],
+ "qty": h["qty"],
+ "avg_price": h.get("avg_price", 0),
+ "sell_price": 0,
+ "order_no": "",
+ "message": str(e)[:200],
+ })
+
+ # DB 기록 + 임베드 작성
+ result_lines: list[str] = []
+ total_pnl = 0
+ total_invested = 0
+ total_recovered = 0
+
+ for sr in sell_results:
+ if sr["success"]:
+ record_trade(
+ sr["ticker"], sr["name"], "SELL",
+ sr["qty"], sr["sell_price"],
+ order_no=sr.get("order_no", ""),
+ reason="데이트레이딩 자동매도",
+ market="KR",
+ currency="KRW",
+ )
+ if sr["avg_price"] > 0 and sr["sell_price"] > 0:
+ record_pnl(
+ sr["ticker"], sr["name"],
+ sr["avg_price"], sr["sell_price"], sr["qty"],
+ market="KR",
+ currency="KRW",
+ )
+ pnl = (sr["sell_price"] - sr["avg_price"]) * sr["qty"]
+ pnl_rate = (
+ (sr["sell_price"] - sr["avg_price"]) / sr["avg_price"] * 100
+ if sr["avg_price"] > 0 else 0
+ )
+ invested = sr["avg_price"] * sr["qty"]
+ recovered = sr["sell_price"] * sr["qty"]
+ total_pnl += pnl
+ total_invested += invested
+ total_recovered += recovered
+ emoji = "🟢" if pnl >= 0 else "🔴"
+ result_lines.append(
+ f"{emoji} **{sr['name']}** (`{sr['ticker']}`) — "
+ f"{sr['qty']}주 | {_format_money(sr['avg_price'], 'KRW')}→"
+ f"{_format_money(sr['sell_price'], 'KRW')} | "
+ f"{_format_money(pnl, 'KRW')} ({pnl_rate:+.1f}%)"
+ )
+ else:
+ result_lines.append(
+ f"❌ **{sr['name']}** (`{sr['ticker']}`) 매도실패: {sr['message'][:80]}"
+ )
+
+ # 실패한 종목 1회 재시도
+ failed = [sr for sr in sell_results if not sr["success"]]
+ if failed:
+ await channel.send(f"⚠️ 매도 실패 {len(failed)}건 — 60초 후 재시도…")
+ await asyncio.sleep(60)
+ for sr in failed:
+ try:
+ retry = await loop.run_in_executor(
+ None, kis.sell_stock, sr["ticker"], sr["qty"], 0, "KR"
+ )
+ if retry["success"]:
+ try:
+ sp = await loop.run_in_executor(None, kis.get_price, sr["ticker"], "KR")
+ except Exception:
+ sp = 0
+ record_trade(
+ sr["ticker"], sr["name"], "SELL", sr["qty"], sp,
+ order_no=retry.get("order_no", ""),
+ reason="데이트레이딩 재시도매도",
+ market="KR",
+ currency="KRW",
+ )
+ if sr["avg_price"] > 0 and sp > 0:
+ record_pnl(
+ sr["ticker"],
+ sr["name"],
+ sr["avg_price"],
+ sp,
+ sr["qty"],
+ market="KR",
+ currency="KRW",
+ )
+ pnl = (sp - sr["avg_price"]) * sr["qty"]
+ result_lines.append(
+ f"✅ [재시도 성공] {sr['name']} — {_format_money(pnl, 'KRW')}"
+ )
+ total_pnl += pnl
+ else:
+ result_lines.append(
+ f"❌ [재시도 실패] {sr['name']}: {retry['message'][:80]}"
+ )
+ except Exception as e:
+ result_lines.append(
+ f"❌ [재시도 오류] {sr['name']}: {str(e)[:80]}"
+ )
+
+ # 일일 손익 요약 임베드
+ pnl_emoji = "🟢" if total_pnl >= 0 else "🔴"
+ cumulative = get_total_pnl(currency="KRW")
+
+ sell_embed = discord.Embed(
+ title="🌇 오후 매도 결과 (워치리스트 외 종목)",
+ description="\n".join(result_lines) if result_lines else "매도 대상 없음",
+ color=0x00FF00 if total_pnl >= 0 else 0xFF0000,
+ timestamp=datetime.datetime.now(KST),
+ )
+ sell_embed.add_field(
+ name=f"{pnl_emoji} 오늘 손익", value=_format_money(total_pnl, "KRW"), inline=True
+ )
+ sell_embed.add_field(
+ name="투입금액", value=_format_money(total_invested, "KRW"), inline=True
+ )
+ sell_embed.add_field(
+ name="회수금액", value=_format_money(total_recovered, "KRW"), inline=True
+ )
+ sell_embed.add_field(
+ name="📊 누적 손익",
+ value=f"{_format_money(cumulative['total_pnl'], 'KRW')} | 승률 {cumulative['win_rate']}%",
+ inline=False,
+ )
+ sell_embed.set_footer(text=f"대형주+ETF 전략 | {mode_label}")
+ await channel.send(embed=sell_embed)
+
+ # 매도 완료 상태 기록 (재시작 시 중복 방지)
+ mark_action_done("afternoon_sell", details=f"매도={len(sell_results)} 보유유지={len(keep_holdings)}")
+ _log("INFO", "AUTO_SELL_DONE", f"sold={len(sell_results)} kept={len(keep_holdings)} total_pnl={total_pnl}")
+
+
+@afternoon_auto_sell.before_loop
+async def before_afternoon():
+ await bot.wait_until_ready()
+
+
+# ─── 스케줄: 미국 자동매수 (09:35 ET) ───────────────────────
+@tasks.loop(time=datetime.time(hour=_us_buy_h, minute=_us_buy_m, tzinfo=NY_TZ))
+async def us_morning_auto_buy():
+ """매일 오전(미국 현지) 상위 후보 분석 후 자동 매수."""
+ if not ENABLE_US_TRADING or not kis.enable_us_trading:
+ return
+ if not ALLOWED_CHANNEL_IDS or not kis.is_configured:
+ _log("INFO", "US_AUTO_BUY_SKIP", "채널 미설정 또는 KIS 미설정")
+ return
+ if not _is_market_day("US"):
+ _log("INFO", "US_AUTO_BUY_SKIP", "오늘은 미국시장 휴장일")
+ return
+ if _analysis_lock.locked():
+ _log("INFO", "US_AUTO_BUY_SKIP", "analysis lock 사용 중")
+ return
+ if is_action_done("us_morning_buy"):
+ _log("INFO", "US_AUTO_BUY_SKIP", "오늘 us_morning_buy 이미 완료")
+ return
+
+ channel_id = next(iter(ALLOWED_CHANNEL_IDS))
+ channel = bot.get_channel(channel_id)
+ if not isinstance(channel, discord.TextChannel):
+ _log("WARN", "US_AUTO_BUY_SKIP", f"채널 접근 실패 channel_id={channel_id}")
+ return
+
+ trade_date = str(datetime.datetime.now(NY_TZ).date())
+ mode_label = "🧪 모의투자" if kis.virtual else "💰 실전투자"
+ loop = asyncio.get_running_loop()
+
+ async with _analysis_lock:
+ _log("INFO", "US_AUTO_BUY_START", f"date={trade_date} target_picks={US_DAY_TRADE_PICKS}")
+ await channel.send(
+ f"🇺🇸🌅 **미국 자동매수** 시작 ({US_AUTO_BUY_TIME} ET)"
+ )
+
+ try:
+ scoring_msg = await channel.send("📊 미국 워치리스트(대형주+ETF) 스코어링 중…")
+ candidates = await _compute_us_stock_scores(count=max(10, US_DAY_TRADE_PICKS * 2))
+ except Exception as e:
+ _log("ERROR", "US_AUTO_BUY_SCORING_ERROR", str(e)[:200])
+ await channel.send(f"❌ 미국 후보 조회 실패: {str(e)[:300]}")
+ return
+
+ if not candidates:
+ _log("INFO", "US_AUTO_BUY_NO_CANDIDATE", "후보 없음")
+ await scoring_msg.edit(content="❌ 미국 매수 후보가 없습니다.")
+ return
+
+ try:
+ balance_data = await loop.run_in_executor(None, kis.get_balance, "US")
+ held_tickers = {h["ticker"] for h in balance_data.get("holdings", [])}
+ except Exception:
+ held_tickers = set()
+
+ filtered = [c for c in candidates if c["ticker"] not in held_tickers]
+ if not filtered:
+ await scoring_msg.edit(content="📋 후보 종목이 모두 이미 보유 중입니다.")
+ return
+
+ desc_lines = []
+ for c in filtered:
+ sig_str = ", ".join(c["signals"])
+ desc_lines.append(
+ f"**{c['score']}점** {c['name']} (`{c['ticker']}`) "
+ f"— {_format_money(c['price'], 'USD')} ({c['prdy_ctrt']:+.2f}%) | {sig_str}"
+ )
+ score_embed = discord.Embed(
+ title=f"🇺🇸 워치리스트 후보 TOP {len(filtered)}",
+ description="\n".join(desc_lines),
+ color=0x0066FF,
+ timestamp=datetime.datetime.now(NY_TZ),
+ )
+ score_embed.set_footer(text=f"{mode_label} | USD")
+ await scoring_msg.edit(content=None, embed=score_embed)
+
+ buy_targets: list[dict] = []
+ analyzed_count = 0
+ analysis_candidates = filtered[:US_DAY_TRADE_PICKS]
+ for c in analysis_candidates:
+ analyzed_count += 1
+ progress = await channel.send(
+ f"🔍 [{analyzed_count}/{len(analysis_candidates)}] "
+ f"**{c['name']}** (`{c['ticker']}`) AI 분석 중…"
+ )
+ try:
+ ta = TradingAgentsGraph(debug=False, config=config)
+ final_state, decision = await loop.run_in_executor(
+ None, ta.propagate, c["ticker"], trade_date
+ )
+ emoji = "🟢" if decision == "BUY" else "🔴" if decision == "SELL" else "🟡"
+ color_map = {"BUY": 0x00FF00, "SELL": 0xFF0000, "HOLD": 0xFFAA00}
+ summary = _extract_decision_summary(final_state, decision, c["ticker"], "US")
+ embed = discord.Embed(
+ title=f"{emoji} {c['name']} ({c['ticker']}) → {decision}",
+ description=summary,
+ color=color_map.get(decision.upper(), 0x808080),
+ )
+ await progress.edit(content=None, embed=embed)
+
+ report_text = _build_report_text(
+ final_state,
+ c["ticker"],
+ market="US",
+ analysis_symbol=c["ticker"],
+ )
+ report_file, report_path = _prepare_report_attachment(
+ report_text,
+ market="US",
+ ticker=c["ticker"],
+ trade_date=trade_date,
+ scope="AUTO_US",
+ )
+ if AUTO_REPORT_UPLOAD:
+ try:
+ await channel.send(file=report_file)
+ except Exception as e:
+ _log(
+ "WARN",
+ "US_AUTO_BUY_REPORT_UPLOAD_FAIL",
+ f"ticker={c['ticker']} error={str(e)[:160]} path={report_path or 'N/A'}",
+ )
+ await channel.send(
+ "⚠️ 보고서 파일 업로드에 실패했습니다. "
+ + (f"로컬 저장 파일: `{report_path}`" if report_path else "로컬 저장도 실패했습니다.")
+ )
+
+ if decision.upper() == "BUY":
+ buy_targets.append(c)
+ _log("INFO", "US_AUTO_BUY_ANALYZED", f"ticker={c['ticker']} decision={decision}")
+ except Exception as e:
+ _log("ERROR", "US_AUTO_BUY_ANALYZE_ERROR", f"ticker={c['ticker']} error={str(e)[:160]}")
+ await progress.edit(content=f"❌ {c['name']} 분석 실패: {str(e)[:200]}")
+
+ if not buy_targets:
+ await channel.send("📋 **미국 AI 분석 완료** — BUY 종목이 없어 매수를 건너뜁니다.")
+ return
+
+ if not await _wait_for_market_open(channel, "US"):
+ _log("INFO", "US_AUTO_BUY_SKIP", "미국 장 마감 이후라 자동매수 생략")
+ await channel.send("❌ 미국 장 마감 이후라 자동매수를 건너뜁니다.")
+ return
+
+ try:
+ balance_data = await loop.run_in_executor(None, kis.get_balance, "US")
+ cash = balance_data.get("summary", {}).get("USD", {}).get("cash", 0)
+ except Exception as e:
+ _log("ERROR", "US_AUTO_BUY_BALANCE_ERROR", str(e)[:200])
+ await channel.send(f"❌ USD 잔액 조회 실패: {str(e)[:300]}")
+ return
+
+ if cash <= 0:
+ await channel.send("❌ USD 예수금이 0입니다. 매수를 건너뜁니다.")
+ return
+
+ budget_info = _compute_auto_buy_budget("US", cash)
+ daily_budget = float(budget_info["usable_budget"])
+ if daily_budget <= 0:
+ _log("WARN", "US_AUTO_BUY_NO_BUDGET", f"cash={cash} ratio={budget_info['ratio']}")
+ await channel.send("❌ 오늘 사용할 미국 자동매수 예산이 0이라 매수를 건너뜁니다.")
+ return
+
+ per_stock_budget = float(daily_budget) / len(buy_targets)
+ await channel.send(
+ "💸 **US 자동매수 예산**\n"
+ f"가용 예수금: {_format_money(cash, 'USD')}\n"
+ f"기준 자금(anchor): {_format_money(budget_info['anchor'], 'USD')}\n"
+ f"적용 비율: {budget_info['ratio'] * 100:.1f}%\n"
+ f"오늘 사용 예산: {_format_money(daily_budget, 'USD')}"
+ )
+ buy_results: list[str] = []
+ total_invested = 0.0
+
+ for target in buy_targets:
+ try:
+ current_price = await loop.run_in_executor(None, kis.get_price, target["ticker"], "US")
+ except Exception:
+ current_price = target["price"]
+ if current_price <= 0:
+ buy_results.append(f"⚠️ {target['name']} — 현재가 조회 실패")
+ continue
+
+ qty = int(per_stock_budget // current_price)
+ if qty <= 0:
+ buy_results.append(
+ f"⚠️ {target['name']} — 예산({_format_money(per_stock_budget, 'USD')}) 부족"
+ )
+ continue
+
+ try:
+ fresh_bal = await loop.run_in_executor(None, kis.get_balance, "US")
+ remaining_cash = fresh_bal.get("summary", {}).get("USD", {}).get("cash", cash)
+ except Exception:
+ remaining_cash = cash
+
+ remaining_budget = max(daily_budget - total_invested, 0.0)
+ effective_cash = min(remaining_cash, remaining_budget)
+
+ if qty * current_price > effective_cash:
+ qty = int(effective_cash // current_price)
+ if qty <= 0:
+ buy_results.append(f"⚠️ {target['name']} — 잔액 부족")
+ continue
+
+ try:
+ result = await loop.run_in_executor(None, kis.buy_stock, target["ticker"], qty, 0, "US")
+ if result["success"]:
+ amount = qty * current_price
+ total_invested += amount
+ record_trade(
+ target["ticker"],
+ target["name"],
+ "BUY",
+ qty,
+ current_price,
+ order_no=result.get("order_no", ""),
+ reason=f"미국 자동매수 (score={target['score']})",
+ market="US",
+ currency="USD",
+ )
+ buy_results.append(
+ f"✅ {target['name']} ({target['ticker']}) — "
+ f"{qty}주 × {_format_money(current_price, 'USD')} = {_format_money(amount, 'USD')}"
+ )
+ else:
+ buy_results.append(f"❌ {target['name']} 매수실패: {result['message'][:180]}")
+ except Exception as e:
+ buy_results.append(f"❌ {target['name']} 매수오류: {str(e)[:180]}")
+
+ result_embed = discord.Embed(
+ title=f"🇺🇸🌅 자동매수 결과 ({len(buy_targets)}종목)",
+ description="\n".join(buy_results),
+ color=0x00FF00,
+ timestamp=datetime.datetime.now(NY_TZ),
+ )
+ result_embed.add_field(name="투자금액", value=_format_money(total_invested, "USD"), inline=True)
+ result_embed.add_field(
+ name="예산 잔여",
+ value=_format_money(max(daily_budget - total_invested, 0), "USD"),
+ inline=True,
+ )
+ result_embed.add_field(
+ name="예수금 잔액",
+ value=_format_money(max(cash - total_invested, 0), "USD"),
+ inline=True,
+ )
+ result_embed.set_footer(text=f"미국 대형주+ETF 전략 | {mode_label}")
+ await channel.send(embed=result_embed)
+
+ bought_names = ", ".join(t["name"] for t in buy_targets)
+ mark_action_done("us_morning_buy", details=f"매수: {bought_names}")
+ _log("INFO", "US_AUTO_BUY_DONE", f"buy_count={len(buy_targets)} invested={total_invested}")
+
+
+@us_morning_auto_buy.before_loop
+async def before_us_morning():
+ await bot.wait_until_ready()
+
+
+# ─── 스케줄: 미국 자동매도 (15:50 ET) ───────────────────────
+@tasks.loop(time=datetime.time(hour=_us_sell_h, minute=_us_sell_m, tzinfo=NY_TZ))
+async def us_afternoon_auto_sell():
+ """매일 오후(미국 현지) 워치리스트 외 종목만 전량 매도.
+
+ 워치리스트(대형주/ETF)에 포함된 종목은 당일 강제매도하지 않고
+ 손절/익절 모니터링에만 맡긴다. 스윙 보유를 허용한다.
+ """
+ if not ENABLE_US_TRADING or not kis.enable_us_trading:
+ return
+ if not ALLOWED_CHANNEL_IDS or not kis.is_configured:
+ _log("INFO", "US_AUTO_SELL_SKIP", "채널 미설정 또는 KIS 미설정")
+ return
+ if not _is_market_day("US"):
+ _log("INFO", "US_AUTO_SELL_SKIP", "미국시장 휴장일")
+ return
+ if not _is_market_open_now("US"):
+ _log("INFO", "US_AUTO_SELL_SKIP", "미국 장시간 아님")
+ return
+ if is_action_done("us_afternoon_sell"):
+ _log("INFO", "US_AUTO_SELL_SKIP", "오늘 us_afternoon_sell 이미 완료")
+ return
+
+ channel_id = next(iter(ALLOWED_CHANNEL_IDS))
+ channel = bot.get_channel(channel_id)
+ if not isinstance(channel, discord.TextChannel):
+ _log("WARN", "US_AUTO_SELL_SKIP", f"채널 접근 실패 channel_id={channel_id}")
+ return
+
+ mode_label = "🧪 모의투자" if kis.virtual else "💰 실전투자"
+ loop = asyncio.get_running_loop()
+ _log("INFO", "US_AUTO_SELL_START", f"time={US_AUTO_SELL_TIME}")
+
+ await channel.send(
+ f"🇺🇸🌇 **미국 오후 매도 점검** 시작 ({US_AUTO_SELL_TIME} ET)\n"
+ f"ℹ️ 워치리스트(대형주/ETF) 종목은 스윙 보유 → 손절/익절만 적용"
+ )
+
+ try:
+ balance_data = await loop.run_in_executor(None, kis.get_balance, "US")
+ holdings = balance_data.get("holdings", [])
+ except Exception as e:
+ _log("ERROR", "US_AUTO_SELL_BALANCE_ERROR", str(e)[:200])
+ await channel.send(f"❌ 미국 잔고 조회 실패: {str(e)[:300]}")
+ return
+
+ if not holdings:
+ await channel.send("📋 미국 보유 종목이 없습니다. 매도 생략.")
+ return
+
+ us_watchlist_set = set(kis.us_watchlist)
+ sell_holdings = [h for h in holdings if h["ticker"] not in us_watchlist_set]
+ keep_holdings = [h for h in holdings if h["ticker"] in us_watchlist_set]
+
+ if keep_holdings:
+ keep_names = ", ".join(f"{h['name']}({h['ticker']})" for h in keep_holdings)
+ await channel.send(
+ f"🏦 **미국 스윙 보유 유지** ({len(keep_holdings)}종목): {keep_names}\n"
+ f"→ 손절({STOP_LOSS_PCT}%)/익절({TAKE_PROFIT_PCT}%) 모니터링만 적용"
+ )
+ _log("INFO", "US_AUTO_SELL_KEEP_WATCHLIST", f"keep={len(keep_holdings)} tickers={keep_names}")
+
+ if not sell_holdings:
+ await channel.send("📋 **미국 워치리스트 외 매도 대상이 없습니다.** 전종목 스윙 보유.")
+ mark_action_done("us_afternoon_sell", details="전종목 워치리스트 보유")
+ return
+
+ sell_results = []
+ for h in sell_holdings:
+ try:
+ result = await loop.run_in_executor(
+ None, kis.sell_stock, h["ticker"], h["qty"], 0, "US"
+ )
+ sell_results.append({
+ "success": result["success"],
+ "ticker": h["ticker"],
+ "name": h["name"],
+ "qty": h["qty"],
+ "avg_price": h.get("avg_price", 0),
+ "sell_price": h.get("current_price", 0),
+ "order_no": result.get("order_no", ""),
+ "message": result.get("message", ""),
+ })
+ except Exception as e:
+ sell_results.append({
+ "success": False,
+ "ticker": h["ticker"],
+ "name": h["name"],
+ "qty": h["qty"],
+ "avg_price": h.get("avg_price", 0),
+ "sell_price": 0,
+ "order_no": "",
+ "message": str(e)[:200],
+ })
+
+ result_lines: list[str] = []
+ total_pnl = 0.0
+ total_invested = 0.0
+ total_recovered = 0.0
+
+ for sr in sell_results:
+ if sr["success"]:
+ record_trade(
+ sr["ticker"],
+ sr["name"],
+ "SELL",
+ sr["qty"],
+ sr["sell_price"],
+ order_no=sr.get("order_no", ""),
+ reason="미국 오후 자동매도",
+ market="US",
+ currency="USD",
+ )
+ if sr["avg_price"] > 0 and sr["sell_price"] > 0:
+ record_pnl(
+ sr["ticker"],
+ sr["name"],
+ sr["avg_price"],
+ sr["sell_price"],
+ sr["qty"],
+ market="US",
+ currency="USD",
+ )
+ pnl = (sr["sell_price"] - sr["avg_price"]) * sr["qty"]
+ pnl_rate = (
+ (sr["sell_price"] - sr["avg_price"]) / sr["avg_price"] * 100
+ if sr["avg_price"] > 0 else 0
+ )
+ invested = sr["avg_price"] * sr["qty"]
+ recovered = sr["sell_price"] * sr["qty"]
+ total_pnl += pnl
+ total_invested += invested
+ total_recovered += recovered
+ emoji = "🟢" if pnl >= 0 else "🔴"
+ result_lines.append(
+ f"{emoji} **{sr['name']}** (`{sr['ticker']}`) — "
+ f"{sr['qty']}주 | {_format_money(sr['avg_price'], 'USD')}→{_format_money(sr['sell_price'], 'USD')} | "
+ f"{_format_money(pnl, 'USD')} ({pnl_rate:+.1f}%)"
+ )
+ else:
+ result_lines.append(f"❌ **{sr['name']}** (`{sr['ticker']}`) 매도실패: {sr['message'][:80]}")
+
+ failed = [sr for sr in sell_results if not sr["success"]]
+ if failed:
+ await channel.send(f"⚠️ 미국 매도 실패 {len(failed)}건 — 60초 후 재시도…")
+ await asyncio.sleep(60)
+ for sr in failed:
+ try:
+ retry = await loop.run_in_executor(None, kis.sell_stock, sr["ticker"], sr["qty"], 0, "US")
+ if retry["success"]:
+ try:
+ sp = await loop.run_in_executor(None, kis.get_price, sr["ticker"], "US")
+ except Exception:
+ sp = 0
+ record_trade(
+ sr["ticker"],
+ sr["name"],
+ "SELL",
+ sr["qty"],
+ sp,
+ order_no=retry.get("order_no", ""),
+ reason="미국 재시도매도",
+ market="US",
+ currency="USD",
+ )
+ if sr["avg_price"] > 0 and sp > 0:
+ record_pnl(
+ sr["ticker"],
+ sr["name"],
+ sr["avg_price"],
+ sp,
+ sr["qty"],
+ market="US",
+ currency="USD",
+ )
+ pnl = (sp - sr["avg_price"]) * sr["qty"]
+ result_lines.append(f"✅ [재시도 성공] {sr['name']} — {_format_money(pnl, 'USD')}")
+ total_pnl += pnl
+ else:
+ result_lines.append(f"❌ [재시도 실패] {sr['name']}: {retry['message'][:80]}")
+ except Exception as e:
+ result_lines.append(f"❌ [재시도 오류] {sr['name']}: {str(e)[:80]}")
+
+ cumulative = get_total_pnl(currency="USD")
+ pnl_emoji = "🟢" if total_pnl >= 0 else "🔴"
+ sell_embed = discord.Embed(
+ title="🇺🇸🌇 오후 매도 결과 (워치리스트 외 종목)",
+ description="\n".join(result_lines) if result_lines else "매도 대상 없음",
+ color=0x00FF00 if total_pnl >= 0 else 0xFF0000,
+ timestamp=datetime.datetime.now(NY_TZ),
+ )
+ sell_embed.add_field(name=f"{pnl_emoji} 오늘 손익", value=_format_money(total_pnl, "USD"), inline=True)
+ sell_embed.add_field(name="투입금액", value=_format_money(total_invested, "USD"), inline=True)
+ sell_embed.add_field(name="회수금액", value=_format_money(total_recovered, "USD"), inline=True)
+ sell_embed.add_field(
+ name="📊 USD 누적 손익",
+ value=f"{_format_money(cumulative['total_pnl'], 'USD')} | 승률 {cumulative['win_rate']}%",
+ inline=False,
+ )
+ sell_embed.set_footer(text=f"미국 대형주+ETF 전략 | {mode_label}")
+ await channel.send(embed=sell_embed)
+
+ mark_action_done("us_afternoon_sell", details=f"매도={len(sell_results)} 보유유지={len(keep_holdings)}")
+ _log("INFO", "US_AUTO_SELL_DONE", f"sold={len(sell_results)} kept={len(keep_holdings)} total_pnl={total_pnl}")
+
+
+@us_afternoon_auto_sell.before_loop
+async def before_us_afternoon():
+ await bot.wait_until_ready()
+
+
+# ─── 스케줄: 보유종목 손절/익절 모니터링 ─────────────────
+@tasks.loop(minutes=MONITOR_INTERVAL_MIN)
+async def monitor_holdings():
+ """보유종목 수익률 감시 → 손절/익절 라인 도달 시 자동 매도."""
+ if not ALLOWED_CHANNEL_IDS or not kis.is_configured:
+ return
+ if not _is_market_day("KR") and not _is_market_day("US"):
+ return
+
+ channel_id = next(iter(ALLOWED_CHANNEL_IDS))
+ channel = bot.get_channel(channel_id)
+ if not isinstance(channel, discord.TextChannel):
+ return
+
+ try:
+ loop = asyncio.get_running_loop()
+ balance_data = await loop.run_in_executor(None, kis.get_balance)
+ holdings = balance_data["holdings"]
+ except Exception:
+ return
+
+ if holdings:
+ _log("INFO", "MONITOR_SCAN", f"holdings={len(holdings)}")
+
+ mode_label = "🧪 모의투자" if kis.virtual else "💰 실전투자"
+
+ for h in holdings:
+ rate = h["pnl_rate"]
+ market = h.get("market", _market_of_ticker(h["ticker"]))
+ if not _is_market_open_now(market):
+ continue
+ triggered = False
+ title = ""
+ desc_extra = ""
+
+ if rate <= STOP_LOSS_PCT:
+ triggered = True
+ title = f"🚨 손절 자동매도: {h['name']} ({market})"
+ desc_extra = f"⚠️ 손절 라인({STOP_LOSS_PCT}%) 도달 → 자동 시장가 매도"
+ elif rate >= TAKE_PROFIT_PCT:
+ triggered = True
+ title = f"🎉 익절 자동매도: {h['name']} ({market})"
+ desc_extra = f"✅ 익절 라인({TAKE_PROFIT_PCT}%) 도달 → 자동 시장가 매도"
+
+ if not triggered:
+ continue
+
+ # 재시작 중복 방지: 이 종목 오늘 이미 손절/익절 했으면 스킵
+ sl_action = f"stop_loss_{market}_{h['ticker']}"
+ if is_action_done(sl_action):
+ _log("INFO", "MONITOR_SKIP_DONE", f"ticker={h['ticker']} already_triggered_today")
+ continue
+
+ # 자동 매도 실행
+ try:
+ result = await loop.run_in_executor(
+ None, kis.sell_stock, h["ticker"], h["qty"], 0, market
+ )
+ if result["success"]:
+ mark_action_done(sl_action, details=f"{rate:+.1f}%")
+ try:
+ sell_price = await loop.run_in_executor(None, kis.get_price, h["ticker"], market)
+ except Exception:
+ sell_price = h["current_price"]
+ record_trade(
+ h["ticker"], h["name"], "SELL", h["qty"], sell_price,
+ order_no=result.get("order_no", ""),
+ reason=f"손절/익절 자동매도 ({rate:+.1f}%)",
+ market=market,
+ currency=h.get("currency", _currency_of_market(market)),
+ )
+ if h["avg_price"] > 0 and sell_price > 0:
+ record_pnl(
+ h["ticker"],
+ h["name"],
+ h["avg_price"],
+ sell_price,
+ h["qty"],
+ market=market,
+ currency=h.get("currency", _currency_of_market(market)),
+ )
+ currency = h.get("currency", _currency_of_market(market))
+ embed = discord.Embed(
+ title=title,
+ description=(
+ f"**시장:** {market}\n"
+ f"**종목:** {h['name']} (`{h['ticker']}`)\n"
+ f"**매도:** {h['qty']}주 × {_format_money(sell_price, currency)}\n"
+ f"**손익:** {_format_money(h['pnl'], currency)} ({rate:+.2f}%)\n\n"
+ f"{desc_extra}"
+ ),
+ color=0xFF0000 if rate < 0 else 0x00FF00,
+ )
+ embed.set_footer(text=f"{mode_label} | {currency}")
+ await channel.send(embed=embed)
+ _log(
+ "INFO",
+ "MONITOR_SELL_DONE",
+ f"market={market} ticker={h['ticker']} qty={h['qty']} rate={rate:+.2f}%",
+ )
+ else:
+ _log("WARN", "MONITOR_SELL_FAIL", f"ticker={h['ticker']} message={result['message'][:120]}")
+ await channel.send(
+ f"❌ {h['name']} 자동매도 실패: {result['message'][:200]}"
+ )
+ except Exception as e:
+ _log("ERROR", "MONITOR_SELL_ERROR", f"ticker={h['ticker']} error={str(e)[:160]}")
+ await channel.send(
+ f"❌ {h['name']} 자동매도 오류: {str(e)[:200]}"
+ )
+
+
+@monitor_holdings.before_loop
+async def before_monitor():
+ await bot.wait_until_ready()
+
+
+# ─── Bot Events ────────────────────────────────────────────────
+@bot.event
+async def on_ready():
+ synced = await tree.sync()
+ if not morning_auto_buy.is_running():
+ morning_auto_buy.start()
+ if not afternoon_auto_sell.is_running():
+ afternoon_auto_sell.start()
+ if ENABLE_US_TRADING and not us_morning_auto_buy.is_running():
+ us_morning_auto_buy.start()
+ if ENABLE_US_TRADING and not us_afternoon_auto_sell.is_running():
+ us_afternoon_auto_sell.start()
+ if not monitor_holdings.is_running():
+ monitor_holdings.start()
+ print(f"✅ {bot.user} 로그인 완료! (v{BOT_VERSION})")
+ print(f" 서버 수: {len(bot.guilds)}")
+ print(f" 동기화된 슬래시 명령 수: {len(synced)}")
+ print(" 슬래시 명령: /분석, /대형주, /잔고, /매수, /매도, /상태, /봇정보, /스코어링, /스코어규칙, /수익, /수익초기화")
+ print(f" KIS: {'✅ 설정됨' if kis.is_configured else '❌ 미설정'}")
+ print(f" 모드: {'🧪 모의투자' if kis.virtual else '💰 실전투자'}")
+ print(f" KR 자동매매: 매수 {AUTO_BUY_TIME} / 매도 {AUTO_SELL_TIME} KST")
+ print(f" KR 매수 종목 수: {DAY_TRADE_PICKS}개 | 예산 비율: {AUTO_BUY_BUDGET_RATIO * 100:.1f}%")
+ if ENABLE_US_TRADING:
+ print(f" US 자동매매: 매수 {US_AUTO_BUY_TIME} / 매도 {US_AUTO_SELL_TIME} ET")
+ print(f" US 매수 종목 수: {US_DAY_TRADE_PICKS}개 | 예산 비율: {US_AUTO_BUY_BUDGET_RATIO * 100:.1f}%")
+ else:
+ print(" US 자동매매: 비활성화 (ENABLE_US_TRADING=false)")
+ print(f" 손절: {STOP_LOSS_PCT}% | 익절: {TAKE_PROFIT_PCT}%")
+ print(f" 모니터링: {MONITOR_INTERVAL_MIN}분 간격")
+ if ALLOWED_CHANNEL_IDS:
+ print(f" 허용 채널: {ALLOWED_CHANNEL_IDS}")
+ else:
+ print(" 허용 채널: 전체 (제한 없음)")
+ print(" ⚠️ 자동매매: DISCORD_CHANNEL_IDS 설정 필요")
+
+ # 디스코드 채널에 버전 알림 전송
+ if ALLOWED_CHANNEL_IDS:
+ mode_label = "🧪 모의투자" if kis.virtual else "💰 실전투자"
+ embed = discord.Embed(
+ title=f"🚀 봇 시작됨 — v{BOT_VERSION}",
+ color=0x2ECC71,
+ timestamp=datetime.datetime.now(KST),
+ )
+ embed.add_field(name="모드", value=mode_label, inline=True)
+ embed.add_field(name="KR 매수", value=f"{AUTO_BUY_TIME} KST", inline=True)
+ embed.add_field(name="KR 매도", value=f"{AUTO_SELL_TIME} KST", inline=True)
+ if kis.kr_watchlist:
+ wl_preview = ", ".join(kis.kr_watchlist[:6])
+ if len(kis.kr_watchlist) > 6:
+ wl_preview += f" 외 {len(kis.kr_watchlist) - 6}개"
+ embed.add_field(name="KR 워치리스트", value=wl_preview, inline=False)
+ embed.set_footer(text=f"손절 {STOP_LOSS_PCT}% | 익절 {TAKE_PROFIT_PCT}% | 감시 {MONITOR_INTERVAL_MIN}분")
+ for ch_id in ALLOWED_CHANNEL_IDS:
+ ch = bot.get_channel(ch_id)
+ if isinstance(ch, discord.TextChannel):
+ try:
+ await ch.send(embed=embed)
+ except Exception:
+ pass
+
+
+# ─── Entry Point ───────────────────────────────────────────────
+if __name__ == "__main__":
+ bot.run(DISCORD_TOKEN)
diff --git a/docker-compose.yml b/docker-compose.yml
new file mode 100644
index 00000000..588c9ab6
--- /dev/null
+++ b/docker-compose.yml
@@ -0,0 +1,16 @@
+services:
+ trading-bot:
+ image: ${IMAGE_NAME:-ghcr.io/jjyn0215/tradingagents:latest}
+ pull_policy: always
+ container_name: tradingagents-bot
+ restart: unless-stopped
+ env_file:
+ - .env
+ environment:
+ - TZ=Asia/Seoul
+ volumes:
+ - ./data:/app/data
+ - ./results:/app/results
+ - ./reports:/app/reports
+ - ./eval_results:/app/eval_results
+ command: ["python", "-u", "bot.py"]
diff --git a/kis_client.py b/kis_client.py
new file mode 100644
index 00000000..830a0b27
--- /dev/null
+++ b/kis_client.py
@@ -0,0 +1,1271 @@
+"""
+한국투자증권 REST API 클라이언트
+
+- KISClient: 한국투자증권 Open API (국내/미국 시세, 잔고, 매수/매도, 순위)
+- format_krw(), format_usd(): 표시 유틸리티
+"""
+
+import os
+import time
+import logging
+import datetime
+from typing import Any, Literal
+from zoneinfo import ZoneInfo
+
+import requests
+
+logger = logging.getLogger(__name__)
+
+
+def _to_float(value: Any) -> float:
+ if value is None:
+ return 0.0
+ if isinstance(value, (int, float)):
+ return float(value)
+ s = str(value).strip().replace(",", "")
+ if not s:
+ return 0.0
+ try:
+ return float(s)
+ except Exception:
+ return 0.0
+
+
+def _to_int(value: Any) -> int:
+ return int(_to_float(value))
+
+
+class KISClient:
+ """한국투자증권 Open API 래퍼 (REST 직접 호출)."""
+
+ REAL_URL = "https://openapi.koreainvestment.com:9443"
+ VIRTUAL_URL = "https://openapivts.koreainvestment.com:29443"
+
+ KST = ZoneInfo("Asia/Seoul")
+ NY_TZ = ZoneInfo("America/New_York")
+
+ _holiday_cache: dict[str, bool] = {} # KR market holiday cache
+
+ def __init__(self):
+ self.app_key = os.getenv("KIS_APP_KEY", "")
+ self.app_secret = os.getenv("KIS_APP_SECRET", "")
+ self.account_no = os.getenv("KIS_ACCOUNT_NO", "")
+ self.virtual = os.getenv("KIS_VIRTUAL", "true").lower() == "true"
+
+ # Manual budget caps
+ self.max_order_amount = int(os.getenv("KIS_MAX_ORDER_AMOUNT", "1000000"))
+ self.enable_us_trading = os.getenv("ENABLE_US_TRADING", "false").lower() == "true"
+ self.us_max_order_amount = float(os.getenv("US_MAX_ORDER_AMOUNT", "5000"))
+
+ # US exchange search order and cache
+ ex_order_raw = os.getenv("US_EXCHANGE_SEARCH_ORDER", "NASD,NYSE,AMEX")
+ self.us_exchange_search_order = [x.strip().upper() for x in ex_order_raw.split(",") if x.strip()]
+ if not self.us_exchange_search_order:
+ self.us_exchange_search_order = ["NASD", "NYSE", "AMEX"]
+ self._us_exchange_cache: dict[str, str] = {}
+
+ # KR scanning watchlist: .env의 KR_WATCHLIST에 콤마 구분으로 종목코드 설정
+ kr_watchlist_raw = os.getenv("KR_WATCHLIST", "")
+ self.kr_watchlist = [x.strip() for x in kr_watchlist_raw.split(",") if x.strip()]
+
+ # US scanning watchlist (fallback source)
+ watchlist_raw = os.getenv(
+ "US_WATCHLIST",
+ "AAPL,MSFT,NVDA,AMZN,GOOGL,META,TSLA,AMD,AVGO,QQQ,SPY",
+ )
+ self.us_watchlist = [x.strip().upper() for x in watchlist_raw.split(",") if x.strip()]
+
+ self.base_url = self.VIRTUAL_URL if self.virtual else self.REAL_URL
+ self._token: str | None = None
+ self._token_expires: datetime.datetime | None = None
+
+ # 계좌번호 파싱 (12345678-01 → cano=12345678, acnt_prdt_cd=01)
+ parts = self.account_no.split("-") if self.account_no else []
+ self.cano = parts[0] if parts else ""
+ self.acnt_prdt_cd = parts[1] if len(parts) > 1 else "01"
+
+ @property
+ def is_configured(self) -> bool:
+ """KIS API 인증 정보가 설정되었는지 확인."""
+ return bool(self.app_key and self.app_secret and self.account_no)
+
+ # ── 공통 유틸 ─────────────────────────────────────────────
+
+ def detect_market(self, ticker: str) -> Literal["KR", "US"]:
+ """티커 형태로 국내/미국 시장 자동 판별."""
+ t = (ticker or "").upper().strip()
+ if t.endswith((".KS", ".KQ")):
+ return "KR"
+ if t.isdigit() and len(t) == 6:
+ return "KR"
+ return "US"
+
+ def normalize_ticker(self, ticker: str, market: str | None = None) -> str:
+ """시장별 티커 정규화."""
+ m = (market or self.detect_market(ticker)).upper()
+ t = (ticker or "").upper().strip()
+ if m == "KR":
+ if t.endswith((".KS", ".KQ")):
+ return t.split(".", 1)[0]
+ return t
+ # US
+ if t.endswith((".KS", ".KQ")) and len(t) > 3:
+ return t.split(".", 1)[0]
+ return t
+
+ def _ranking_exchange_code(self, exchange: str) -> str:
+ """해외 순위 API용 거래소 코드로 변환."""
+ mapping = {
+ "NASD": "NAS",
+ "NASDAQ": "NAS",
+ "NAS": "NAS",
+ "NYSE": "NYS",
+ "NYS": "NYS",
+ "AMEX": "AMS",
+ "AMS": "AMS",
+ }
+ return mapping.get((exchange or "").upper(), (exchange or "").upper())
+
+ def _ensure_token(self):
+ """Access token이 없거나 만료됐으면 재발급."""
+ if (
+ self._token
+ and self._token_expires
+ and datetime.datetime.now() < self._token_expires
+ ):
+ return
+ self._issue_token()
+
+ def _issue_token(self):
+ """OAuth2 access token 발급."""
+ resp = requests.post(
+ f"{self.base_url}/oauth2/tokenP",
+ json={
+ "grant_type": "client_credentials",
+ "appkey": self.app_key,
+ "appsecret": self.app_secret,
+ },
+ timeout=10,
+ )
+ resp.raise_for_status()
+ data = resp.json()
+ self._token = data["access_token"]
+ self._token_expires = datetime.datetime.strptime(
+ data["access_token_token_expired"], "%Y-%m-%d %H:%M:%S"
+ )
+
+ def _headers(self, tr_id: str) -> dict[str, str]:
+ """공통 API 헤더 생성."""
+ self._ensure_token()
+ return {
+ "content-type": "application/json; charset=utf-8",
+ "authorization": f"Bearer {self._token}",
+ "appkey": self.app_key,
+ "appsecret": self.app_secret,
+ "tr_id": tr_id,
+ }
+
+ def _request(
+ self,
+ method: str,
+ path: str,
+ tr_id: str,
+ *,
+ params: dict | None = None,
+ json_data: dict | None = None,
+ timeout: int = 10,
+ ) -> dict:
+ url = f"{self.base_url}{path}"
+ if method.upper() == "GET":
+ resp = requests.get(url, headers=self._headers(tr_id), params=params, timeout=timeout)
+ else:
+ resp = requests.post(url, headers=self._headers(tr_id), json=json_data, timeout=timeout)
+ if resp.status_code >= 400:
+ # KIS는 5xx에서도 JSON 본문(msg_cd/msg1)을 내려주는 경우가 있어 에러 메시지에 포함한다.
+ detail = ""
+ try:
+ body = resp.json()
+ if isinstance(body, dict):
+ rt_cd = body.get("rt_cd", "")
+ msg_cd = body.get("msg_cd", "")
+ msg1 = body.get("msg1", "") or body.get("message", "")
+ detail = f"rt_cd={rt_cd} msg_cd={msg_cd} msg1={msg1}"
+ else:
+ detail = str(body)[:240]
+ except Exception:
+ detail = (resp.text or "").strip().replace("\n", " ")[:240]
+ raise requests.HTTPError(
+ f"HTTP {resp.status_code} {method.upper()} {url} {detail}".strip(),
+ response=resp,
+ )
+ return resp.json()
+
+ def _request_with_retry(
+ self,
+ method: str,
+ path: str,
+ tr_id: str,
+ *,
+ params: dict | None = None,
+ json_data: dict | None = None,
+ timeout: int = 10,
+ retries: int = 1,
+ base_delay_sec: float = 0.4,
+ ) -> dict:
+ """주문/중요 API용 재시도 래퍼 (5xx/429/네트워크 오류 대상)."""
+ last_error: Exception | None = None
+ for attempt in range(retries + 1):
+ try:
+ return self._request(
+ method,
+ path,
+ tr_id,
+ params=params,
+ json_data=json_data,
+ timeout=timeout,
+ )
+ except requests.HTTPError as e:
+ status = e.response.status_code if e.response is not None else None
+ last_error = e
+ if status is not None and status < 500 and status != 429:
+ raise
+ except Exception as e:
+ last_error = e
+
+ if attempt < retries:
+ time.sleep(base_delay_sec * (attempt + 1))
+
+ if last_error:
+ raise last_error
+ raise RuntimeError(f"request failed: {path}")
+
+ def _ranking_request(
+ self,
+ path: str,
+ tr_id: str,
+ params: dict | None = None,
+ *,
+ retries: int = 2,
+ base_delay_sec: float = 0.35,
+ ) -> dict:
+ """순위성 GET API 전용 재시도 래퍼.
+
+ - 5xx/429/네트워크 오류는 재시도
+ - 그 외 4xx는 즉시 실패
+ """
+ last_error: Exception | None = None
+ for attempt in range(retries + 1):
+ try:
+ return self._request("GET", path, tr_id, params=params)
+ except requests.HTTPError as e:
+ status = e.response.status_code if e.response is not None else None
+ last_error = e
+ if status is not None and status < 500 and status != 429:
+ raise
+ except Exception as e:
+ last_error = e
+
+ if attempt < retries:
+ time.sleep(base_delay_sec * (attempt + 1))
+
+ if last_error:
+ raise last_error
+ raise RuntimeError(f"ranking request failed: {path}")
+
+ # ── 시장 상태 ─────────────────────────────────────────────
+
+ def is_market_open(self, dt: datetime.date | None = None, market: str = "KR") -> bool:
+ """시장 개장일 여부 판별 (KR: KIS holiday API / US: weekday 기준)."""
+ market = market.upper()
+ if dt is None:
+ if market == "US":
+ dt = datetime.datetime.now(self.NY_TZ).date()
+ else:
+ dt = datetime.datetime.now(self.KST).date()
+
+ if market == "US":
+ return dt.weekday() < 5
+
+ # KR
+ key = dt.strftime("%Y%m%d")
+ # KIS 문서상 모의투자는 chk-holiday API 미지원.
+ # 모의환경에서는 불필요한 500 오류를 피하기 위해 주말만 휴장으로 간주한다.
+ if self.virtual:
+ is_open = dt.weekday() < 5
+ self._holiday_cache[key] = is_open
+ return is_open
+
+ if dt.weekday() >= 5:
+ return False
+ if key in self._holiday_cache:
+ return self._holiday_cache[key]
+
+ try:
+ data = self._request(
+ "GET",
+ "/uapi/domestic-stock/v1/quotations/chk-holiday",
+ "CTCA0903R",
+ params={"BASS_DT": key, "CTX_AREA_FK": "", "CTX_AREA_NK": ""},
+ )
+ for item in data.get("output", []):
+ if item.get("bass_dt") == key:
+ is_open = item.get("opnd_yn", "N") == "Y"
+ self._holiday_cache[key] = is_open
+ return is_open
+ self._holiday_cache[key] = True
+ return True
+ except Exception as e:
+ logger.warning("KIS 휴장일 조회 실패 (KR 개장으로 간주): %s", e)
+ return True
+
+ def is_market_open_now(self, market: str = "KR") -> bool:
+ """시장 정규장 시간 여부 판별."""
+ market = market.upper()
+ if market == "US":
+ now = datetime.datetime.now(self.NY_TZ)
+ if not self.is_market_open(now.date(), market="US"):
+ return False
+ return datetime.time(9, 30) <= now.time() <= datetime.time(16, 0)
+
+ now = datetime.datetime.now(self.KST)
+ if not self.is_market_open(now.date(), market="KR"):
+ return False
+ return datetime.time(9, 0) <= now.time() <= datetime.time(15, 30)
+
+ # ── 잔고 조회 ─────────────────────────────────────────────
+
+ def get_balance(self, market: str = "ALL") -> dict:
+ """주식 잔고 + 계좌 요약 조회.
+
+ Returns:
+ {
+ "holdings": [{...}],
+ "summary": {
+ "total_eval": ,
+ "total_pnl": ,
+ "cash": ,
+ "KRW": {...},
+ "USD": {...}
+ },
+ "by_market": {"KR": {...}, "US": {...}}
+ }
+ """
+ market = market.upper()
+ holdings: list[dict] = []
+
+ kr_summary = {"total_eval": 0.0, "total_pnl": 0.0, "cash": 0.0, "currency": "KRW"}
+ us_summary = {"total_eval": 0.0, "total_pnl": 0.0, "cash": 0.0, "currency": "USD"}
+
+ if market in ("KR", "ALL"):
+ kr_data = self._get_kr_balance()
+ holdings.extend(kr_data["holdings"])
+ kr_summary = kr_data["summary"]
+
+ if market in ("US", "ALL") and self.enable_us_trading:
+ us_data = self._get_us_balance()
+ holdings.extend(us_data["holdings"])
+ us_summary = us_data["summary"]
+
+ return {
+ "holdings": holdings,
+ "summary": {
+ "total_eval": kr_summary["total_eval"],
+ "total_pnl": kr_summary["total_pnl"],
+ "cash": kr_summary["cash"],
+ "KRW": kr_summary,
+ "USD": us_summary,
+ },
+ "by_market": {
+ "KR": kr_summary,
+ "US": us_summary,
+ },
+ }
+
+ def _get_kr_balance(self) -> dict:
+ """국내 주식 잔고 조회."""
+ tr_id = "VTTC8434R" if self.virtual else "TTTC8434R"
+ data = self._request(
+ "GET",
+ "/uapi/domestic-stock/v1/trading/inquire-balance",
+ tr_id,
+ params={
+ "CANO": self.cano,
+ "ACNT_PRDT_CD": self.acnt_prdt_cd,
+ "AFHR_FLPR_YN": "N",
+ "OFL_YN": "",
+ "INQR_DVSN": "01",
+ "UNPR_DVSN": "01",
+ "FUND_STTL_ICLD_YN": "N",
+ "FNCG_AMT_AUTO_RDPT_YN": "N",
+ "PRCS_DVSN": "00",
+ "CTX_AREA_FK100": "",
+ "CTX_AREA_NK100": "",
+ },
+ )
+
+ holdings: list[dict] = []
+ for item in data.get("output1", []):
+ qty = _to_int(item.get("hldg_qty", 0))
+ if qty <= 0:
+ continue
+ holdings.append(
+ {
+ "market": "KR",
+ "currency": "KRW",
+ "exchange": "KRX",
+ "ticker": item.get("pdno", ""),
+ "name": item.get("prdt_name", ""),
+ "qty": qty,
+ "avg_price": _to_float(item.get("pchs_avg_pric", 0)),
+ "current_price": _to_float(item.get("prpr", 0)),
+ "pnl": _to_float(item.get("evlu_pfls_amt", 0)),
+ "pnl_rate": _to_float(item.get("evlu_pfls_rt", 0)),
+ }
+ )
+
+ summary = {"total_eval": 0.0, "total_pnl": 0.0, "cash": 0.0, "currency": "KRW"}
+ output2 = data.get("output2", [])
+ if output2:
+ s = output2[0] if isinstance(output2, list) else output2
+ summary = {
+ "total_eval": _to_float(s.get("tot_evlu_amt", 0)),
+ "total_pnl": _to_float(s.get("evlu_pfls_smtl_amt", 0)),
+ "cash": _to_float(s.get("dnca_tot_amt", 0)),
+ "currency": "KRW",
+ }
+
+ return {"holdings": holdings, "summary": summary}
+
+ def _get_us_balance(self) -> dict:
+ """미국 주식 잔고 조회 (거래소별 합산)."""
+ if not self.enable_us_trading:
+ return {
+ "holdings": [],
+ "summary": {"total_eval": 0.0, "total_pnl": 0.0, "cash": 0.0, "currency": "USD"},
+ }
+
+ tr_id = "VTTS3012R" if self.virtual else "TTTS3012R"
+ path = "/uapi/overseas-stock/v1/trading/inquire-balance"
+
+ holdings_map: dict[tuple[str, str], dict] = {}
+ total_eval = 0.0
+ total_pnl = 0.0
+ total_cash = 0.0
+
+ for exchange in self.us_exchange_search_order:
+ try:
+ data = self._request(
+ "GET",
+ path,
+ tr_id,
+ params={
+ "CANO": self.cano,
+ "ACNT_PRDT_CD": self.acnt_prdt_cd,
+ "OVRS_EXCG_CD": exchange,
+ "TR_CRCY_CD": "USD",
+ "CTX_AREA_FK200": "",
+ "CTX_AREA_NK200": "",
+ },
+ )
+ except Exception as e:
+ logger.warning("US 잔고 조회 실패 exchange=%s: %s", exchange, str(e)[:120])
+ continue
+
+ for item in data.get("output1", []):
+ ticker = (item.get("ovrs_pdno") or item.get("pdno") or item.get("symb") or "").strip().upper()
+ if not ticker:
+ continue
+
+ qty = _to_int(
+ item.get("ovrs_cblc_qty")
+ or item.get("cblc_qty13")
+ or item.get("hldg_qty")
+ or 0
+ )
+ if qty <= 0:
+ continue
+
+ avg_price = _to_float(
+ item.get("pchs_avg_pric")
+ or item.get("avg_unpr")
+ or item.get("frcr_pchs_amt1")
+ or 0
+ )
+ current_price = _to_float(item.get("now_pric2") or item.get("ovrs_now_pric1") or item.get("last") or 0)
+ if avg_price > 0 and _to_float(item.get("frcr_pchs_amt1", 0)) > 0 and qty > 0:
+ avg_price = _to_float(item.get("frcr_pchs_amt1", 0)) / qty
+ if current_price <= 0:
+ current_price = avg_price
+
+ pnl = _to_float(item.get("evlu_pfls_amt") or item.get("frcr_evlu_pfls_amt") or 0)
+ pnl_rate = _to_float(item.get("evlu_pfls_rt") or item.get("evlu_pfls_rt1") or 0)
+ name = (item.get("ovrs_item_name") or item.get("prdt_name") or ticker).strip()
+
+ key = (exchange, ticker)
+ holdings_map[key] = {
+ "market": "US",
+ "currency": "USD",
+ "exchange": exchange,
+ "ticker": ticker,
+ "name": name,
+ "qty": qty,
+ "avg_price": avg_price,
+ "current_price": current_price,
+ "pnl": pnl,
+ "pnl_rate": pnl_rate,
+ }
+
+ out2 = data.get("output2", [])
+ if out2:
+ s = out2[0] if isinstance(out2, list) else out2
+ total_eval += _to_float(s.get("frcr_evlu_tota") or s.get("ovrs_tot_evlu_amt") or 0)
+ total_pnl += _to_float(s.get("frcr_evlu_pfls_amt") or s.get("evlu_pfls_smtl_amt") or 0)
+ total_cash += _to_float(s.get("frcr_dncl_amt_2") or s.get("frcr_buy_mgn_amt") or s.get("cash") or 0)
+
+ # summary 정보가 비어도 holdings 기반으로 계산
+ if total_eval == 0 and holdings_map:
+ total_eval = sum(h["qty"] * h["current_price"] for h in holdings_map.values())
+ if total_pnl == 0 and holdings_map:
+ total_pnl = sum(h["pnl"] for h in holdings_map.values())
+
+ return {
+ "holdings": list(holdings_map.values()),
+ "summary": {
+ "total_eval": total_eval,
+ "total_pnl": total_pnl,
+ "cash": total_cash,
+ "currency": "USD",
+ },
+ }
+
+ # ── 현재가 조회 ──────────────────────────────────────────
+
+ def get_price(self, ticker: str, market: str | None = None) -> float:
+ """종목 현재가 조회 (KR/US 자동 분기)."""
+ m = (market or self.detect_market(ticker)).upper()
+ t = self.normalize_ticker(ticker, m)
+ if m == "US":
+ return self._get_us_price(t)
+ return self._get_kr_price(t)
+
+ def _get_kr_price(self, ticker: str) -> float:
+ data = self._request(
+ "GET",
+ "/uapi/domestic-stock/v1/quotations/inquire-price",
+ "FHKST01010100",
+ params={
+ "FID_COND_MRKT_DIV_CODE": "J",
+ "FID_INPUT_ISCD": ticker,
+ },
+ )
+ return _to_float(data.get("output", {}).get("stck_prpr", 0))
+
+ def _get_us_price(self, ticker: str) -> float:
+ if not self.enable_us_trading:
+ raise RuntimeError("ENABLE_US_TRADING=false 상태입니다.")
+
+ cached_exchange = self._us_exchange_cache.get(ticker)
+ search_exchanges = ([cached_exchange] if cached_exchange else []) + [
+ ex for ex in self.us_exchange_search_order if ex != cached_exchange
+ ]
+
+ for exchange in search_exchanges:
+ if not exchange:
+ continue
+ px = self._get_us_price_by_exchange(ticker, exchange)
+ if px > 0:
+ self._us_exchange_cache[ticker] = exchange
+ return px
+
+ return 0.0
+
+ def _get_us_price_by_exchange(self, ticker: str, exchange: str) -> float:
+ path = "/uapi/overseas-price/v1/quotations/price"
+ tr_id = "HHDFS00000300"
+
+ try:
+ data = self._request(
+ "GET",
+ path,
+ tr_id,
+ params={
+ "AUTH": "",
+ "EXCD": exchange,
+ "SYMB": ticker,
+ # 일부 계정/문서 변형 파라미터 대비
+ "OVRS_EXCG_CD": exchange,
+ "PDNO": ticker,
+ },
+ )
+ except Exception:
+ return 0.0
+
+ out = data.get("output", {}) or {}
+ price = _to_float(
+ out.get("last")
+ or out.get("stck_prpr")
+ or out.get("ovrs_nmix_prpr")
+ or out.get("clos")
+ or 0
+ )
+ return price
+
+ # ── 주문 (매수/매도) ─────────────────────────────────────
+
+ def buy_stock(
+ self,
+ ticker: str,
+ qty: int,
+ price: float = 0,
+ market: str | None = None,
+ ) -> dict:
+ """주식 매수 (price=0 → 시장가)."""
+ m = (market or self.detect_market(ticker)).upper()
+ t = self.normalize_ticker(ticker, m)
+ if m == "US":
+ return self._order_us("BUY", t, qty, price)
+ return self._order_kr("BUY", t, qty, price)
+
+ def sell_stock(
+ self,
+ ticker: str,
+ qty: int,
+ price: float = 0,
+ market: str | None = None,
+ ) -> dict:
+ """주식 매도 (price=0 → 시장가)."""
+ m = (market or self.detect_market(ticker)).upper()
+ t = self.normalize_ticker(ticker, m)
+ if m == "US":
+ return self._order_us("SELL", t, qty, price)
+ return self._order_kr("SELL", t, qty, price)
+
+ def _order_kr(self, side: Literal["BUY", "SELL"], ticker: str, qty: int, price: float = 0) -> dict:
+ primary_tr_id = "VTTC0012U" if side == "BUY" and self.virtual else "TTTC0012U"
+ if side == "SELL":
+ primary_tr_id = "VTTC0011U" if self.virtual else "TTTC0011U"
+ fallback_tr_id = "VTTC0802U" if side == "BUY" and self.virtual else "TTTC0802U"
+ if side == "SELL":
+ fallback_tr_id = "VTTC0801U" if self.virtual else "TTTC0801U"
+
+ qty_int = int(qty)
+ excg_id_dvsn_cd = os.getenv("KIS_KR_EXCHANGE_ID", "KRX")
+ sll_type = "00" if side == "SELL" else ""
+
+ primary_payload = {
+ "CANO": self.cano,
+ "ACNT_PRDT_CD": self.acnt_prdt_cd,
+ "PDNO": ticker,
+ "EXCG_ID_DVSN_CD": excg_id_dvsn_cd,
+ "ORD_DVSN": "01", # 시장가
+ "ORD_QTY": str(qty_int),
+ "ORD_UNPR": str(int(price)) if price else "0",
+ "SLL_TYPE": sll_type,
+ "CNDT_PRIC": "",
+ }
+ # 일부 계정/문서 버전 호환용 최소 페이로드 fallback
+ fallback_payload = {
+ "CANO": self.cano,
+ "ACNT_PRDT_CD": self.acnt_prdt_cd,
+ "PDNO": ticker,
+ "ORD_DVSN": "01", # 시장가
+ "ORD_QTY": str(qty_int),
+ "ORD_UNPR": str(int(price)) if price else "0",
+ }
+
+ attempts = [("primary", primary_tr_id, primary_payload)]
+ if fallback_tr_id != primary_tr_id:
+ attempts.append(("fallback", fallback_tr_id, fallback_payload))
+
+ errors: list[str] = []
+ for mode, tr_id, payload in attempts:
+ try:
+ data = self._request_with_retry(
+ "POST",
+ "/uapi/domestic-stock/v1/trading/order-cash",
+ tr_id,
+ json_data=payload,
+ retries=1,
+ )
+ return {
+ "success": data.get("rt_cd") == "0",
+ "message": data.get("msg1", ""),
+ "order_no": data.get("output", {}).get("ODNO", ""),
+ "market": "KR",
+ "currency": "KRW",
+ "exchange": "KRX",
+ }
+ except Exception as e:
+ logger.warning(
+ "국내 주문 실패 (%s) ticker=%s tr_id=%s: %s",
+ mode,
+ ticker,
+ tr_id,
+ e,
+ )
+ errors.append(f"{mode}:{str(e)}")
+
+ return {
+ "success": False,
+ "message": " | ".join(errors)[:300] if errors else "국내 주문 실패",
+ "order_no": "",
+ "market": "KR",
+ "currency": "KRW",
+ "exchange": "KRX",
+ }
+
+ def _order_us(self, side: Literal["BUY", "SELL"], ticker: str, qty: int, price: float = 0) -> dict:
+ if not self.enable_us_trading:
+ return {
+ "success": False,
+ "message": "ENABLE_US_TRADING=false 상태입니다.",
+ "order_no": "",
+ "market": "US",
+ "currency": "USD",
+ "exchange": "",
+ }
+
+ exchange = self._us_exchange_cache.get(ticker)
+ if not exchange:
+ # 가격 조회로 거래소 자동 확정
+ _ = self._get_us_price(ticker)
+ exchange = self._us_exchange_cache.get(ticker)
+ if not exchange:
+ return {
+ "success": False,
+ "message": f"거래소를 찾을 수 없습니다: {ticker}",
+ "order_no": "",
+ "market": "US",
+ "currency": "USD",
+ "exchange": "",
+ }
+
+ path = "/uapi/overseas-stock/v1/trading/order"
+ if side == "BUY":
+ tr_id = "VTTT1002U" if self.virtual else "TTTT1002U"
+ else:
+ tr_id = "VTTT1006U" if self.virtual else "TTTT1006U"
+ qty_int = int(qty)
+ sll_type = "00" if side == "SELL" else ""
+
+ try:
+ data = self._request_with_retry(
+ "POST",
+ path,
+ tr_id,
+ json_data={
+ "CANO": self.cano,
+ "ACNT_PRDT_CD": self.acnt_prdt_cd,
+ "OVRS_EXCG_CD": exchange,
+ "PDNO": ticker,
+ "ORD_QTY": str(qty_int),
+ "OVRS_ORD_UNPR": str(price) if price else "0",
+ "ORD_SVR_DVSN_CD": "0",
+ "ORD_DVSN": "00",
+ "SLL_TYPE": sll_type,
+ "CTAC_TLNO": "",
+ "MGCO_APTM_ODNO": "",
+ },
+ retries=1,
+ )
+ return {
+ "success": data.get("rt_cd") == "0",
+ "message": data.get("msg1", ""),
+ "order_no": data.get("output", {}).get("ODNO", ""),
+ "market": "US",
+ "currency": "USD",
+ "exchange": exchange,
+ }
+ except Exception as e:
+ return {
+ "success": False,
+ "message": str(e)[:200],
+ "order_no": "",
+ "market": "US",
+ "currency": "USD",
+ "exchange": exchange,
+ }
+
+ # ── 국내 순위 분석 조회 (KR 전용) ─────────────────────────
+
+ def get_top_market_cap(self, count: int = 5) -> list[dict]:
+ """코스피 시가총액 상위 종목 조회."""
+ try:
+ data = self._ranking_request(
+ "/uapi/domestic-stock/v1/ranking/market-cap",
+ "FHPST01740000",
+ params={
+ "fid_cond_mrkt_div_code": "J",
+ "fid_cond_scr_div_code": "20174",
+ "fid_input_iscd": "0001",
+ "fid_div_cls_code": "1",
+ "fid_trgt_cls_code": "0",
+ "fid_trgt_exls_cls_code": "0",
+ "fid_input_price_1": "",
+ "fid_input_price_2": "",
+ "fid_vol_cnt": "",
+ },
+ )
+ items = data.get("output", [])
+ results = []
+ for item in items[:count]:
+ ticker = item.get("mksc_shrn_iscd", "")
+ if not ticker:
+ continue
+ results.append(
+ {
+ "market": "KR",
+ "currency": "KRW",
+ "exchange": "KRX",
+ "rank": _to_int(item.get("data_rank", len(results) + 1)),
+ "ticker": ticker,
+ "name": item.get("hts_kor_isnm", "").strip(),
+ "price": _to_float(item.get("stck_prpr", 0)),
+ "market_cap": _to_float(item.get("stck_avls", 0)) * 1_0000_0000,
+ "volume": _to_int(item.get("acml_vol", 0)),
+ }
+ )
+ return results
+ except Exception as e:
+ logger.error("KIS 시가총액 순위 조회 실패: %s", e)
+ return []
+
+ def get_volume_rank(self, count: int = 30) -> list[dict]:
+ """거래량 상위 종목 조회."""
+ time.sleep(0.2)
+ try:
+ data = self._ranking_request(
+ "/uapi/domestic-stock/v1/quotations/volume-rank",
+ "FHPST01710000",
+ params={
+ "FID_COND_MRKT_DIV_CODE": "J",
+ "FID_COND_SCR_DIV_CODE": "20171",
+ "FID_INPUT_ISCD": "0001",
+ "FID_DIV_CLS_CODE": "1",
+ "FID_BLNG_CLS_CODE": "0",
+ "FID_TRGT_CLS_CODE": "111111111",
+ "FID_TRGT_EXLS_CLS_CODE": "0000000000",
+ "FID_INPUT_PRICE_1": "",
+ "FID_INPUT_PRICE_2": "",
+ "FID_VOL_CNT": "",
+ "FID_INPUT_DATE_1": "",
+ },
+ )
+ items = data.get("output", [])
+ results = []
+ for item in items[:count]:
+ ticker = item.get("mksc_shrn_iscd", "")
+ if not ticker:
+ continue
+ results.append(
+ {
+ "market": "KR",
+ "currency": "KRW",
+ "exchange": "KRX",
+ "ticker": ticker,
+ "name": item.get("hts_kor_isnm", "").strip(),
+ "rank": _to_int(item.get("data_rank", 0)),
+ "price": _to_float(item.get("stck_prpr", 0)),
+ "prdy_ctrt": _to_float(item.get("prdy_ctrt", 0)),
+ "acml_vol": _to_int(item.get("acml_vol", 0)),
+ "vol_inrt": _to_float(item.get("vol_inrt", 0)),
+ }
+ )
+ return results
+ except Exception as e:
+ logger.error("거래량 순위 조회 실패: %s", e)
+ return []
+
+ def get_volume_power(self, count: int = 30) -> list[dict]:
+ """체결강도 상위 종목 조회."""
+ time.sleep(0.2)
+ try:
+ data = self._ranking_request(
+ "/uapi/domestic-stock/v1/ranking/volume-power",
+ "FHPST01680000",
+ params={
+ "fid_cond_mrkt_div_code": "J",
+ "fid_cond_scr_div_code": "20168",
+ "fid_input_iscd": "0001",
+ "fid_div_cls_code": "1",
+ "fid_trgt_cls_code": "0",
+ "fid_trgt_exls_cls_code": "0",
+ "fid_input_price_1": "",
+ "fid_input_price_2": "",
+ "fid_vol_cnt": "",
+ },
+ )
+ items = data.get("output", [])
+ results = []
+ for item in items[:count]:
+ ticker = item.get("stck_shrn_iscd", "")
+ if not ticker:
+ continue
+ results.append(
+ {
+ "market": "KR",
+ "currency": "KRW",
+ "exchange": "KRX",
+ "ticker": ticker,
+ "name": item.get("hts_kor_isnm", "").strip(),
+ "rank": _to_int(item.get("data_rank", 0)),
+ "price": _to_float(item.get("stck_prpr", 0)),
+ "prdy_ctrt": _to_float(item.get("prdy_ctrt", 0)),
+ "tday_rltv": _to_float(item.get("tday_rltv", 0)),
+ }
+ )
+ return results
+ except Exception as e:
+ logger.error("체결강도 순위 조회 실패: %s", e)
+ return []
+
+ def get_fluctuation_rank(self, count: int = 30) -> list[dict]:
+ """등락률 상위 종목 조회."""
+ time.sleep(0.2)
+ try:
+ data = self._ranking_request(
+ "/uapi/domestic-stock/v1/ranking/fluctuation",
+ "FHPST01700000",
+ params={
+ "fid_cond_mrkt_div_code": "J",
+ "fid_cond_scr_div_code": "20170",
+ "fid_input_iscd": "0000",
+ "fid_rank_sort_cls_code": "0",
+ "fid_input_cnt_1": str(count),
+ "fid_prc_cls_code": "0",
+ "fid_input_price_1": "",
+ "fid_input_price_2": "",
+ "fid_vol_cnt": "",
+ "fid_trgt_cls_code": "0",
+ "fid_trgt_exls_cls_code": "0",
+ "fid_div_cls_code": "0",
+ "fid_rsfl_rate1": "",
+ "fid_rsfl_rate2": "",
+ },
+ )
+ items = data.get("output", [])
+ results = []
+ for item in items[:count]:
+ ticker = item.get("stck_shrn_iscd", "")
+ if not ticker:
+ continue
+ results.append(
+ {
+ "market": "KR",
+ "currency": "KRW",
+ "exchange": "KRX",
+ "ticker": ticker,
+ "name": item.get("hts_kor_isnm", "").strip(),
+ "rank": _to_int(item.get("data_rank", 0)),
+ "price": _to_float(item.get("stck_prpr", 0)),
+ "prdy_ctrt": _to_float(item.get("prdy_ctrt", 0)),
+ "acml_vol": _to_int(item.get("acml_vol", 0)),
+ "cnnt_ascn_dynu": _to_int(item.get("cnnt_ascn_dynu", 0)),
+ }
+ )
+ return results
+ except Exception as e:
+ logger.error("등락률 순위 조회 실패: %s", e)
+ return []
+
+ def get_bulk_trans(self, count: int = 30) -> list[dict]:
+ """대량체결건수 매수 상위 종목 조회."""
+ time.sleep(0.2)
+ try:
+ data = self._ranking_request(
+ "/uapi/domestic-stock/v1/ranking/bulk-trans-num",
+ "FHKST190900C0",
+ params={
+ "fid_cond_mrkt_div_code": "J",
+ "fid_cond_scr_div_code": "11909",
+ "fid_input_iscd": "0001",
+ "fid_rank_sort_cls_code": "0",
+ "fid_div_cls_code": "0",
+ "fid_input_price_1": "",
+ "fid_aply_rang_prc_1": "",
+ "fid_aply_rang_prc_2": "",
+ "fid_input_iscd_2": "",
+ "fid_trgt_exls_cls_code": "0",
+ "fid_trgt_cls_code": "0",
+ "fid_vol_cnt": "",
+ },
+ )
+ items = data.get("output", [])
+ results = []
+ for item in items[:count]:
+ ticker = item.get("mksc_shrn_iscd", "")
+ if not ticker:
+ continue
+ results.append(
+ {
+ "market": "KR",
+ "currency": "KRW",
+ "exchange": "KRX",
+ "ticker": ticker,
+ "name": item.get("hts_kor_isnm", "").strip(),
+ "rank": _to_int(item.get("data_rank", 0)),
+ "price": _to_float(item.get("stck_prpr", 0)),
+ "prdy_ctrt": _to_float(item.get("prdy_ctrt", 0)),
+ "buy_cnt": _to_int(item.get("shnu_cntg_csnu", 0)),
+ "ntby_cnqn": _to_int(item.get("ntby_cnqn", 0)),
+ }
+ )
+ return results
+ except Exception as e:
+ logger.error("대량체결 순위 조회 실패: %s", e)
+ return []
+
+ # ── 미국 후보 조회 (KIS 우선, 실패 시 빈 리스트 반환) ─────
+
+ def get_us_market_cap_rank(self, count: int = 30) -> list[dict]:
+ """미국 시가총액 상위 후보 조회.
+
+ 한국투자 공식 문서 기준 이 API는 모의투자를 지원하지 않으므로
+ 실전 환경에서만 조회하고, 그 외에는 빈 리스트를 반환한다.
+ """
+ if not self.enable_us_trading or self.virtual:
+ return []
+
+ path = "/uapi/overseas-stock/v1/ranking/market-cap"
+ tr_id = "HHDFS76350100"
+ vol_rang = "0"
+
+ results: list[dict] = []
+ for exchange in self.us_exchange_search_order:
+ ranking_exchange = self._ranking_exchange_code(exchange)
+ try:
+ data = self._request(
+ "GET",
+ path,
+ tr_id,
+ params={
+ "KEYB": "",
+ "AUTH": "",
+ "EXCD": ranking_exchange,
+ "VOL_RANG": vol_rang,
+ },
+ )
+ except Exception as e:
+ logger.warning(
+ "US 시가총액 랭킹 조회 실패 exchange=%s: %s",
+ ranking_exchange,
+ str(e)[:120],
+ )
+ continue
+
+ items = data.get("output2", [])
+ if isinstance(items, dict):
+ items = [items]
+
+ for item in items:
+ ticker = (item.get("symb") or item.get("rsym") or "").strip().upper()
+ if not ticker:
+ continue
+ results.append(
+ {
+ "market": "US",
+ "currency": "USD",
+ "exchange": item.get("excd", ranking_exchange),
+ "ticker": ticker,
+ "name": (item.get("name") or item.get("ename") or ticker).strip(),
+ "rank": _to_int(item.get("rank", 0)),
+ "price": _to_float(item.get("last", 0)),
+ "prdy_ctrt": _to_float(item.get("rate", 0)),
+ "acml_vol": _to_int(item.get("tvol", 0)),
+ "market_cap": _to_float(item.get("tomv", 0)),
+ "weight": _to_float(item.get("grav", 0)),
+ }
+ )
+
+ if not results:
+ return []
+
+ # 중복 종목은 더 높은(숫자가 작은) 랭크를 우선
+ deduped: dict[str, dict] = {}
+ for item in results:
+ existing = deduped.get(item["ticker"])
+ if existing is None or (
+ item.get("rank", 0) > 0
+ and (
+ existing.get("rank", 0) <= 0
+ or item["rank"] < existing["rank"]
+ )
+ ):
+ deduped[item["ticker"]] = item
+
+ ranked = list(deduped.values())
+ ranked.sort(
+ key=lambda x: (
+ x.get("rank", 0) <= 0,
+ x.get("rank", 0) if x.get("rank", 0) > 0 else 10**9,
+ -x.get("market_cap", 0),
+ )
+ )
+ return ranked[:count]
+
+ def get_us_volume_rank(self, count: int = 30) -> list[dict]:
+ """미국 거래량 상위 후보 조회.
+
+ 한국투자 공식 문서 기준 이 API는 모의투자를 지원하지 않으므로
+ 실전 환경에서만 조회하고, 그 외에는 빈 리스트를 반환한다.
+ """
+ if not self.enable_us_trading or self.virtual:
+ return []
+
+ path = "/uapi/overseas-stock/v1/ranking/trade-vol"
+ tr_id = "HHDFS76310010"
+ nday = "0"
+ prc1 = ""
+ prc2 = ""
+ vol_rang = "0"
+
+ results: list[dict] = []
+ for exchange in self.us_exchange_search_order:
+ ranking_exchange = self._ranking_exchange_code(exchange)
+ try:
+ data = self._request(
+ "GET",
+ path,
+ tr_id,
+ params={
+ "KEYB": "",
+ "AUTH": "",
+ "EXCD": ranking_exchange,
+ "NDAY": nday,
+ "PRC1": prc1,
+ "PRC2": prc2,
+ "VOL_RANG": vol_rang,
+ },
+ )
+ except Exception as e:
+ logger.warning(
+ "US 거래량 랭킹 조회 실패 exchange=%s: %s",
+ ranking_exchange,
+ str(e)[:120],
+ )
+ continue
+
+ items = data.get("output2", [])
+ if isinstance(items, dict):
+ items = [items]
+
+ for item in items:
+ ticker = (item.get("symb") or item.get("rsym") or "").strip().upper()
+ if not ticker:
+ continue
+ results.append(
+ {
+ "market": "US",
+ "currency": "USD",
+ "exchange": item.get("excd", ranking_exchange),
+ "ticker": ticker,
+ "name": (item.get("name") or item.get("ename") or ticker).strip(),
+ "rank": _to_int(item.get("rank", 0)),
+ "price": _to_float(item.get("last", 0)),
+ "prdy_ctrt": _to_float(item.get("rate", 0)),
+ "acml_vol": _to_int(item.get("tvol", 0)),
+ "trade_amount": _to_float(item.get("tamt", 0)),
+ "avg_volume": _to_int(item.get("a_tvol", 0)),
+ }
+ )
+
+ if not results:
+ return []
+
+ deduped: dict[str, dict] = {}
+ for item in results:
+ existing = deduped.get(item["ticker"])
+ if existing is None or (
+ item.get("rank", 0) > 0
+ and (
+ existing.get("rank", 0) <= 0
+ or item["rank"] < existing["rank"]
+ )
+ ):
+ deduped[item["ticker"]] = item
+
+ ranked = list(deduped.values())
+ ranked.sort(
+ key=lambda x: (
+ x.get("rank", 0) <= 0,
+ x.get("rank", 0) if x.get("rank", 0) > 0 else 10**9,
+ -x.get("acml_vol", 0),
+ )
+ )
+ return ranked[:count]
+
+ # ── 보유종목 전량 매도 ────────────────────────────────────
+
+ def sell_all_holdings(self, market: str = "ALL") -> list[dict]:
+ """보유 종목 전량 시장가 매도 후 결과 리스트 반환."""
+ balance = self.get_balance(market=market)
+ holdings = balance.get("holdings", [])
+ results: list[dict] = []
+
+ for h in holdings:
+ ticker = h["ticker"]
+ qty = int(h["qty"])
+ mkt = h.get("market", self.detect_market(ticker))
+ if qty <= 0:
+ continue
+
+ try:
+ sell_result = self.sell_stock(ticker, qty, market=mkt)
+ try:
+ sell_price = self.get_price(ticker, market=mkt)
+ except Exception:
+ sell_price = _to_float(h.get("current_price", 0))
+
+ results.append(
+ {
+ "market": mkt,
+ "currency": h.get("currency", "KRW" if mkt == "KR" else "USD"),
+ "exchange": h.get("exchange", ""),
+ "ticker": ticker,
+ "name": h.get("name", ticker),
+ "qty": qty,
+ "avg_price": _to_float(h.get("avg_price", 0)),
+ "sell_price": _to_float(sell_price),
+ "success": sell_result.get("success", False),
+ "message": sell_result.get("message", ""),
+ "order_no": sell_result.get("order_no", ""),
+ }
+ )
+ except Exception as e:
+ results.append(
+ {
+ "market": mkt,
+ "currency": h.get("currency", "KRW" if mkt == "KR" else "USD"),
+ "exchange": h.get("exchange", ""),
+ "ticker": ticker,
+ "name": h.get("name", ticker),
+ "qty": qty,
+ "avg_price": _to_float(h.get("avg_price", 0)),
+ "sell_price": 0.0,
+ "success": False,
+ "message": str(e)[:200],
+ "order_no": "",
+ }
+ )
+ time.sleep(0.3)
+
+ return results
+
+
+# ── 유틸리티 ─────────────────────────────────────────────────
+
+
+def format_krw(amount: float) -> str:
+ """숫자를 한국 원화 축약 포맷으로 변환."""
+ if abs(amount) >= 1_0000_0000_0000:
+ return f"{amount / 1_0000_0000_0000:.1f}조"
+ if abs(amount) >= 1_0000_0000:
+ return f"{amount / 1_0000_0000:.1f}억"
+ if abs(amount) >= 1_0000:
+ return f"{amount / 1_0000:.0f}만"
+ return f"{amount:,.0f}"
+
+
+def format_usd(amount: float) -> str:
+ """USD 표기 유틸리티."""
+ return f"${amount:,.2f}"
diff --git a/requirements.txt b/requirements.txt
index 9c558e35..54662e11 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -1 +1,23 @@
-.
+typing-extensions
+langchain-core
+langchain-openai
+langchain-experimental
+pandas
+yfinance
+stockstats
+langgraph
+rank-bm25
+setuptools
+backtrader
+parsel
+requests
+tqdm
+pytz
+redis
+chainlit
+rich
+typer
+questionary
+langchain_anthropic
+langchain-google-genai
+discord.py
\ No newline at end of file
diff --git a/trade_history.py b/trade_history.py
new file mode 100644
index 00000000..503e8277
--- /dev/null
+++ b/trade_history.py
@@ -0,0 +1,446 @@
+"""
+매매 이력 관리 (SQLite)
+- 매수/매도 기록 저장
+- 누적 수익률 조회
+- 통화별 수익 요약
+- 일일 상태 관리 (재시작 중복 방지)
+"""
+
+import datetime
+import sqlite3
+from pathlib import Path
+
+DB_PATH = Path(__file__).parent / "data" / "trade_history.db"
+
+
+def _get_conn() -> sqlite3.Connection:
+ DB_PATH.parent.mkdir(parents=True, exist_ok=True)
+ conn = sqlite3.connect(str(DB_PATH))
+ conn.row_factory = sqlite3.Row
+ conn.execute("PRAGMA journal_mode=WAL")
+ return conn
+
+
+def _has_column(conn: sqlite3.Connection, table: str, column: str) -> bool:
+ rows = conn.execute(f"PRAGMA table_info({table})").fetchall()
+ return any(r["name"] == column for r in rows)
+
+
+def _migrate_schema(conn: sqlite3.Connection):
+ """기존 DB에 누락 컬럼을 idempotent하게 추가."""
+ if not _has_column(conn, "trades", "market"):
+ conn.execute("ALTER TABLE trades ADD COLUMN market TEXT NOT NULL DEFAULT 'KR'")
+ if not _has_column(conn, "trades", "currency"):
+ conn.execute("ALTER TABLE trades ADD COLUMN currency TEXT NOT NULL DEFAULT 'KRW'")
+ if not _has_column(conn, "pnl_log", "market"):
+ conn.execute("ALTER TABLE pnl_log ADD COLUMN market TEXT NOT NULL DEFAULT 'KR'")
+ if not _has_column(conn, "pnl_log", "currency"):
+ conn.execute("ALTER TABLE pnl_log ADD COLUMN currency TEXT NOT NULL DEFAULT 'KRW'")
+
+
+def init_db():
+ """테이블 생성 (최초 1회) + 스키마 마이그레이션."""
+ conn = _get_conn()
+ conn.executescript(
+ """
+ CREATE TABLE IF NOT EXISTS trades (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ ticker TEXT NOT NULL,
+ name TEXT NOT NULL DEFAULT '',
+ side TEXT NOT NULL CHECK(side IN ('BUY', 'SELL')),
+ qty INTEGER NOT NULL,
+ price REAL NOT NULL,
+ amount REAL NOT NULL,
+ market TEXT NOT NULL DEFAULT 'KR',
+ currency TEXT NOT NULL DEFAULT 'KRW',
+ order_no TEXT DEFAULT '',
+ reason TEXT DEFAULT '',
+ created_at TEXT NOT NULL DEFAULT (datetime('now', 'localtime'))
+ );
+
+ CREATE TABLE IF NOT EXISTS pnl_log (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ ticker TEXT NOT NULL,
+ name TEXT NOT NULL DEFAULT '',
+ buy_price REAL NOT NULL,
+ sell_price REAL NOT NULL,
+ qty INTEGER NOT NULL,
+ pnl REAL NOT NULL,
+ pnl_rate REAL NOT NULL,
+ market TEXT NOT NULL DEFAULT 'KR',
+ currency TEXT NOT NULL DEFAULT 'KRW',
+ created_at TEXT NOT NULL DEFAULT (datetime('now', 'localtime'))
+ );
+
+ CREATE TABLE IF NOT EXISTS daily_state (
+ date TEXT NOT NULL,
+ action TEXT NOT NULL,
+ completed_at TEXT NOT NULL,
+ details TEXT DEFAULT '',
+ PRIMARY KEY (date, action)
+ );
+
+ CREATE TABLE IF NOT EXISTS pnl_resets (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ currency TEXT DEFAULT NULL,
+ reset_by TEXT DEFAULT '',
+ reason TEXT DEFAULT '',
+ created_at TEXT NOT NULL DEFAULT (datetime('now', 'localtime'))
+ );
+
+ CREATE TABLE IF NOT EXISTS budget_anchor (
+ market TEXT PRIMARY KEY,
+ anchor_amount REAL NOT NULL DEFAULT 0,
+ updated_at TEXT NOT NULL
+ );
+ """
+ )
+
+ _migrate_schema(conn)
+ conn.commit()
+ conn.close()
+
+
+def record_trade(
+ ticker: str,
+ name: str,
+ side: str,
+ qty: int,
+ price: float,
+ order_no: str = "",
+ reason: str = "",
+ market: str = "KR",
+ currency: str = "KRW",
+):
+ """매수/매도 기록 저장."""
+ side = side.upper()
+ market = (market or "KR").upper()
+ currency = (currency or "KRW").upper()
+ px = float(price)
+ amount = float(qty) * px
+
+ conn = _get_conn()
+ conn.execute(
+ """INSERT INTO trades
+ (ticker, name, side, qty, price, amount, market, currency, order_no, reason)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
+ (ticker, name, side, qty, px, amount, market, currency, order_no, reason),
+ )
+ conn.commit()
+ conn.close()
+
+
+def record_pnl(
+ ticker: str,
+ name: str,
+ buy_price: float,
+ sell_price: float,
+ qty: int,
+ market: str = "KR",
+ currency: str = "KRW",
+):
+ """실현 손익 기록."""
+ buy_px = float(buy_price)
+ sell_px = float(sell_price)
+ pnl = (sell_px - buy_px) * qty
+ pnl_rate = ((sell_px - buy_px) / buy_px * 100) if buy_px > 0 else 0.0
+ market = (market or "KR").upper()
+ currency = (currency or "KRW").upper()
+
+ conn = _get_conn()
+ conn.execute(
+ """INSERT INTO pnl_log
+ (ticker, name, buy_price, sell_price, qty, pnl, pnl_rate, market, currency)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)""",
+ (ticker, name, buy_px, sell_px, qty, pnl, round(pnl_rate, 2), market, currency),
+ )
+ conn.commit()
+ conn.close()
+
+
+def _aggregate_pnl(
+ market: str | None = None,
+ currency: str | None = None,
+) -> dict:
+ conn = _get_conn()
+ where_clause, params = _build_pnl_where_clause(conn, market=market, currency=currency)
+ row = conn.execute(
+ f"""SELECT
+ COALESCE(SUM(pnl), 0) as total_pnl,
+ COUNT(*) as trade_count,
+ COALESCE(SUM(CASE WHEN pnl > 0 THEN 1 ELSE 0 END), 0) as win_count,
+ COALESCE(SUM(CASE WHEN pnl <= 0 THEN 1 ELSE 0 END), 0) as loss_count
+ FROM pnl_log
+ {where_clause}""",
+ params,
+ ).fetchone()
+ conn.close()
+
+ total = float(row["total_pnl"])
+ count = int(row["trade_count"])
+ win = int(row["win_count"])
+ loss = int(row["loss_count"])
+ win_rate = (win / count * 100) if count > 0 else 0.0
+
+ return {
+ "total_pnl": total,
+ "trade_count": count,
+ "win_count": win,
+ "loss_count": loss,
+ "win_rate": round(win_rate, 1),
+ }
+
+
+def _get_pnl_reset_cutoff(
+ conn: sqlite3.Connection,
+ currency: str | None = None,
+) -> str | None:
+ target_currency = (currency or "").upper() or None
+ if target_currency:
+ row = conn.execute(
+ """
+ SELECT MAX(created_at) AS cutoff
+ FROM pnl_resets
+ WHERE currency IS NULL OR currency = ?
+ """,
+ (target_currency,),
+ ).fetchone()
+ else:
+ row = conn.execute(
+ """
+ SELECT MAX(created_at) AS cutoff
+ FROM pnl_resets
+ WHERE currency IS NULL
+ """
+ ).fetchone()
+
+ cutoff = row["cutoff"] if row else None
+ return str(cutoff) if cutoff else None
+
+
+def _build_pnl_where_clause(
+ conn: sqlite3.Connection,
+ market: str | None = None,
+ currency: str | None = None,
+) -> tuple[str, list[object]]:
+ conditions: list[str] = []
+ params: list[object] = []
+ if market:
+ conditions.append("market = ?")
+ params.append(market.upper())
+ if currency:
+ conditions.append("currency = ?")
+ params.append(currency.upper())
+
+ cutoff = _get_pnl_reset_cutoff(conn, currency=currency)
+ if cutoff:
+ conditions.append("created_at > ?")
+ params.append(cutoff)
+
+ where_clause = f"WHERE {' AND '.join(conditions)}" if conditions else ""
+ return where_clause, params
+
+
+def get_total_pnl(
+ market: str | None = None,
+ currency: str | None = None,
+) -> dict:
+ """누적 수익 요약 (기존 호환: 인자 없이 전체 반환)."""
+ return _aggregate_pnl(market=market, currency=currency)
+
+
+def get_total_pnl_by_currency() -> dict[str, dict]:
+ """통화별 누적 수익 요약."""
+ conn = _get_conn()
+ rows = conn.execute(
+ "SELECT DISTINCT currency FROM pnl_log ORDER BY currency"
+ ).fetchall()
+ conn.close()
+
+ currencies = [r["currency"] for r in rows] or ["KRW", "USD"]
+ result: dict[str, dict] = {}
+ for cur in currencies:
+ result[cur] = _aggregate_pnl(currency=cur)
+ return result
+
+
+def get_recent_trades(
+ limit: int = 20,
+ market: str | None = None,
+ currency: str | None = None,
+) -> list[dict]:
+ """최근 매매 이력."""
+ conn = _get_conn()
+ conditions: list[str] = []
+ params: list[object] = []
+ if market:
+ conditions.append("market = ?")
+ params.append(market.upper())
+ if currency:
+ conditions.append("currency = ?")
+ params.append(currency.upper())
+ where_clause = f"WHERE {' AND '.join(conditions)}" if conditions else ""
+ params.append(limit)
+ rows = conn.execute(
+ f"SELECT * FROM trades {where_clause} ORDER BY id DESC LIMIT ?", params
+ ).fetchall()
+ conn.close()
+ return [dict(r) for r in rows]
+
+
+def get_recent_pnl(
+ limit: int = 20,
+ market: str | None = None,
+ currency: str | None = None,
+) -> list[dict]:
+ """최근 실현손익."""
+ conn = _get_conn()
+ where_clause, params = _build_pnl_where_clause(conn, market=market, currency=currency)
+ params.append(limit)
+ rows = conn.execute(
+ f"SELECT * FROM pnl_log {where_clause} ORDER BY id DESC LIMIT ?", params
+ ).fetchall()
+ conn.close()
+ return [dict(r) for r in rows]
+
+
+def get_ticker_summary(
+ market: str | None = None,
+ currency: str | None = None,
+) -> list[dict]:
+ """종목별 누적 수익 요약."""
+ conn = _get_conn()
+ where_clause, params = _build_pnl_where_clause(conn, market=market, currency=currency)
+ rows = conn.execute(
+ f"""SELECT
+ ticker, name, market, currency,
+ COUNT(*) as count,
+ SUM(pnl) as total_pnl,
+ AVG(pnl_rate) as avg_pnl_rate
+ FROM pnl_log
+ {where_clause}
+ GROUP BY ticker, name, market, currency
+ ORDER BY total_pnl DESC""",
+ params,
+ ).fetchall()
+ conn.close()
+ return [dict(r) for r in rows]
+
+
+def reset_pnl_history(
+ currency: str | None = None,
+ reset_by: str = "",
+ reason: str = "",
+) -> str:
+ """실현손익 집계 기준 시점을 기록한다.
+
+ 기존 손익 로그는 보존하고, 이후 조회 시 마지막 초기화 시점 이후 데이터만 집계한다.
+ """
+ target_currency = (currency or "").upper() or None
+ reset_at = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
+
+ conn = _get_conn()
+ conn.execute(
+ """
+ INSERT INTO pnl_resets (currency, reset_by, reason, created_at)
+ VALUES (?, ?, ?, ?)
+ """,
+ (target_currency, reset_by, reason, reset_at),
+ )
+ conn.commit()
+ conn.close()
+ return reset_at
+
+
+# ─── 일일 상태 관리 (재시작 중복 방지) ─────────────────────
+def is_action_done(action: str, date: str | None = None) -> bool:
+ """오늘 해당 액션이 이미 완료되었는지 확인.
+
+ Args:
+ action: 'morning_buy', 'afternoon_sell', 'us_morning_buy' 등
+ date: 날짜 (기본: 오늘)
+ """
+ if date is None:
+ date = datetime.date.today().isoformat()
+ conn = _get_conn()
+ row = conn.execute(
+ "SELECT 1 FROM daily_state WHERE date = ? AND action = ?",
+ (date, action),
+ ).fetchone()
+ conn.close()
+ return row is not None
+
+
+def mark_action_done(action: str, details: str = "", date: str | None = None):
+ """해당 액션을 완료로 표시."""
+ if date is None:
+ date = datetime.date.today().isoformat()
+ now = datetime.datetime.now().isoformat()
+ conn = _get_conn()
+ conn.execute(
+ "INSERT OR IGNORE INTO daily_state (date, action, completed_at, details) VALUES (?, ?, ?, ?)",
+ (date, action, now, details),
+ )
+ conn.commit()
+ conn.close()
+
+
+def get_daily_state(date: str | None = None) -> list[dict]:
+ """오늘 완료된 모든 액션 조회."""
+ if date is None:
+ date = datetime.date.today().isoformat()
+ conn = _get_conn()
+ rows = conn.execute(
+ "SELECT action, completed_at, details FROM daily_state WHERE date = ? ORDER BY completed_at",
+ (date,),
+ ).fetchall()
+ conn.close()
+ return [dict(r) for r in rows]
+
+
+def get_budget_anchor(market: str = "KR") -> float:
+ """시장별 자동매수 기준 자금(anchor) 조회."""
+ conn = _get_conn()
+ row = conn.execute(
+ "SELECT anchor_amount FROM budget_anchor WHERE market = ?",
+ ((market or "KR").upper(),),
+ ).fetchone()
+ conn.close()
+ return float(row["anchor_amount"]) if row else 0.0
+
+
+def set_budget_anchor(market: str, anchor_amount: float) -> float:
+ """시장별 자동매수 기준 자금을 저장한다."""
+ market = (market or "KR").upper()
+ amount = max(float(anchor_amount), 0.0)
+ now = datetime.datetime.now().isoformat()
+
+ conn = _get_conn()
+ conn.execute(
+ """
+ INSERT INTO budget_anchor (market, anchor_amount, updated_at)
+ VALUES (?, ?, ?)
+ ON CONFLICT(market) DO UPDATE SET
+ anchor_amount = excluded.anchor_amount,
+ updated_at = excluded.updated_at
+ """,
+ (market, amount, now),
+ )
+ conn.commit()
+ conn.close()
+ return amount
+
+
+def ensure_budget_anchor(market: str, available_cash: float) -> float:
+ """기준 자금이 없으면 현재 예수금으로 초기화하고, 더 큰 값이 들어오면 상향 반영한다."""
+ market = (market or "KR").upper()
+ cash = max(float(available_cash), 0.0)
+ current = get_budget_anchor(market)
+
+ if cash > 0 and (current <= 0 or cash > current):
+ return set_budget_anchor(market, cash)
+ return current
+
+
+# 모듈 로드 시 DB 초기화
+init_db()