}
const RecommendationPanel = React.memo(({
@@ -147,6 +182,7 @@ const RecommendationPanel = React.memo(({
heldStockCodes,
pendingBuyStockCodes,
livePrices,
+ onBuy,
}: RecommendationPanelProps) => {
return (
@@ -205,6 +241,7 @@ const RecommendationPanel = React.memo(({
isHeld={isHeld}
isPending={isPending}
livePrices={livePrices}
+ onBuy={onBuy}
/>
)
})
diff --git a/frontend/src/components/TradingChart.tsx b/frontend/src/components/TradingChart.tsx
index e7ce9f6..80d0c6a 100644
--- a/frontend/src/components/TradingChart.tsx
+++ b/frontend/src/components/TradingChart.tsx
@@ -284,9 +284,12 @@ export const TradingChart = React.memo(({ data, livePrice: livePriceProp }: Trad
try {
const allCandles = pd.candles;
const totalBars = allCandles.length;
- const visibleRange = {
- from: allCandles[Math.max(0, totalBars - 100)].time,
- to: allCandles[totalBars - 1].time,
+ if (totalBars === 0) return;
+ // 시간 기반 setVisibleRange는 차트가 아직 레이아웃되기 전(폭 0)이나 재마운트 직후
+ // "Value is null"을 던짐 → 인덱스 기반 logical range로 교체 + 개별 try/catch
+ const logicalRange = { from: Math.max(0, totalBars - 100), to: totalBars - 1 };
+ const setRange = (chart: IChartApi | null) => {
+ try { chart?.timeScale().setVisibleLogicalRange(logicalRange); } catch (_) { /* not laid out yet */ }
};
// 메인 차트 데이터 교체
@@ -299,11 +302,11 @@ export const TradingChart = React.memo(({ data, livePrice: livePriceProp }: Trad
mainLines[3].setData(pd.sma5);
mainLines[4].setData(pd.sma60);
}
- mainChartInstanceRef.current?.timeScale().setVisibleRange(visibleRange);
+ setRange(mainChartInstanceRef.current);
// 거래량 데이터 교체
volumeSeriesRef.current?.setData(pd.volumes);
- volumeChartInstanceRef.current?.timeScale().setVisibleRange(visibleRange);
+ setRange(volumeChartInstanceRef.current);
// RSI 데이터 교체
const rsiLines = rsiLineSeriesRef.current;
@@ -313,13 +316,13 @@ export const TradingChart = React.memo(({ data, livePrice: livePriceProp }: Trad
rsiLines[2].setData(pd.rsi.line50);
rsiLines[3].setData(pd.rsi.line70);
}
- rsiChartInstanceRef.current?.timeScale().setVisibleRange(visibleRange);
+ setRange(rsiChartInstanceRef.current);
// MACD 데이터 교체
macdSeriesRef.current.histogram?.setData(pd.macd.histogram);
macdSeriesRef.current.line?.setData(pd.macd.line);
macdSeriesRef.current.signal?.setData(pd.macd.signal);
- macdChartInstanceRef.current?.timeScale().setVisibleRange(visibleRange);
+ setRange(macdChartInstanceRef.current);
} catch (error: unknown) {
const err = error as Error | null;
console.error('차트 데이터 업데이트 오류:', err?.message);
@@ -402,11 +405,12 @@ export const TradingChart = React.memo(({ data, livePrice: livePriceProp }: Trad
macdSeriesRef.current = { histogram: macdHistSeries, line: macdLineSeries, signal: macdSignalSeries };
// 시간축 동기화
- mainChart.timeScale().subscribeVisibleTimeRangeChange((timeRange) => {
- if (timeRange) {
- volumeChart.timeScale().setVisibleRange(timeRange);
- rsiChart.timeScale().setVisibleRange(timeRange);
- macdChart.timeScale().setVisibleRange(timeRange);
+ // logical range 동기화: 하위 차트 시리즈에 해당 time이 아직 없을 때(setData 직전)
+ // setVisibleRange가 "Value is null"을 던지므로 인덱스 기반으로 맞춘다
+ mainChart.timeScale().subscribeVisibleLogicalRangeChange((range) => {
+ if (!range) return;
+ for (const c of [volumeChart, rsiChart, macdChart]) {
+ try { c.timeScale().setVisibleLogicalRange(range); } catch (_) { /* series not ready */ }
}
});
diff --git a/frontend/src/hooks/useTradeActions.ts b/frontend/src/hooks/useTradeActions.ts
index fc6e56e..b8d5227 100644
--- a/frontend/src/hooks/useTradeActions.ts
+++ b/frontend/src/hooks/useTradeActions.ts
@@ -12,11 +12,13 @@ export const useTradeActions = () => {
quantity: number
): Promise => {
try {
+ // KIS 모의서버 지연 시 주문 1건에 30~60초 걸릴 수 있음 — 기본 15초로 끊으면
+ // 서버는 체결하는데 화면은 실패로 보여 중복 주문을 유발함
await api.post('/portfolio/sell', {
stock_code: stockCode,
quantity,
price: null,
- })
+ }, { timeout: 90000 })
// Invalidate queries to refresh portfolio
queryClient.invalidateQueries({
@@ -38,6 +40,30 @@ export const useTradeActions = () => {
[queryClient]
)
+ /** 추천 종목 수동 매수 (시장가, 수량은 서버가 예산 기반 산출) */
+ const handleBuy = useCallback(
+ async (stockCode: string, stockName: string): Promise => {
+ try {
+ await api.post('/portfolio/buy', {
+ stock_code: stockCode,
+ stock_name: stockName,
+ quantity: null,
+ price: null,
+ }, { timeout: 90000 })
+ queryClient.invalidateQueries({ queryKey: ['portfolio-combined'] })
+ queryClient.invalidateQueries({ queryKey: ['trade-history'] })
+ console.log(`✅ ${stockName} buy order completed`)
+ } catch (error: any) {
+ console.error(
+ `❌ Buy order failed for ${stockName}:`,
+ error.response?.data?.detail || error.message
+ )
+ throw error
+ }
+ },
+ [queryClient]
+ )
+
const resetPendingTrades = useCallback(async (): Promise => {
try {
const response = await api.delete('/trades/pending')
@@ -62,6 +88,7 @@ export const useTradeActions = () => {
return {
handleSell,
+ handleBuy,
resetPendingTrades,
invalidateTradeHistory,
}
diff --git a/frontend/src/pages/Dashboard.tsx b/frontend/src/pages/Dashboard.tsx
index 1081675..3a19f1a 100644
--- a/frontend/src/pages/Dashboard.tsx
+++ b/frontend/src/pages/Dashboard.tsx
@@ -210,7 +210,7 @@ const Dashboard = () => {
}, [processChartData])
// Trade actions hook
- const { handleSell, resetPendingTrades } = useTradeActions()
+ const { handleSell, handleBuy, resetPendingTrades } = useTradeActions()
// Subscribe to market data via WebSocket
// reconnect 감지: wsConnected가 false→true 전환 시 강제 재구독
@@ -770,6 +770,7 @@ const Dashboard = () => {
heldStockCodes={heldStockCodes}
pendingBuyStockCodes={pendingBuyStockCodes}
livePrices={livePrices}
+ onBuy={handleBuy}
/>
# 컨테이너에서 실행 시 필수
+
+제약:
+ - API tool_use(함수 호출) 미지원 → tools 인자는 무시하고 텍스트(JSON) 응답만 사용.
+ - 프롬프트 캐시 usage 는 CLI가 보고하는 값을 그대로 전달.
+"""
+
+import asyncio
+import json
+import logging
+import os
+import shutil
+import tempfile
+from typing import Any, Dict, List
+
+from config import settings
+from claude_client import ClaudeClient, _CACHED_CONTEXT
+
+logger = logging.getLogger(__name__)
+
+_CLI_TIMEOUT = float(os.getenv("CLAUDE_CODE_TIMEOUT", "120"))
+_CLI_BIN = os.getenv("CLAUDE_CODE_BIN", "claude")
+_MAX_CONCURRENT = int(os.getenv("CLAUDE_CODE_MAX_CONCURRENT", "2"))
+# 프로젝트 .claude/ 설정(훅·CLAUDE.md)이 끼어들지 않도록 중립 디렉터리에서 실행
+_CLI_CWD = os.getenv("CLAUDE_CODE_CWD") or os.path.join(tempfile.gettempdir(), "autostock-claude-code")
+
+
+class ClaudeCodeClient(ClaudeClient):
+ """`claude -p` 서브프로세스로 ClaudeClient.analyze() 를 대체."""
+
+ def __init__(self):
+ # 부모 __init__ 은 AsyncAnthropic(api_key) 를 만들지만 여기서는 SDK 미사용
+ self.client = None
+ self.model = settings.CLAUDE_MODEL
+ self.haiku_model = settings.CLAUDE_HAIKU_MODEL
+ self._sem = asyncio.Semaphore(_MAX_CONCURRENT)
+ os.makedirs(_CLI_CWD, exist_ok=True)
+ if shutil.which(_CLI_BIN) is None:
+ logger.error("[ClaudeCode] '%s' 실행 파일을 찾을 수 없음 — 분석 호출은 모두 실패합니다", _CLI_BIN)
+ if not os.getenv("CLAUDE_CODE_OAUTH_TOKEN") and not os.getenv("ANTHROPIC_API_KEY"):
+ logger.warning("[ClaudeCode] CLAUDE_CODE_OAUTH_TOKEN 미설정 — 호스트 로그인 세션이 없으면 인증 실패")
+
+ async def analyze(
+ self,
+ system_prompt: str,
+ user_message: str,
+ tools: List[Dict[str, Any]] = None,
+ max_tokens: int = None,
+ model_override: str = None,
+ ) -> Dict[str, Any]:
+ use_model = model_override or self.model
+ if tools:
+ logger.debug("[ClaudeCode] tools %d개 무시 (CLI 모드는 tool_use 미지원)", len(tools))
+
+ full_system = f"{_CACHED_CONTEXT}\n\n{system_prompt}"
+ cmd = [
+ _CLI_BIN, "-p", user_message,
+ "--output-format", "json",
+ "--model", use_model,
+ "--system-prompt", full_system,
+ "--tools", "", # 파일/셸 도구 비활성 — 순수 텍스트 분석
+ "--max-turns", "1",
+ ]
+
+ env = os.environ.copy()
+ env.pop("ANTHROPIC_API_KEY", None) # 크레딧 없는 API 키가 구독 인증을 가리지 않도록
+
+ try:
+ async with self._sem:
+ proc = await asyncio.create_subprocess_exec(
+ *cmd,
+ stdout=asyncio.subprocess.PIPE,
+ stderr=asyncio.subprocess.PIPE,
+ env=env,
+ cwd=_CLI_CWD,
+ )
+ try:
+ out, err = await asyncio.wait_for(proc.communicate(), timeout=_CLI_TIMEOUT)
+ except asyncio.TimeoutError:
+ proc.kill()
+ raise RuntimeError(f"claude CLI timeout ({_CLI_TIMEOUT}s)")
+
+ if proc.returncode != 0:
+ raise RuntimeError(
+ f"claude CLI exit={proc.returncode}: {err.decode(errors='ignore')[-500:]}"
+ )
+
+ data = json.loads(out.decode())
+ if data.get("is_error"):
+ raise RuntimeError(f"claude CLI error: {data.get('result')}")
+
+ usage = data.get("usage") or {}
+ result = {
+ "content": [{"type": "text", "text": data.get("result", "")}],
+ "stop_reason": data.get("stop_reason", "end_turn"),
+ "model_used": use_model,
+ "usage": {
+ "input_tokens": usage.get("input_tokens", 0),
+ "output_tokens": usage.get("output_tokens", 0),
+ "cache_read_tokens": usage.get("cache_read_input_tokens", 0),
+ "cache_write_tokens": usage.get("cache_creation_input_tokens", 0),
+ },
+ }
+ logger.debug(
+ "[ClaudeCode] model=%s in=%s out=%s cost=$%s",
+ use_model, usage.get("input_tokens"), usage.get("output_tokens"),
+ data.get("total_cost_usd"),
+ )
+ return result
+
+ except Exception as e:
+ logger.error(f"Claude Code analysis error: {e}")
+ raise
+
+
+claude_code_client = ClaudeCodeClient()
diff --git a/mcp-server/config.py b/mcp-server/config.py
index bcf7f37..f8492c5 100644
--- a/mcp-server/config.py
+++ b/mcp-server/config.py
@@ -42,7 +42,7 @@ class Settings:
LOG_LEVEL = os.getenv("LOG_LEVEL", "INFO")
# Claude Settings
- CLAUDE_MODEL = os.getenv("CLAUDE_MODEL", "claude-sonnet-4-6-20250514")
+ CLAUDE_MODEL = os.getenv("CLAUDE_MODEL", "claude-sonnet-4-6")
CLAUDE_HAIKU_MODEL = os.getenv("CLAUDE_HAIKU_MODEL", "claude-haiku-4-5-20251001")
# Gemini Settings
@@ -69,7 +69,7 @@ class Settings:
if self.AI_PROVIDER == "gemini":
self.MAX_TOKENS = int(os.getenv("GEMINI_MAX_TOKENS", "1024"))
self.TEMPERATURE = float(os.getenv("GEMINI_TEMPERATURE", "0.3"))
- else:
+ else: # claude | claude_code
self.MAX_TOKENS = int(os.getenv("CLAUDE_MAX_TOKENS", "1024"))
self.TEMPERATURE = float(os.getenv("CLAUDE_TEMPERATURE", "0.3"))
diff --git a/mcp-server/gemini_client.py b/mcp-server/gemini_client.py
index 4f37a0e..4ab6124 100644
--- a/mcp-server/gemini_client.py
+++ b/mcp-server/gemini_client.py
@@ -59,8 +59,8 @@ class GeminiClient:
temperature=self.config.temperature,
)
- # 비동기 호출
- response = await self.client.models.generate_content(
+ # 비동기 호출 (google-genai SDK: models.generate_content는 동기 함수 — aio 네임스페이스 사용)
+ response = await self.client.aio.models.generate_content(
model=self.model_id,
contents=user_message,
config=config_with_system
diff --git a/mcp-server/main.py b/mcp-server/main.py
index ae90739..5389623 100644
--- a/mcp-server/main.py
+++ b/mcp-server/main.py
@@ -35,6 +35,10 @@ from mcp_tools import mcp_tools
if settings.AI_PROVIDER == "gemini":
from gemini_client import gemini_client
ai_client = gemini_client
+elif settings.AI_PROVIDER == "claude_code":
+ # Claude Code 구독(`claude -p`)으로 실행 — API 크레딧 불필요 (테스트용)
+ from claude_code_client import claude_code_client
+ ai_client = claude_code_client
else:
ai_client = claude_client
@@ -118,7 +122,7 @@ async def health_check():
return {
"status": "healthy",
"ai_provider": settings.AI_PROVIDER,
- "hybrid_ai_enabled": settings.HYBRID_AI_ENABLED if settings.AI_PROVIDER == "claude" else False,
+ "hybrid_ai_enabled": settings.HYBRID_AI_ENABLED if settings.AI_PROVIDER in ("claude", "claude_code") else False,
"claude_model": settings.CLAUDE_MODEL,
"claude_haiku_model": settings.CLAUDE_HAIKU_MODEL,
"claude_configured": bool(settings.ANTHROPIC_API_KEY),
diff --git a/mcp-server/risk_validator.py b/mcp-server/risk_validator.py
index 51d52bb..2c86661 100644
--- a/mcp-server/risk_validator.py
+++ b/mcp-server/risk_validator.py
@@ -35,7 +35,7 @@ from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession
from sqlalchemy.orm import sessionmaker
from app.core.config import settings
-from app.models.models import Portfolio, ActiveTrade, TradeHistory
+from app.models.models import Portfolio, ActiveTrade, TradeHistory, StockBan
from app.services.korea_investment_api import korea_investment_api
logger = logging.getLogger(__name__)
@@ -234,6 +234,16 @@ class RiskValidator:
# Import MDD manager
from app.services.mdd_manager import mdd_manager
+ # MCP는 백엔드와 별도 프로세스 — 인메모리 mdd_manager는 항상 초기 상태이므로
+ # 판정 전에 백엔드가 Redis에 저장한 최신 상태를 복원한다.
+ # 복원 실패 시 fail-open 유지 (기존 인메모리 상태로 계속 진행 + 경고 로그).
+ try:
+ restored = await mdd_manager.load_from_redis()
+ if not restored:
+ logger.warning("MDD state restore from Redis returned no data (fail-open, using in-memory state)")
+ except Exception as redis_err:
+ logger.warning(f"MDD state restore from Redis failed (fail-open): {redis_err}")
+
# Check if new positions are paused due to MDD
can_open = await mdd_manager.can_open_new_position()
diff --git a/mcp-server/trade_monitor.py b/mcp-server/trade_monitor.py
index 9057795..c7895b0 100644
--- a/mcp-server/trade_monitor.py
+++ b/mcp-server/trade_monitor.py
@@ -30,7 +30,14 @@ ACTIVE_TRADES_FILE = os.path.join(os.path.dirname(__file__), 'active_trades.json
MONITORING_INTERVAL_SECONDS = 60 # 60초마다 확인
BACKEND_API_BASE = os.getenv('BACKEND_API_BASE', 'http://localhost:8000')
-async def update_active_trade_state_via_api(stock_code: str, new_status: str, reason: str = "") -> bool:
+# 백엔드 require_api_key와 동일한 소스 (app/core/security.py: API_KEY 우선, 없으면 SECRET_KEY 폴백)
+API_KEY = settings.API_KEY if settings.API_KEY else settings.SECRET_KEY
+_API_HEADERS = {"X-API-Key": API_KEY}
+
+
+async def update_active_trade_state_via_api(
+ stock_code: str, new_status: str, reason: str = "", remaining_pct: float = None
+) -> bool:
"""
CLAUDE.md Section 3.2: 직접 DB 접근 금지. 백엔드 API를 통해 상태 업데이트.
@@ -41,20 +48,25 @@ async def update_active_trade_state_via_api(stock_code: str, new_status: str, re
stock_code: 종목 코드
new_status: 목표 상태 ('active', 'pending_sell', 'closed')
reason: 상태 변경 이유
+ remaining_pct: 부분 매도 후 잔여 비율 (제공 시 ActiveTrade.remaining_pct 갱신)
Returns:
성공 여부
"""
try:
async with aiohttp.ClientSession() as session:
- url = f"{BACKEND_API_BASE}/api/portfolio/active-trades/{stock_code}/status"
+ url = f"{BACKEND_API_BASE}/api/v1/portfolio/active-trades/{stock_code}/status"
payload = {
"status": new_status,
"reason": reason,
"updated_at": datetime.utcnow().isoformat()
}
+ if remaining_pct is not None:
+ payload["remaining_pct"] = remaining_pct
- async with session.patch(url, json=payload, timeout=aiohttp.ClientTimeout(total=5)) as resp:
+ async with session.patch(
+ url, json=payload, headers=_API_HEADERS, timeout=aiohttp.ClientTimeout(total=5)
+ ) as resp:
if resp.status == 200:
logging.info(f"✅ [{stock_code}] API를 통해 상태 변경: {new_status}")
return True
@@ -240,6 +252,7 @@ async def check_and_execute_trades(db: AsyncSession):
should_sell = False
sell_reason = ""
sell_quantity = 0
+ new_remaining_pct = 0.0 # 매도 후 잔여 비율 (0.0 = 전량 매도 → closed)
try:
# 현재가 조회
@@ -269,6 +282,7 @@ async def check_and_execute_trades(db: AsyncSession):
should_sell = True
sell_quantity = int(quantity * remaining_pct)
sell_reason = f"손절가 도달 (현재가: {current_price} <= 손절가: {stop_loss})"
+ new_remaining_pct = 0.0 # 전량 매도 → closed
# ─────────────────────────────────────────────────────────────
# 2. 기본 익절 조건 확인
@@ -277,6 +291,7 @@ async def check_and_execute_trades(db: AsyncSession):
should_sell = True
sell_quantity = int(quantity * remaining_pct)
sell_reason = f"목표가 도달 (현재가: {current_price} >= 목표가: {take_profit})"
+ new_remaining_pct = 0.0 # 전량 매도 → closed
# ─────────────────────────────────────────────────────────────
# 3. 부분 익절 + 트레일링 스탑 조건 (CLAUDE.md 5.2)
@@ -296,11 +311,13 @@ async def check_and_execute_trades(db: AsyncSession):
if should_tp:
should_sell = True
if remaining_pct == 1.0:
- # 첫 익절: 50% 분할 매도
+ # 첫 익절: 50% 분할 매도 — 잔량 50%는 'active'로 유지 (CLAUDE.md 5.2)
sell_quantity = int(quantity * 0.5)
+ new_remaining_pct = remaining_pct * 0.5
else:
- # 트레일링 스탑: 남은 물량 전부
+ # 트레일링 스탑: 남은 물량 전부 → closed
sell_quantity = int(quantity * remaining_pct)
+ new_remaining_pct = 0.0
sell_reason = tp_reason
else:
@@ -313,7 +330,7 @@ async def check_and_execute_trades(db: AsyncSession):
if should_sell and sell_quantity > 0:
await _execute_sell_with_pending_state(
- stock_code, quantity, sell_quantity, entry_price, sell_reason
+ stock_code, quantity, sell_quantity, entry_price, sell_reason, new_remaining_pct
)
@@ -322,7 +339,8 @@ async def _execute_sell_with_pending_state(
total_quantity: int,
sell_quantity: int,
entry_price: float,
- sell_reason: str
+ sell_reason: str,
+ new_remaining_pct: float = 0.0
) -> None:
"""
CLAUDE.md Section 5.2: 매도 주문 실행 및 상태 전이
@@ -330,7 +348,9 @@ async def _execute_sell_with_pending_state(
단계:
1. 상태를 'pending_sell'로 변경 (즉시)
2. 매도 주문 실행
- 3. 주문 성공 시 'closed'로 변경 (1-5초 대기)
+ 3. 주문 성공 시 상태 전이 (1-5초 대기)
+ - new_remaining_pct > 0 (부분 매도): 'active' 유지 + remaining_pct 갱신
+ - new_remaining_pct == 0 (전량 매도): 'closed'로 전이
4. 실패 시 'active'로 롤백
Args:
@@ -339,6 +359,7 @@ async def _execute_sell_with_pending_state(
sell_quantity: 매도할 수량
entry_price: 진입가
sell_reason: 매도 이유
+ new_remaining_pct: 매도 후 잔여 비율 (0.0이면 전량 매도 → closed)
"""
try:
logging.warning(
@@ -374,24 +395,41 @@ async def _execute_sell_with_pending_state(
logging.info(f"[{stock_code}] 매도 주문 결과: {sell_result}")
if sell_result and sell_result.get('rt_cd') == '0':
- order_id = sell_result.get('output', {}).get('order_id', '')
+ order_id = sell_result.get('output', {}).get('ODNO', '')
logging.info(f"✅ [{stock_code}] 매도 주문 성공 (주문ID: {order_id})")
# ─────────────────────────────────────────────────────────
- # Step 3: 1-5초 대기 후 체결 확인 및 상태 'closed'로 변경
+ # Step 3: 1-5초 대기 후 체결 확인 및 상태 전이
+ # CLAUDE.md 5.2: 부분 매도(+2R 50%)는 'active' 유지 + remaining_pct 갱신,
+ # 전량 매도만 'closed'로 전이 (잔량이 감시에서 이탈하지 않도록)
# ─────────────────────────────────────────────────────────
await asyncio.sleep(2) # 체결 대기
- success = await update_active_trade_state_via_api(
- stock_code,
- "closed",
- f"매도 완료: {sell_reason} (주문ID: {order_id})"
- )
-
- if success:
- logging.info(f"✅ [{stock_code}] 상태: pending_sell → closed")
+ if new_remaining_pct > 0:
+ success = await update_active_trade_state_via_api(
+ stock_code,
+ "active",
+ f"부분 매도 완료: {sell_reason} (주문ID: {order_id})",
+ remaining_pct=new_remaining_pct
+ )
+ if success:
+ logging.info(
+ f"✅ [{stock_code}] 상태: pending_sell → active "
+ f"(잔여비율: {new_remaining_pct*100:.1f}%)"
+ )
+ else:
+ logging.warning(f"⚠️ [{stock_code}] 부분매도 후 active 전이 실패 (order_id: {order_id})")
else:
- logging.warning(f"⚠️ [{stock_code}] closed 상태 전이 실패 (order_id: {order_id})")
+ success = await update_active_trade_state_via_api(
+ stock_code,
+ "closed",
+ f"매도 완료: {sell_reason} (주문ID: {order_id})"
+ )
+
+ if success:
+ logging.info(f"✅ [{stock_code}] 상태: pending_sell → closed")
+ else:
+ logging.warning(f"⚠️ [{stock_code}] closed 상태 전이 실패 (order_id: {order_id})")
else:
error_msg = sell_result.get('msg1', '알 수 없는 오류') if sell_result else "응답 없음"
logging.error(f"❌ [{stock_code}] 매도 주문 실패: {error_msg}")