feat: 자동매매 신뢰성 개선 — 시세 실서버 라우팅·일일 한도 전역화·수동 매수 API

- korea_investment_api: KIS_QUOTES_VIA_REAL — 모의투자 모드에서 시세/차트/호가 조회를
  실계좌 시세 서버(20/s)로 위임, 주문·잔고는 모의서버 유지 (EGW00201 대응)
- recommendations: execute_auto_trade_background asyncio.Lock 중복 실행 방지 +
  MAX_DAILY_TRADES를 당일 BUY 건수 차감 전역 기준으로 적용 (11종목 동시 매수 재발 방지)
- manual_buy.py 신규: POST /api/v1/portfolio/buy (리스크 가드 + safe_place_order + db_worker)
- mcp-server: claude_code_client(claude -p 헤드리스 모드) 추가, trade_monitor 부분매도 시
  active 유지·remaining_pct 갱신, API 키 헤더·/api/v1 경로 수정, ODNO 주문ID 수정
- config: MCP_REQUEST_TIMEOUT, MCP_RISK_CACHE_TTL, BATCH_ANALYSIS_INTERVAL,
  SWING_INDEX_BREAKER_ENABLED(OFF), API_TRIGGERED_AUTO_TRADE(OFF) 추가
- docker-compose: 포트 충돌 해소 (postgres 15433, frontend 3001)
- frontend: 추천 패널 매수 버튼, 차트/호가/저널 패널 개선
- CLAUDE.md 압축 최적화 (규칙 변경 없음), _sweep_single_axis.py 백테스트 단일축 스윕

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-19 23:37:08 +09:00
parent b1da208fd5
commit 028a1c352b
39 changed files with 1073 additions and 694 deletions

View File

@@ -0,0 +1,113 @@
#!/usr/bin/env python3
"""단일 파라미터 축 민감도 스윕 (run_backtest.py의 'normal' 모드 필드 1개만 오버라이드).
run_backtest_sweep.py와 동일한 텍스트 치환 방식을 사용하되, ADAPTIVE_MODES 전체가
아니라 normal 모드의 지정된 키 하나만 바꿔서 단일 축 민감도를 측정한다.
"""
import subprocess, json, sys, os, time, argparse
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
BACKTEST_SCRIPT = os.path.join(BASE_DIR, "run_backtest.py")
BASELINE_MODES = {
"aggressive": {"rsi_entry": 48.0, "bear_rsi_entry": 38.0, "bb_pct_b_scale": 0.45, "position_size": 0.07,
"max_holdings": 15, "stop_loss": -8.0, "stop_loss_vol": -12.0, "atr_stop_mult": 3.0,
"atr_trail_mult": 1.8, "rsi_partial": 58.0, "rsi_full": 72.0},
"normal": {"rsi_entry": 43.0, "bear_rsi_entry": 32.0, "bb_pct_b_scale": 0.35, "position_size": 0.05,
"max_holdings": 12, "stop_loss": -6.0, "stop_loss_vol": -10.0, "atr_stop_mult": 2.5,
"atr_trail_mult": 1.5, "rsi_partial": 52.0, "rsi_full": 67.0},
"defensive": {"rsi_entry": 35.0, "bear_rsi_entry": 25.0, "bb_pct_b_scale": 0.20, "position_size": 0.03,
"max_holdings": 8, "stop_loss": -5.0, "stop_loss_vol": -8.0, "atr_stop_mult": 2.0,
"atr_trail_mult": 1.2, "rsi_partial": 48.0, "rsi_full": 60.0},
"crisis": {"rsi_entry": 28.0, "bear_rsi_entry": 20.0, "bb_pct_b_scale": 0.10, "position_size": 0.0,
"max_holdings": 3, "stop_loss": -4.0, "stop_loss_vol": -6.0, "atr_stop_mult": 1.5,
"atr_trail_mult": 1.0, "rsi_partial": 45.0, "rsi_full": 55.0},
}
def patch_and_run(modes, start, end, timeout=600):
with open(BACKTEST_SCRIPT, "r") as f:
content = f.read()
start_marker = "ADAPTIVE_MODES = {"
end_marker = "\n\ndef determine_adaptive_mode("
s = content.find(start_marker)
e = content.find(end_marker)
if s == -1 or e == -1:
print("[ERROR] ADAPTIVE_MODES 블록을 찾을 수 없습니다")
return None
new_block = "ADAPTIVE_MODES = " + json.dumps(modes, indent=4, ensure_ascii=False)
patched = content[:s] + new_block + content[e:]
tmp_script = os.path.join(BASE_DIR, f"_tmp_sweep_{os.getpid()}.py")
with open(tmp_script, "w") as f:
f.write(patched)
try:
result = subprocess.run(
[sys.executable, tmp_script, "--start", start, "--end", end],
capture_output=True, text=True, timeout=timeout,
)
output = result.stdout + result.stderr
finally:
try:
os.remove(tmp_script)
except OSError:
pass
metrics = {}
for line in output.split("\n"):
line = line.strip()
if "총 수익률:" in line:
metrics["return"] = line.split(":")[-1].strip()
elif "CAGR:" in line:
metrics["cagr"] = line.split(":")[-1].strip()
elif "Max Drawdown:" in line:
metrics["mdd"] = line.split(":")[-1].strip()
elif "승률:" in line:
metrics["winrate"] = line.split(":")[-1].strip()
elif "총 매매 횟수:" in line:
metrics["trades"] = line.split(":")[-1].strip()
elif "Sharpe Ratio:" in line:
metrics["sharpe"] = line.split(":")[-1].strip()
elif "Profit Factor:" in line:
metrics["pf"] = line.split(":")[-1].strip()
elif "최대 연패:" in line:
metrics["max_loss_streak"] = line.split(":")[-1].strip()
if not metrics:
print("---- RAW OUTPUT (no metrics parsed) ----")
print(output[-3000:])
return metrics
AXIS_KEY_MAP = {
"rsi_entry": "rsi_entry",
"stop_loss": "stop_loss",
"rsi_partial": "rsi_partial",
"atr_stop_mult": "atr_stop_mult",
}
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--axis", required=True, choices=list(AXIS_KEY_MAP.keys()))
ap.add_argument("--values", required=True, help="comma separated values, e.g. 42,46,50")
ap.add_argument("--start", default="2024-09-01")
ap.add_argument("--end", default="2026-09-05")
args = ap.parse_args()
key = AXIS_KEY_MAP[args.axis]
values = [float(v) for v in args.values.split(",")]
print(f"=== Axis: {args.axis} ({key}) | period {args.start}~{args.end} ===")
for v in values:
import copy
modes = copy.deepcopy(BASELINE_MODES)
modes["normal"][key] = v
print(f"\n--- {args.axis}={v} (normal mode only) ---")
t0 = time.time()
m = patch_and_run(modes, args.start, args.end)
elapsed = time.time() - t0
print(f" elapsed={elapsed:.1f}s {m}")
if __name__ == "__main__":
main()

