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

@@ -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 결과와 앙상블