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:
113
backend/_sweep_single_axis.py
Normal file
113
backend/_sweep_single_axis.py
Normal 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()
|
||||
@@ -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)],
|
||||
|
||||
183
backend/app/api/endpoints/manual_buy.py
Normal file
183
backend/app/api/endpoints/manual_buy.py
Normal 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,
|
||||
}
|
||||
@@ -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}")
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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'")
|
||||
|
||||
@@ -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 # 규칙당 자동매매 실행 최대 종목 수
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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 "",
|
||||
}
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 결과와 앙상블
|
||||
|
||||
@@ -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 []
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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()} # 현재 보유 종목 집합
|
||||
|
||||
Reference in New Issue
Block a user