View File

@@ -14,7 +14,7 @@ from app.api.endpoints import (
journals, day_trading, strategy_config, backtest, analysis,
swing_portfolio, daytrading_v2, us_trading, kr_backtest,
market_intelligence, market_playbook, news,
adaptive_strategy,
adaptive_strategy, manual_buy,
)
api_router = APIRouter()
@@ -28,6 +28,10 @@ api_router.include_router(
portfolio.router, prefix="/portfolio", tags=["portfolio"],
dependencies=[Depends(require_api_key)],
)
api_router.include_router(
manual_buy.router, prefix="/portfolio", tags=["portfolio"],
dependencies=[Depends(require_api_key)],
)
api_router.include_router(
recommendations.router, prefix="/recommendations", tags=["recommendations"],
dependencies=[Depends(require_api_key)],

View File

@@ -0,0 +1,183 @@
"""추천 종목 수동 매수 엔드포인트 (프론트 "매수" 버튼 — 청산 버튼의 매수 대응).
POST /api/v1/portfolio/buy
- quantity 생략 시: 총자산 × MAX_POSITION_SIZE 예산으로 시장가 수량 자동 산출
- 리스크 가드(§5): 가격 필터·연속손실 ban·MDD 신규진입 차단·최대 보유수·TRADING_HALTED
- 주문은 trade_executor.safe_place_order (종목별 세마포어 + Redis 분산락) 경유
- DB 쓰기는 db_worker 큐 경유(insert_trade_history / upsert_portfolio), 이후 KIS 잔고 재동기화
"""
import asyncio
import time
import logging
from datetime import datetime
from typing import Optional
import pytz
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, Field
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.config import settings
from app.core.database import get_db
from app.core.security import require_api_key
from app.models.models import Portfolio
from app.services.korea_investment_api import korea_investment_api
from app.services.loss_tracker import loss_tracker
from app.services.mdd_manager import mdd_manager
from app.services.utils import _is_domestic
logger = logging.getLogger(__name__)
router = APIRouter()
_KST = pytz.timezone("Asia/Seoul")
class BuyRequest(BaseModel):
stock_code: str
stock_name: Optional[str] = None
quantity: Optional[int] = Field(None, gt=0, description="매수 수량 (None이면 예산 기반 자동 산출)")
price: Optional[int] = Field(None, gt=0, description="지정가 (None이면 시장가)")
def _kst_now() -> datetime:
return datetime.now(_KST).replace(tzinfo=None)
# 종목별 수동 매수 중복 방지: 진행 중(in-flight) 또는 최근 완료(쿨다운) 시 거부.
# KIS 지연으로 주문이 30~60초 걸릴 때 화면 타임아웃 → 재클릭 → 같은 종목 3회 매수된 사고 방지.
_inflight: set[str] = set()
_last_done: dict[str, float] = {}
_DUP_COOLDOWN_SEC = 120.0
@router.post("/buy", dependencies=[Depends(require_api_key)])
async def buy_stock(request: BuyRequest, db: AsyncSession = Depends(get_db)):
"""추천 종목 수동 매수 (매수 버튼)."""
stock_code = request.stock_code.strip()
if not _is_domestic(stock_code):
raise HTTPException(status_code=400, detail="국내 종목(6자리 코드)만 수동 매수를 지원합니다")
if settings.TRADING_HALTED:
raise HTTPException(status_code=423, detail="TRADING_HALTED — 전체 매수 중단 상태")
if stock_code in _inflight:
raise HTTPException(status_code=409, detail="같은 종목 매수 주문이 아직 처리 중입니다 (KIS 응답 대기). 잠시 후 포트폴리오를 확인하세요")
_since = time.monotonic() - _last_done.get(stock_code, -1e9)
if _since < _DUP_COOLDOWN_SEC:
raise HTTPException(
status_code=409,
detail=f"{int(_DUP_COOLDOWN_SEC - _since)}초 전 같은 종목을 매수했습니다. 중복 주문 방지 쿨다운 중",
)
_inflight.add(stock_code)
try:
return await _buy_stock_impl(request, stock_code, db)
finally:
_inflight.discard(stock_code)
async def _buy_stock_impl(request: BuyRequest, stock_code: str, db: AsyncSession):
# ── 현재가 ──────────────────────────────────────────────────────────
try:
price_data = await korea_investment_api.get_current_price(stock_code)
output = (price_data or {}).get("output") or {}
current_price = float(output.get("stck_prpr") or 0)
stock_name = request.stock_name or output.get("hts_kor_isnm") or stock_code
except Exception as e:
raise HTTPException(status_code=502, detail=f"현재가 조회 실패: {e}")
if current_price <= 0:
raise HTTPException(status_code=404, detail=f"현재가를 가져올 수 없습니다: {stock_code}")
# ── 리스크 가드 (CLAUDE.md §5) ───────────────────────────────────────
if current_price <= settings.MIN_PRICE_KRW:
raise HTTPException(status_code=400, detail=f"가격 필터: {settings.MIN_PRICE_KRW:,}원 이하 종목 매수 금지")
if await loss_tracker.is_banned_safe(stock_code):
raise HTTPException(status_code=400, detail="연속 손실 진입 금지 종목입니다 (loss_tracker ban)")
if not await mdd_manager.can_open_new_position():
raise HTTPException(status_code=400, detail="MDD 신규 진입 중단 상태입니다")
held = await db.execute(select(Portfolio).where(Portfolio.stock_code == stock_code))
holding = held.scalar_one_or_none()
if holding is None:
cnt = (await db.execute(
select(func.count()).select_from(Portfolio).where(Portfolio.quantity > 0)
)).scalar() or 0
if cnt >= settings.MAX_HOLDINGS_COUNT:
raise HTTPException(status_code=400, detail=f"최대 보유 종목 수({settings.MAX_HOLDINGS_COUNT}) 초과")
# ── 수량 산출 (예산 = 총자산 × MAX_POSITION_SIZE, 예수금 한도) ────────────
quantity = request.quantity
order_price = float(request.price) if request.price else current_price
if quantity is None:
try:
balance = await korea_investment_api.get_balance()
output2 = balance.get("output2") or [{}]
b = output2[0] if isinstance(output2, list) else output2
available_cash = float(b.get("dnca_tot_amt") or 0)
total_assets = float(b.get("tot_evlu_amt") or 0)
except Exception as e:
raise HTTPException(status_code=502, detail=f"잔고 조회 실패: {e}")
budget = min(total_assets * settings.MAX_POSITION_SIZE, available_cash)
quantity = int(budget // order_price)
if quantity < 1:
raise HTTPException(
status_code=400,
detail=f"예산 부족: 예산 {budget:,.0f}원 < 1주 {order_price:,.0f}원 (예수금 {available_cash:,.0f}원)",
)
logger.info(f"🟢 수동 매수 요청: {stock_name}({stock_code}) {quantity}주 @ {request.price or '시장가'}")
# ── 주문 (세마포어 + 분산락) ─────────────────────────────────────────
from app.services.trade_executor import safe_place_order, _delayed_sync_portfolio
order_result = await safe_place_order(
stock_code=stock_code, order_type="BUY", quantity=quantity,
price=request.price, is_domestic=True,
)
if not order_result or order_result.get("rt_cd") != "0":
msg = order_result.get("msg1", "알 수 없는 오류") if order_result else "API 호출 실패"
raise HTTPException(status_code=500, detail=f"매수 주문 실패: {msg}")
order_id = (order_result.get("output") or {}).get("ODNO")
# ── DB 기록 (db_worker 큐) ────────────────────────────────────────────
from app.services.db_worker import enqueue_trade_history, enqueue_portfolio_upsert
now = _kst_now()
await enqueue_trade_history({
"stock_code": stock_code,
"stock_name": stock_name,
"order_type": "BUY",
"order_price": float(request.price) if request.price else None,
"order_quantity": quantity,
"executed_price": float(order_price),
"executed_quantity": quantity,
"total_amount": float(order_price * quantity),
"status": "COMPLETED",
"order_id": order_id,
"message": "수동 매수 (추천 패널)",
"strategy_type": "manual",
"created_at": now,
"executed_at": now,
})
if holding is None:
await enqueue_portfolio_upsert({
"stock_code": stock_code,
"stock_name": stock_name,
"quantity": quantity,
"average_price": order_price,
"current_price": current_price,
"market": "KRX",
"strategy_type": "manual",
"currency_code": "KRW",
"exchange_code": "KRX",
})
# 체결 후 KIS 잔고 기준 재동기화 (평균단가·수량 정확화)
asyncio.create_task(_delayed_sync_portfolio(3))
_last_done[stock_code] = time.monotonic()
logger.info(f"✅ 수동 매수 주문 완료: {stock_name} {quantity}주 @ {order_price:,.0f}원 (주문번호 {order_id})")
return {
"success": True,
"message": f"{stock_name} {quantity}주 매수 주문 완료",
"order_id": order_id,
"quantity": quantity,
"price": order_price,
}

View File

@@ -212,6 +212,7 @@ def _generate_sample_chart(stock_code: str, period: str, limit: int = 100) -> di
chart_cache = {}
_CHART_CACHE_MAX_SIZE = 100
_CHART_CACHE_TTL = 600 # 10분
_CHART_STALE_DAYS = 7 # DB 마지막 봉이 이보다 오래되면 네이버 차트 폴백
def _aggregate_weekly_monthly(daily_data: list, period: str) -> list:
@@ -328,13 +329,23 @@ async def get_chart_data_cached(stock_code: str, period: str, limit: int) -> lis
db_limit = limit if period == "D" else limit * 7 if period == "W" else limit * 31
db_data = await _fetch_chart_from_db(stock_code, min(db_limit, 1200))
if not db_data:
# DB에 데이터 없으면 네이버 차트 폴백
# DB 데이터가 없거나 오래됐으면(마지막 봉이 _CHART_STALE_DAYS일 이상 전) 네이버 차트 폴백
# — KRX 일괄 수집(DATAGOKR)이 멈춘 종목은 DB가 수개월 전에서 끊겨 차트가 현재가와 어긋난다.
_stale = False
if db_data:
try:
_last = datetime.strptime(str(db_data[-1].get("date", ""))[:8], "%Y%m%d")
_stale = (current_time - _last).days > _CHART_STALE_DAYS
except ValueError:
_stale = False
if not db_data or _stale:
if _stale:
logger.info(f"차트 DB 데이터 오래됨 ({stock_code}, 마지막 {db_data[-1].get('date')}) → 네이버 폴백")
try:
from app.services.naver_finance import get_naver_daily_chart
naver_data = await get_naver_daily_chart(stock_code, days=limit * 2)
if naver_data:
db_data = []
naver_rows = []
for item in naver_data:
o = float(item.get("stck_oprc") or 0)
h = float(item.get("stck_hgpr") or 0)
@@ -343,7 +354,7 @@ async def get_chart_data_cached(stock_code: str, period: str, limit: int) -> lis
d = item.get("stck_bsop_date", "")
if not d or c <= 0 or o <= 0 or h <= 0 or l <= 0:
continue
db_data.append({
naver_rows.append({
"date": d,
"open": o,
"high": h,
@@ -351,7 +362,8 @@ async def get_chart_data_cached(stock_code: str, period: str, limit: int) -> lis
"close": c,
"volume": int(item.get("acml_vol") or 0),
})
db_data = db_data[-limit:]
if naver_rows:
db_data = naver_rows[-limit:]
logger.info(f"차트 네이버 폴백: {stock_code} ({len(db_data)}행)")
except Exception as e:
logger.warning(f"네이버 차트 폴백 실패 ({stock_code}): {e}")

View File

@@ -14,6 +14,7 @@ from typing import Dict, Any, Optional, Tuple
from pydantic import BaseModel
import httpx
import logging
import time
from app.core.config import settings
@@ -66,6 +67,20 @@ def get_http_client() -> httpx.AsyncClient:
_fail_open_count: int = 0
_FAIL_OPEN_WARN_THRESHOLD: int = 5 # 연속 N회 초과 시 경고 로그
# ─── RISK_ASSESSMENT 결과 캐시 (MCP_RISK_CACHE_TTL>0 일 때만) ────────────
# 스케줄러가 매 사이클(60s/30s) 같은 종목셋으로 재요청하는 AI 호출을 줄인다.
_risk_cache: Dict[str, Tuple[float, bool, str]] = {} # key → (expires_at, approve, reason)
def _risk_cache_key(trade_plan: Dict[str, Any]) -> str:
stocks = trade_plan.get("top_stocks") or []
codes = sorted(
str(s.get("stock_code", s) if isinstance(s, dict) else s) for s in stocks
)
if not codes and trade_plan.get("stock_code"):
codes = [str(trade_plan["stock_code"])]
return f"{trade_plan.get('strategy', '')}:{trade_plan.get('action', '')}:{','.join(codes)}"
# ─── 공통 리스크 평가 함수 ────────────────────────────────────────────
async def request_mcp_risk_assessment(
@@ -91,6 +106,13 @@ async def request_mcp_risk_assessment(
if not settings.MCP_VALIDATION_ENABLED:
return True, "mcp_disabled"
_ttl = settings.MCP_RISK_CACHE_TTL
_ckey = _risk_cache_key(trade_plan) if _ttl > 0 else None
if _ckey:
_hit = _risk_cache.get(_ckey)
if _hit and _hit[0] > time.monotonic():
return _hit[1], f"{_hit[2]} (cached)"
try:
client = get_http_client()
except RuntimeError:
@@ -116,7 +138,7 @@ async def request_mcp_risk_assessment(
"signals": signals,
},
},
timeout=15.0,
timeout=settings.MCP_REQUEST_TIMEOUT,
)
if resp.status_code == 200:
result = resp.json()
@@ -124,11 +146,11 @@ async def request_mcp_risk_assessment(
payload = result.get("result", result)
decision = str(payload.get("decision", "approve")).lower()
reason = str(payload.get("reason", ""))
if "deny" in decision or "reject" in decision:
_fail_open_count = 0 # 정상 응답 수신 시 카운터 리셋
return False, reason
_fail_open_count = 0 # 정상 응답 수신 시 카운터 리셋
return True, reason
approve = not ("deny" in decision or "reject" in decision)
if _ckey:
_risk_cache[_ckey] = (time.monotonic() + _ttl, approve, reason)
return approve, reason
else:
_fail_open_count += 1
logger.warning(

View File

@@ -178,7 +178,9 @@ async def get_rule_based_recommendations(
else:
result = await get_rule_based_recommendations_from_db(db)
if auto_trade and result.get("recommendations_by_rule"):
# 대시보드 폴링(GET)이 매매를 트리거하면 스윙 스케줄러와 동시 실행되어 일일 한도가 무력화됨
# (2026-09-17). 기본적으로 조회 전용이며, API_TRIGGERED_AUTO_TRADE=true 일 때만 실행.
if auto_trade and settings.API_TRIGGERED_AUTO_TRADE and result.get("recommendations_by_rule"):
rec_snapshot = result
async def _bg_trade():
try:

View File

@@ -291,7 +291,15 @@ async def check_risk_limits(
session = result.scalar_one_or_none()
if not session:
session = TradingSession(session_date=today, starting_balance=Decimal(str(account_balance)))
session = TradingSession(
session_date=today,
starting_balance=Decimal(str(account_balance)),
consecutive_losses=0,
consecutive_wins=0,
daily_profit_loss=0,
daily_trades_count=0,
is_paused=False
)
db.add(session)
await db.commit()
await db.refresh(session)
@@ -386,9 +394,16 @@ async def update_trading_session(
session = result.scalar_one_or_none()
if not session:
session = TradingSession(session_date=today)
session = TradingSession(
session_date=today,
consecutive_losses=0,
consecutive_wins=0,
daily_profit_loss=0,
daily_trades_count=0,
is_paused=False
)
db.add(session)
# 손익 누적
current_pl = float(session.daily_profit_loss) if session.daily_profit_loss else 0
session.daily_profit_loss = Decimal(str(current_pl + profit_loss))

View File

@@ -128,8 +128,7 @@ async def manual_swing_sell(
stock_code=req.stock_code,
order_type="SELL",
quantity=quantity,
price=0,
order_price_type="01",
price=None, # 시장가 (order_dvsn="01"은 price=None일 때 자동 적용)
)
if not order_resp or order_resp.get("rt_cd") != "0":
err = order_resp.get("msg1", "unknown") if order_resp else "no response"

View File

@@ -69,7 +69,7 @@ async def execute_us_trade(req: UsOrderRequest):
if not req.price:
# Get current price for limit order
price_data = await korea_investment_api.get_overseas_price(req.ticker, exchange)
req.price = float(price_data.get("last", 0))
req.price = float(price_data.get("output", {}).get("last", 0))
if not req.price or req.price <= 0:
raise HTTPException(
status_code=400,
@@ -77,12 +77,12 @@ async def execute_us_trade(req: UsOrderRequest):
)
result = await korea_investment_api.place_overseas_order(
req.ticker, "buy", req.quantity, req.price, exchange
req.ticker, req.order_type.upper(), req.quantity, req.price, exchange
)
elif req.order_type.lower() == "sell":
if not req.price:
price_data = await korea_investment_api.get_overseas_price(req.ticker, exchange)
req.price = float(price_data.get("last", 0))
req.price = float(price_data.get("output", {}).get("last", 0))
if not req.price or req.price <= 0:
raise HTTPException(
status_code=400,
@@ -90,7 +90,7 @@ async def execute_us_trade(req: UsOrderRequest):
)
result = await korea_investment_api.place_overseas_order(
req.ticker, "sell", req.quantity, req.price, exchange
req.ticker, req.order_type.upper(), req.quantity, req.price, exchange
)
else:
raise HTTPException(status_code=400, detail="order_type must be 'buy' or 'sell'")

View File

@@ -69,6 +69,8 @@ class Settings(BaseSettings):
KIS_WS_VIRTUAL_URL: str = "wss://openapivts.koreainvestment.com:29443/websocket"
KIS_WS_ENABLED: bool = True # WebSocket 실시간 스트림 활성화
KIS_SYNC_INTERVAL: int = 10 # 구독 목록 동기화 주기 (초)
KIS_QUOTES_VIA_REAL: bool = True # 모의투자 모드에서 시세·차트·호가 조회를 실계좌 시세 서버(초당 20건)로 라우팅. 주문·잔고는 모의서버 유지
KIS_REAL_MIN_REQUEST_INTERVAL: float = 0.1 # 실계좌 시세 인스턴스 최소 요청 간격(초). 한도 20/s 대비 여유 10/s
KIS_RECONNECT_WAIT: int = 5 # 재연결 대기 시간 (초)
KIS_APPROVAL_KEY_TTL: int = 82800 # approval_key 캐시 유효시간 (초, 23시간)
@@ -85,6 +87,9 @@ class Settings(BaseSettings):
MCP_SERVER_URL: str = "http://localhost:8001"
MCP_API_KEY: str = "" # MCP 서버 인증 키 (backend -> MCP 통신 시 X-API-Key 헤더)
MCP_VALIDATION_ENABLED: bool = True # 기본 ON — AI 검증 없이 자동매매 차단
MCP_REQUEST_TIMEOUT: float = 15.0 # MCP /analyze 호출 타임아웃(초) — claude_code CLI 모드는 60 권장
MCP_RISK_CACHE_TTL: int = 0 # 동일 종목셋 RISK_ASSESSMENT 결과 재사용(초). 0=매번 호출, 3600=1시간
BATCH_ANALYSIS_INTERVAL: int = 600 # 장중 배치 분석 주기(초). 3600=1시간
MCP_CONFIDENCE_THRESHOLD: float = 0.5 # AI 신뢰도 최소값 (0.0~1.0)
MCP_ANALYSIS_MIN_SCORE: int = 70 # 기본 70점 — .env에서 MCP_ANALYSIS_MIN_SCORE=N 으로 조정
NEWS_API_KEY: str = ""
@@ -268,6 +273,7 @@ class Settings(BaseSettings):
OVERHEAT_10DAY_MAX: float = 30.0
OVERHEAT_DISPARITY_MAX: float = 108.0
BEAR_MARKET_FILTER_ENABLED: bool = True # 하락장 방어 필터
SWING_INDEX_BREAKER_ENABLED: bool = False # 지수 서킷브레이커(KODEX200<MA20 시 스윙 신규진입 차단). 백테스트 근거 있으나 사용자 결정으로 기본 OFF
BEAR_MARKET_MIN_SCORE: int = 5
BEAR_MARKET_MAX_TRADES: int = 3
BEAR_MARKET_POSITION_RATIO: float = 0.5
@@ -283,6 +289,7 @@ class Settings(BaseSettings):
# ═════════════════════════════════════════════════════════════════════════════
AUTO_TRADE_ENABLED: bool = True
MAX_DAILY_TRADES: int = 5
API_TRIGGERED_AUTO_TRADE: bool = False # GET /recommendations/rule-based?auto_trade=true 가 매매를 실행할지. 기본 OFF — 매매는 스윙 스케줄러 단일 경로
MAX_DAILY_BUYS_PER_STOCK: int = 2 # 종목당 일일 최대 매수 횟수
ANALYSIS_STOCK_COUNT: int = 300 # 배치 분석 종목 수
AUTO_TRADE_TOP_N_PER_RULE: int = 2 # 규칙당 자동매매 실행 최대 종목 수

View File

@@ -451,8 +451,10 @@ else:
_allowed_origins = [
"http://localhost:5173",
"http://localhost:3000",
"http://localhost:3001",
"http://127.0.0.1:5173",
"http://127.0.0.1:3000",
"http://127.0.0.1:3001",
]
app.add_middleware(
CORSMiddleware,

View File

@@ -551,7 +551,8 @@ def check_day_trade_exit(
pass
# 4. 목표가 달성 → 50% 분할 매도 (CLAUDE.md 5.2: +2R 시 50% 분할 매도)
if current_price >= target_price:
# [FIX] target_price<=0 방어 — 0이면 즉시 조건 충족되어 오매도되는 것을 방지
if target_price > 0 and current_price >= target_price:
gain_pct = (current_price - entry_price) / entry_price * 100
return DayTradeExit(
action="SELL_PARTIAL",

View File

@@ -124,8 +124,10 @@ async def _check_single_position(stock_code: str) -> dict | None:
return None
entry_price = pos.get("entry_price", 0)
stop_loss = pos.get("stop_loss", entry_price * 0.94)
target_price = pos.get("target_price", entry_price * 1.06)
# [FIX] 0 값이 저장된 경우 dict.get() 기본값이 적용되지 않으므로 `or`로 방어
# (target_price=0이면 day_trading_strategy.py의 목표가 도달 조건이 항상 참이 되어 즉시 오매도됨)
stop_loss = pos.get("stop_loss") or (entry_price * 0.94)
target_price = pos.get("target_price") or (entry_price * 1.06)
quantity = pos.get("quantity", 0)
highest_price = pos.get("highest_price", entry_price)
entry_time = pos.get("entry_time")

View File

@@ -267,6 +267,7 @@ class DayTradingState:
int: 복원된 포지션 수
"""
from app.models.models import ActiveTrade
from app.core.config import settings
today = _today_kst()
restored = 0
@@ -293,12 +294,32 @@ class DayTradingState:
if trade.stock_code in self.positions:
continue
entry_price = float(trade.entry_price) if trade.entry_price else 0
stop_loss = float(trade.stop_loss) if trade.stop_loss else 0
target_price = float(trade.take_profit) if trade.take_profit else 0
# [FIX] stop_loss/target_price가 0/None으로 복원되면 entry_price 기반
# 기본값으로 대체 — day_trading_strategy.py의 target_price<=0 즉시매도 방지
if entry_price > 0 and stop_loss <= 0:
stop_loss = entry_price * (1 - settings.DAY_TRADE_STOP_LOSS_PCT)
logger.warning(
f"[DayTrading] {trade.stock_code} stop_loss 복원값 없음 → "
f"기본값 적용: {stop_loss:.0f}"
)
if entry_price > 0 and target_price <= 0:
risk = entry_price - stop_loss
target_price = entry_price + risk * settings.DAY_TRADE_TARGET_MULTIPLIER
logger.warning(
f"[DayTrading] {trade.stock_code} target_price 복원값 없음 → "
f"기본값 적용: {target_price:.0f}"
)
self.positions[trade.stock_code] = {
"stock_name": trade.stock_name or trade.stock_code,
"strategy": trade.strategy_name or "day_trade",
"entry_price": float(trade.entry_price) if trade.entry_price else 0,
"stop_loss": float(trade.stop_loss) if trade.stop_loss else 0,
"target_price": float(trade.take_profit) if trade.take_profit else 0,
"entry_price": entry_price,
"stop_loss": stop_loss,
"target_price": target_price,
"quantity": trade.quantity or 0,
"highest_price": float(trade.highest_since_entry) if trade.highest_since_entry else (
float(trade.entry_price) if trade.entry_price else 0

View File

@@ -462,12 +462,14 @@ async def db_writer_worker() -> None:
else:
# remaining_pct_multiply: 기존 remaining_pct에 비율을 곱함
# (MDD 축소 시 기존 분할매도 이력 반영)
multiply_ratio = data.pop("remaining_pct_multiply", None)
# data.get() 사용 — pop()으로 task dict를 파괴적으로 변경하면
# 재시도 시 이 키가 사라져 no-op으로 성공 처리되는 버그 방지
multiply_ratio = data.get("remaining_pct_multiply")
if multiply_ratio is not None:
current_remaining = getattr(record, "remaining_pct", 1.0) or 1.0
record.remaining_pct = current_remaining * float(multiply_ratio)
record.remaining_pct = float(current_remaining) * float(multiply_ratio)
for key, val in data.items():
if key != "stock_code" and val is not None:
if key not in ("stock_code", "remaining_pct_multiply") and val is not None:
setattr(record, key, val)
await session.commit()
@@ -525,6 +527,10 @@ async def db_writer_worker() -> None:
while not queue.empty():
try:
drain_task = queue.get_nowait()
except asyncio.QueueEmpty:
# get_nowait()이 실패해 아이템을 꺼내지 못했으므로 task_done() 호출 금지
break
try:
drain_type = drain_task.get("type", "unknown")
drain_data = drain_task.get("data")
async with AsyncSessionLocal() as drain_session:
@@ -548,8 +554,6 @@ async def db_writer_worker() -> None:
else:
await _save_to_dlq(drain_task, "shutdown_drain_unknown_type")
remaining += 1
except asyncio.QueueEmpty:
break
except Exception as drain_err:
logger.error(f"[DBWorker] Drain 실패: {drain_err}")
try:

View File

@@ -214,18 +214,20 @@ def _parse_realtime_message(raw: str) -> Optional[dict]:
_enc, tr_id, _cnt, body = parts[0], parts[1], parts[2], parts[3]
fields = body.split("^")
if tr_id == TR_TICK and len(fields) >= 10:
if tr_id == TR_TICK and len(fields) >= 15:
# H0STCNT0 필드 순서 (KIS 개발가이드 기준)
# [0]=종목코드, [1]=체결시간, [2]=현재가, [4]=전일대비율,
# [7]=누적거래량, [9]=누적거래대금
# [0]=종목코드 [1]=체결시간 [2]=현재가 [3]=전일대비부호 [4]=전일대비(원) [5]=전일대비율(%)
# [6]=가중평균가 [7]=시가 [8]=고가 [9]=저가 [10]=매도호가1 [11]=매수호가1
# [12]=체결거래량 [13]=누적거래량 [14]=누적거래대금
# (2026-09-18 수정: 기존 [4]/[7]/[9] 는 전일대비원/시가/저가를 잘못 읽고 있었음)
return {
"_type": "tick",
"tr_id": tr_id,
"stock_code": fields[0],
"price": _safe_float(fields[2]),
"change_rate": _safe_float(fields[4]),
"volume": _safe_int(fields[7]),
"trade_amount":_safe_int(fields[9]),
"change_rate": _safe_float(fields[5]),
"volume": _safe_int(fields[13]),
"trade_amount":_safe_int(fields[14]),
"timestamp": fields[1] if len(fields) > 1 else "",
}

View File

@@ -199,7 +199,8 @@ class KoreaInvestmentAPI:
instance._http_client = None
instance._http_client_lock = asyncio.Lock()
instance.TOKEN_CACHE_FILE = Path.home() / ".cache" / "auto-stock" / "kis_token_cache_real.json"
instance.min_request_interval = settings.API_MIN_REQUEST_INTERVAL
# 실서버는 초당 20건 허용 — 모의서버용 간격(API_MIN_REQUEST_INTERVAL)과 분리
instance.min_request_interval = getattr(settings, "KIS_REAL_MIN_REQUEST_INTERVAL", 0.1)
instance.max_retries = settings.API_MAX_RETRIES
instance.retry_delay = settings.API_RETRY_DELAY
instance.retry_backoff = settings.API_RETRY_BACKOFF
@@ -549,8 +550,24 @@ class KoreaInvestmentAPI:
raise last_exception
raise Exception("API 요청 실패 (알 수 없는 오류)")
# ── 시세 조회 실서버 라우팅 ──────────────────────────────────────────────
# 모의투자 서버(openapivts)는 앱키당 초당 2건 제한 + 응답 지연이 잦아(EGW00201)
# 시세·차트·호가 조회를 감당하지 못한다. KIS_QUOTES_VIA_REAL=True 이고 실계좌
# 시세 인스턴스(real_market_api)가 있으면 조회성 호출만 실서버로 위임한다.
# 주문·잔고는 그대로 모의서버. (real_market_api 자신은 위임하지 않음 → 재귀 방지)
def _quote_delegate(self) -> "KoreaInvestmentAPI | None":
if not getattr(settings, "KIS_QUOTES_VIA_REAL", False):
return None
_real = globals().get("real_market_api")
if _real is None or _real is self:
return None
return _real
async def get_current_price(self, stock_code: str) -> Dict[str, Any]:
"""현재가 조회"""
_d = self._quote_delegate()
if _d is not None:
return await _d.get_current_price(stock_code)
try:
result, _ = await self._make_request(
"GET",
@@ -572,6 +589,9 @@ class KoreaInvestmentAPI:
캐시 키: chart:daily:{stock_code}:{period}:{limit}
TTL: 5분 (장중 업데이트 주기 고려)
"""
_d = self._quote_delegate()
if _d is not None:
return await _d.get_daily_chart(stock_code, period, limit)
cache_key = f"chart:daily:{stock_code}:{period}:{limit}"
# ── 1. 캐시 히트 확인 ───────────────────────────────────────────────
@@ -771,6 +791,9 @@ class KoreaInvestmentAPI:
]
}
"""
_d = self._quote_delegate()
if _d is not None:
return await _d.get_minute_chart(stock_code, ref_time, include_past)
try:
if not ref_time:
from datetime import datetime
@@ -829,6 +852,9 @@ class KoreaInvestmentAPI:
Returns:
List[dict] — output2 레코드 목록 (오래된 것 → 최신 순)
"""
_d = self._quote_delegate()
if _d is not None:
return await _d.get_minute_chart_full_day(stock_code, market_open)
from datetime import datetime
import pytz
kst = pytz.timezone("Asia/Seoul")
@@ -1244,6 +1270,9 @@ class KoreaInvestmentAPI:
async def get_orderbook(self, stock_code: str) -> Dict[str, Any]:
"""호가 정보 조회 (매수/매도 호가 10단계)"""
_d = self._quote_delegate()
if _d is not None:
return await _d.get_orderbook(stock_code)
try:
result, _ = await self._make_request(
"GET",

View File

@@ -118,8 +118,10 @@ async def check_market_regime() -> dict:
chart_data = None
try:
from app.services.naver_finance import get_naver_daily_chart
naver_data = await get_naver_daily_chart("069500", days=240) # oldest-first
if naver_data and len(naver_data) >= 60:
# MA200+기울기(5봉) 판별에 205봉 이상 필요 — 캘린더일 기준이므로 400일 요청(≈270거래일)
naver_data = await get_naver_daily_chart("069500", days=400) # oldest-first
# 205봉 미만이면 MA200/SIDEWAYS 판별 불가 → KIS 폴백(240봉 페이지네이션)으로 넘김
if naver_data and len(naver_data) >= 205:
chart_data = list(reversed(naver_data)) # → newest-first
except Exception:
pass
@@ -128,14 +130,17 @@ async def check_market_regime() -> dict:
_api = _real_market_api or korea_investment_api
kospi_chart = await _api.get_daily_chart("069500", "D", limit=240)
if "output2" not in kospi_chart or not kospi_chart["output2"]:
return {"regime": "neutral", "message": "시장 지수 확인 불가", "position_multiplier": 0.5}
# 국면 판별 불가 시 신규 진입 차단 (기존 0.5배 매매 지속 → 무국면 매매가 실손실 원인)
return {"regime": "neutral", "regime_200d": "bear", "message": "시장 지수 확인 불가 — 신규 진입 차단",
"position_multiplier": 0.0, "min_score": 9999, "max_trades": 0}
chart_data = kospi_chart["output2"] # newest-first
# closes: newest-first (closes[0]=최신, closes[-1]=가장 오래된)
closes = [float(item.get("stck_clpr", 0)) for item in chart_data]
if len(closes) < 60:
return {"regime": "neutral", "message": "데이터 부족", "position_multiplier": 0.5}
return {"regime": "neutral", "regime_200d": "bear", "message": "데이터 부족 — 신규 진입 차단",
"position_multiplier": 0.0, "min_score": 9999, "max_trades": 0}
def _cache_and_return(result: dict) -> dict:
_market_regime_cache["result"] = result
@@ -173,9 +178,17 @@ async def check_market_regime() -> dict:
is_sideways_200 = regime_enum == MarketRegime.SIDEWAYS
ma200 = sum(closes[:200]) / 200
else:
is_bull_200 = current_price > ma20
is_sideways_200 = False
ma200 = ma60
# 205봉 미만 → MA200/ADX 국면 판별 불가. 기존에는 MA20 프록시로 상승장 판정 후
# 매매를 지속했으나(실계좌 손실 원인), 판별 불가 시 신규 진입을 차단한다.
logger.warning(f"국면 판별 불가: 일봉 {len(closes)}개 < 205 — 신규 진입 차단")
return _cache_and_return({
"regime": "neutral",
"regime_200d": "bear",
"message": f"국면 판별 불가 (일봉 {len(closes)}개 < 205) — 신규 진입 차단",
"position_multiplier": 0.0,
"min_score": 9999,
"max_trades": 0,
})
# ── HMM 앙상블 (optional) ────────────────────────────────────
# 규칙 기반 결과를 먼저 결정한 뒤, HMM 결과와 앙상블

View File

@@ -10,7 +10,7 @@ KIS API 호출량을 절감한다.
- market_analyzer.get_all_tradable_stocks() 대체
- market_analyzer.analyze_stock() 배치 분석용 일봉 데이터 제공
URL (종목 목록): https://finance.naver.com/sise/sise_market_sum.naver?sosok=0&page=N
URL (종목 목록): https://m.stock.naver.com/api/stocks/marketValue/{KOSPI|KOSDAQ}?page=N&pageSize=50
URL (일봉 차트): https://fchart.stock.naver.com/siseJson.naver?symbol=CODE&requestType=1&startTime=YYYYMMDD&endTime=YYYYMMDD&timeframe=day
"""
@@ -65,16 +65,43 @@ def _parse_page(html: bytes) -> List[Tuple[str, str, int]]:
return result
_LIST_API_URL = "https://m.stock.naver.com/api/stocks/marketValue/{market}"
_LIST_PAGE_SIZE = 50 # 구 HTML 페이지(~50종목/페이지)와 동일한 페이지 의미 유지
_SOSOK_TO_MARKET = {0: "KOSPI", 1: "KOSDAQ"}
def _parse_json_page(data: Dict) -> List[Tuple[str, str, int]]:
"""m.stock.naver.com JSON → [(종목코드, 종목명, 현재가), ...]"""
result = []
for item in data.get("stocks") or []:
code = item.get("itemCode") or ""
name = (item.get("stockName") or "").strip()
if not code or not name:
continue
try:
price = int(str(item.get("closePrice") or "0").replace(",", ""))
except ValueError:
price = 0
result.append((code, name, price))
return result
async def _fetch_page(client: httpx.AsyncClient, sosok: int, page: int) -> List[Tuple[str, str, int]]:
"""시가총액 순위 1페이지 조회.
2026-09 네이버가 finance.naver.com/sise/sise_market_sum.naver(HTML)를 폐기하고
stock.naver.com으로 302 리다이렉트 → m.stock.naver.com JSON API로 교체.
"""
market = _SOSOK_TO_MARKET.get(sosok, "KOSPI")
try:
resp = await client.get(
_BASE_URL,
params={"sosok": sosok, "page": page},
_LIST_API_URL.format(market=market),
params={"page": page, "pageSize": _LIST_PAGE_SIZE},
headers=_HEADERS,
timeout=10,
)
resp.raise_for_status()
return _parse_page(resp.content)
return _parse_json_page(resp.json())
except Exception as exc:
logger.warning("네이버 파이낸스 조회 실패 sosok=%d page=%d: %s", sosok, page, exc)
return []

View File

@@ -115,7 +115,8 @@ async def execute_swing_entries(recommendations_response: dict, db: AsyncSession
# KODEX200(069500) 일봉 종가 < MA20 → 신규 스윙 진입 전면 중단 (기존 보유 청산/관리는 정상 동작).
# check_market_regime()이 이미 계산한 drop_from_ma20(=(현재가-MA20)/MA20*100)을 재사용하여
# 지수 데이터 중복 조회를 피한다.
if isinstance(regime, dict):
# 2026-09-17: 사용자 결정으로 기본 OFF (SWING_INDEX_BREAKER_ENABLED=true 로 재활성화 가능).
if settings.SWING_INDEX_BREAKER_ENABLED and isinstance(regime, dict):
_drop_from_ma20 = regime.get("drop_from_ma20")
if _drop_from_ma20 is not None and float(_drop_from_ma20) < 0:
logger.info(

View File

@@ -1220,6 +1220,11 @@ async def _execute_sell(
await _release_order_lock(stock_code, "SELL")
# execute_auto_trade_background 동시 실행 방지 — 스윙 스케줄러·API 트리거가 겹치면
# 일일 거래 한도(MAX_DAILY_TRADES)가 호출 단위로만 적용돼 합산 초과됨 (2026-09-17 11종목 동시 매수 사고).
_auto_trade_bg_lock = asyncio.Lock()
async def execute_auto_trade_background(recommendations_response: dict, db: AsyncSession):
"""recommendations_response dict 에서 추천 종목을 꺼내 자동매매 실행.
@@ -1234,6 +1239,14 @@ async def execute_auto_trade_background(recommendations_response: dict, db: Asyn
logger.error("[AUTO_TRADE_BG] recommendations_response is not a dict: %r", recommendations_response)
return
if _auto_trade_bg_lock.locked():
logger.info("[AUTO_TRADE_BG] 이전 실행이 진행 중 — 이번 호출 스킵 (중복 실행 방지)")
return
async with _auto_trade_bg_lock:
await _execute_auto_trade_background_locked(recommendations_response, db)
async def _execute_auto_trade_background_locked(recommendations_response: dict, db: AsyncSession):
try:
total_processed = 0
trade_results = []
@@ -1245,7 +1258,31 @@ async def execute_auto_trade_background(recommendations_response: dict, db: Asyn
regime_max_trades = market_regime.get("max_trades", 20) if market_regime else 20
position_multiplier = market_regime.get("position_multiplier", 1.0) if market_regime else 1.0
max_trades = min(20, regime_max_trades)
max_trades = min(settings.MAX_DAILY_TRADES, regime_max_trades)
# 일일 한도는 "오늘 이미 나간 BUY 종목 수"를 차감한 전역 기준으로 적용
# (호출 단위 슬라이스만으로는 여러 사이클에 걸쳐 한도 초과됨)
try:
_today_buys = await db.execute(
select(TradeHistory.stock_code)
.where(
TradeHistory.order_type == "BUY",
TradeHistory.status.in_(["COMPLETED", "PENDING"]),
TradeHistory.created_at >= get_daily_cutoff_utc(),
)
.distinct()
)
_bought_today = len(_today_buys.scalars().all())
except Exception as _e:
logger.warning(f"[AUTO_TRADE_BG] 당일 매수 건수 조회 실패 (한도 미차감): {_e}")
_bought_today = 0
max_trades = max(0, max_trades - _bought_today)
if max_trades == 0:
logger.info(
f"[AUTO_TRADE_BG] 일일 매수 한도 도달 (오늘 {_bought_today}종목 / 한도 "
f"{min(settings.MAX_DAILY_TRADES, regime_max_trades)}) — 신규 진입 스킵"
)
return
result = await db.execute(select(Portfolio).where(Portfolio.quantity > 0))
held_stocks = {p.stock_code for p in result.scalars().all()} # 현재 보유 종목 집합