fix: 프로그램 검증 루프 7회차 — 리스크·주문·청산·인프라 결함 일괄 수정 + 거래 확대 결정 반영

하네스 규칙 검사 전역 위반 117→0, 회차별 검증(code-verifier opus 4도메인)으로 Critical/High 해소.

주문·리스크
- MDD 강제축소 반복 매도(종목별 완료 집합·재시도 상한·날짜 롤오버·잔고 오류 보류·epoch·장외 미주문)
- KIS 거부 FAILED 기록, 결과 불명(전송 후) 재전송 금지·PENDING, 미전송 실패만 재시도(msg_cd 분류)
- 국내 전량·손실 매도 시장가, 부분 익절 직전 현재가, 지정가만 미체결 취소(cancel_order_v2: TR 0013U·조직번호) + 0체결 롤백
- 누적 4%·현금·레버리지(신규분 포함)·사이클 내 매수 누적, 수동매수 공용 가드(buy_guards), 수동매도 DT 차감·락
- 가드 우회 엔드포인트 차단(/recommendations/auto-trade 위임, US /execute 매수 410·매도 가드)
- 매수 슬롯=성공 매수, 평가 상한·회전, 업무거부/계좌 쿨다운, 프로세스 내 in-flight 주문 중복 차단
데이트레이딩
- 진입·청산 shield, 청산 진행 중 표식(소유 토큰), 2단계 청산 체크, 결과 불명 → 보유 감소 판정·비DT 차감 상한
- 전일 포지션 재시도(일시 실패 미산정), 분할매도 영속, ORB 구간 손절, /cycle 락, 사이클 내 현금 차감, 체결 수량 보정
스윙
- 손절 공백(판정 예외 시 하드 바닥), 단일 손절 소스·범위 검증, 매도 직전 KIS 잔고 상한(min(주문가능,보유)−DT)
- 결과 불명 체결 대조·장부 반영, 대체가(나이·진입 이후·ATR 캐시), 분할 단계 기록, 국면 실패 fail-closed
- 듀얼 플레이북·서킷브레이커 게이트, 평균회귀 우선·듀얼 독립, 시간손절 거래일
- 거래 확대(사용자 결정, 백테스트 미검증): RSI 46/35 정본 경로, 모멘텀 v2 ON, SIDEWAYS ADX AND, 트레일링 1차 익절 후, 20일 시간손절 ON
인프라·보안
- 계좌·손익 GET 인증, 세션 is_active·관리자 경로, 서명키 파생, realip, WS 토큰 마스킹
- KIS 토큰 즉시 실패(sleep 제거)·무효 시 캐시 삭제, 주문 5xx+JSON 본문 반환
- db_worker 종료 drain(멱등 재실행·커밋 불명 격리·예산·밴 커밋), stop_grace 40s, Redis 타임아웃·volatile-lru, /health DLQ·캐시
- KRX 휴장일, py3.11 비호환 f-string 수정 + Jenkins 이미지 내 compileall·헬스 실패 시 빌드 실패
하네스: rules_check 에 PEP 701 f-string 검사, 마커(allow-*), deploy 스모크 401 기준
CLAUDE.md: 실제 동작·플래그·백로그(§8.5-27~38) 갱신

backend 1451 passed / hooks 112 passed / tsc ok / py3.9 compile 0 errors

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-02 12:01:31 +09:00
parent 0f89a9d678
commit 9fab4018d4
99 changed files with 15712 additions and 1718 deletions

View File

@@ -21,13 +21,14 @@ import pytz
from fastapi import Depends
from app.core.config import settings
from app.core.security import require_api_key as _require_admin_key
from app.core.security import require_admin_v2 as _require_admin_v2
from app.api import api_router
from app.services.websocket_manager import websocket_manager
from app.core.json_helpers import _dumps
from app.services.db_worker import db_writer_worker
from app.core.database import engine, Base, AsyncSessionLocal
from app.models import models # 모델 임포트
from app.core.market_schedule import is_market_open
from app.core.market_schedule import is_market_open, is_krx_holiday
# 로깅 설정
import os
@@ -51,8 +52,80 @@ for _h in _handlers:
logging.basicConfig(level=_log_level, handlers=_handlers)
# [M-6 2026-10-02] WebSocket 인증 토큰(?token=)이 uvicorn 접속 로그에 평문으로 남지 않게 마스킹
from app.core.logging_config import install_query_token_redaction # noqa: E402
install_query_token_redaction()
from app.core.log_throttle import warn_throttled # noqa: E402
logger = logging.getLogger(__name__)
# [L-3 2026-10-02] fire-and-forget 태스크 강참조 보관 — 이벤트 루프는 태스크를 약참조로만
# 들고 있어 참조를 버리면 실행 중에 GC 로 사라질 수 있다(asyncio 문서 경고).
_background_tasks: set[asyncio.Task] = set()
def _spawn_background(coro) -> asyncio.Task:
task = asyncio.create_task(coro)
_background_tasks.add(task)
task.add_done_callback(_background_tasks.discard)
return task
async def _reload_bans_after_replay() -> None:
"""replay_dlq 가 밴 upsert 를 재큐잉했으면 큐 소진을 기다린 뒤 loss_tracker 를 다시 로드."""
try:
from app.services.db_worker import get_db_task_queue, last_replay_requeued_types
if not ({"upsert_stock_ban", "upsert_stock_ban_v2"} & last_replay_requeued_types):
return
try:
await asyncio.wait_for(
get_db_task_queue().join(), timeout=float(settings.DLQ_REPLAY_SETTLE_SECONDS)
)
except asyncio.TimeoutError:
logger.warning(
f"[DLQ] 재큐잉된 밴 커밋 대기 {settings.DLQ_REPLAY_SETTLE_SECONDS}초 초과 — 커밋된 것만 재로드"
)
from app.services.loss_tracker import loss_tracker
async with AsyncSessionLocal() as session: # 기동 시 1회 SELECT (lifespan, 실시간 계층 아님)
await loss_tracker.load_from_db(session)
logger.warning("[DLQ] 재처리된 연패 밴 반영 — loss_tracker 재로드 완료")
except Exception as e:
logger.error(f"❌ [DLQ] 밴 재로드 실패 — 재처리된 밴이 재시작 전까지 인메모리에 없을 수 있음: {e}")
def check_shutdown_budget() -> list[str]:
"""[L-3] 종료 예산(uvicorn 정리 + 백그라운드 대기 + drain) 이 컨테이너 종료 유예를 넘는가."""
from app.services.db_worker import DRAIN_DEADLINE_SECONDS
total = (
int(settings.UVICORN_GRACEFUL_SHUTDOWN_SECONDS)
+ int(settings.SHUTDOWN_BACKGROUND_DRAIN_SECONDS)
+ int(DRAIN_DEADLINE_SECONDS)
)
if total >= int(settings.CONTAINER_STOP_GRACE_SECONDS):
return [
f"종료 예산 {total}초(uvicorn {settings.UVICORN_GRACEFUL_SHUTDOWN_SECONDS} + 백그라운드 "
f"{settings.SHUTDOWN_BACKGROUND_DRAIN_SECONDS} + drain {DRAIN_DEADLINE_SECONDS}) ≥ 종료 유예 "
f"{settings.CONTAINER_STOP_GRACE_SECONDS}초 — SIGKILL 로 drain 중 쓰기 유실 가능. "
f"docker-compose stop_grace_period 와 CONTAINER_STOP_GRACE_SECONDS 를 함께 올릴 것"
]
return []
async def _await_background_tasks(timeout: float) -> None:
"""종료 시 `_background_tasks` 를 최대 timeout 초 기다리고, 남은 것은 cancel."""
pending = [t for t in _background_tasks if not t.done()]
if not pending:
return
done, still = await asyncio.wait(pending, timeout=max(0.0, timeout))
if still:
logger.warning(f"[shutdown] 백그라운드 태스크 {len(still)}건이 {timeout:.0f}초 내 미완료 — cancel")
for t in still:
t.cancel()
await asyncio.gather(*still, return_exceptions=True)
# Import scheduler functions from dedicated module
from app.services.schedulers import (
batch_analysis_scheduler,
@@ -78,6 +151,19 @@ async def lifespan(app: FastAPI):
for warn_msg in settings.validate_required():
logger.warning(f"⚠️ [Config] {warn_msg}")
# ── [M-5 2026-10-02] 키 강도 점검 — 기본은 error 로그 + /health "security":"warn" ──
# SECURITY_FAIL_CLOSED=True 이고 DEBUG=False 면 기동을 중단한다(운영 .env 확인 후 켤 것).
from app.core.security import security_key_issues
_key_issues = security_key_issues()
for _issue in _key_issues:
logger.error(f"❌ [Security] {_issue}")
for _budget_issue in check_shutdown_budget():
logger.error(f"❌ [Shutdown] {_budget_issue}")
if _key_issues and settings.SECURITY_FAIL_CLOSED and not settings.DEBUG:
raise RuntimeError(
"SECURITY_FAIL_CLOSED=True: 약한/기본 키로 기동할 수 없습니다 — " + "; ".join(_key_issues)
)
# DB 테이블 자동 생성
# 컬럼 추가/테이블 생성 DDL 은 Alembic migration 으로 관리됨.
# 배포 전 `cd backend && alembic upgrade head` 를 실행할 것.
@@ -139,15 +225,22 @@ async def lifespan(app: FastAPI):
if result.rowcount > 0:
logger.info(f"🗑️ Deleted {result.rowcount} old stock_analysis records")
# [M-1 2026-10-02] PENDING 은 삭제하지 않는다. KIS 주문접수 후 체결 확인이
# 실패한 BUY 는 영구 PENDING 으로 남는데, 그 행이 유입잔고 강등(swing→external)
# 방어의 근거다(portfolio.py `_NON_FILL_BUY_STATUSES` 주석). 지우면 재기동마다
# 정상 보유 포지션이 강등 후보가 된다. 당일 판정 쿼리는 모두 created_at>=오늘
# 조건이라 오래된 PENDING 이 남아도 매매 판정에 영향이 없다.
# FAILED(주문 거부, 주식 미보유)만 1일 경과분을 정리한다 — 기동 시 1회의
# 부트스트랩 정리이며 실시간 계층이 아니다.
cutoff_datetime = _today_kst - timedelta(days=1)
result = await session.execute(
delete(TradeHistory).where(
TradeHistory.status.in_(['PENDING', 'FAILED']),
TradeHistory.status == 'FAILED',
TradeHistory.created_at < cutoff_datetime,
)
)
if result.rowcount > 0:
logger.info(f"🗑️ Deleted {result.rowcount} old PENDING/FAILED trades")
logger.info(f"🗑️ Deleted {result.rowcount} old FAILED trades")
result = await session.execute(
delete(Portfolio).where(Portfolio.quantity <= 0)
@@ -207,7 +300,7 @@ async def lifespan(app: FastAPI):
logger.info("✅ [KRX] 초기 데이터 수집 완료")
except Exception as e:
logger.warning(f"⚠️ [KRX] 초기 데이터 수집 실패 (non-critical): {e}")
asyncio.create_task(_initial_krx_collect())
_spawn_background(_initial_krx_collect())
logger.info("📥 [KRX] stock_daily_prices 비어있음 — 백그라운드 초기 수집 예약됨")
elif _total == 0:
logger.warning("⚠️ [KRX] stock_daily_prices 비어있으나 DATAGOKR_API_KEY 미설정 — 네이버 차트 폴백 사용")
@@ -251,8 +344,8 @@ async def lifespan(app: FastAPI):
category="DB_WORKER",
message=f"db_worker 크래시 후 자동 재시작: {type(exc).__name__}: {exc}",
))
except Exception:
pass
except Exception as _swallowed:
logger.warning("[main] db_worker 재시작 텔레그램 알림 실패: %s", _swallowed)
new_task = asyncio.create_task(db_writer_worker())
new_task.add_done_callback(_restart_db_worker)
app.state.db_worker_task = new_task
@@ -271,6 +364,28 @@ async def lifespan(app: FastAPI):
except Exception as exc:
logger.error(f"DLQ 재처리 실패: {exc}", exc_info=True)
# [M-3 2026-10-02] loss_tracker.load_from_db 는 위(기동 초반)에서 이미 끝났다. replay 로 되살린
# 밴은 그 뒤에 커밋되므로 인메모리에 없다 → 커밋을 기다렸다가 다시 읽는다(42일 밴 유실 방지).
await _reload_bans_after_replay()
# [L1 2026-10-02] 사람이 봐야 하는 DLQ(manual/) 가 있으면 텔레그램 시스템 경고 — 기동 로그만으로는 묻힌다
try:
from app.services.db_worker import dlq_status_counts
_dlq_counts = await dlq_status_counts()
if _dlq_counts.get("manual", 0) > 0:
logger.error(f"❌ DLQ 수동 확인 대기 {_dlq_counts['manual']}건 (~/.cache/auto-stock/dlq/manual)")
from app.services.telegram_notifier import telegram_notifier
_spawn_background(telegram_notifier.notify_system_warning(
category="DLQ",
message=(
f"DB 쓰기 수동 확인 대기 {_dlq_counts['manual']}건 "
f"(커밋 여부 불명 — 중복 확인 후 처리 필요). 대기 {_dlq_counts['pending']}건, "
f"격리 {_dlq_counts['expired']}건."
),
))
except Exception as exc:
logger.warning(f"DLQ 건수 확인/알림 실패: {exc}")
# ── 스윙 / 데이트레이딩 분리 스케줄러 (신규 아키텍처) ────────────────────
from app.services.swing.scheduler import swing_scheduler
from app.services.daytrading.scheduler import daytrading_scheduler
@@ -332,8 +447,8 @@ async def lifespan(app: FastAPI):
today = now.date()
hm = now.hour * 100 + now.minute # HHMM 형식
# 평일만 (월~금)
if now.weekday() >= 5:
# 평일 + KRX 개장일만 (공휴일·연말 휴장·근로자의날 제외 — L-4)
if now.weekday() >= 5 or is_krx_holiday(today):
await asyncio.sleep(300)
continue
@@ -373,7 +488,7 @@ async def lifespan(app: FastAPI):
logger.warning(f"🚨 [AI Strategy] 뉴스 악재 감지: {len(alerts)}건")
_last_news_check = now
except Exception as e:
logger.debug(f"[AI Strategy] 뉴스 모니터링 실패: {e}")
warn_throttled(logger, "main:376", f"[AI Strategy] 뉴스 모니터링 실패: {e}")
# 15:40 — 장 후 리뷰
if 1540 <= hm < 1600 and _post_market_done_today != today:
@@ -420,6 +535,9 @@ async def lifespan(app: FastAPI):
yield
# 태스크 정리
# [H-5 2026-10-02] db_worker 는 **마지막에** 멈춘다. 이전에는 생산자(us_trade·news·
# adaptive·ai_strategy 등)보다 먼저 cancel 돼, 그 뒤 생산자가 종료 과정에서 넣은 쓰기가
# drain 되지 못하고 유실됐다. 생산자를 모두 멈춘 뒤 워커가 큐를 비우게 한다.
for task_attr in (
"swing_trade_task",
"batch_analysis_task",
@@ -427,7 +545,6 @@ async def lifespan(app: FastAPI):
"krx_collector_task",
"post_market_task",
"journal_intraday_task",
"db_worker_task",
"kis_stream_task",
"us_trade_task",
"news_collect_task",
@@ -435,7 +552,15 @@ async def lifespan(app: FastAPI):
"pre_market_us_task",
"adaptive_strategy_task",
"ai_strategy_task",
"__background__",
"db_worker_task",
):
if task_attr == "__background__":
# [M-D 2026-10-02] 워커를 멈추기 전에 fire-and-forget 태스크가 enqueue 를 끝낼
# 시간을 준다(상한 SHUTDOWN_BACKGROUND_DRAIN_SECONDS). 넘기면 cancel —
# 그 뒤 늦게 들어오는 enqueue 는 db_worker 가 DLQ 로 직접 보관한다.
await _await_background_tasks(float(settings.SHUTDOWN_BACKGROUND_DRAIN_SECONDS))
continue
try:
task = getattr(app.state, task_attr, None)
if task:
@@ -500,7 +625,7 @@ app.add_middleware(
CORSMiddleware,
allow_origins=_allowed_origins,
allow_credentials=True,
allow_methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"],
allow_methods=["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"], # PATCH: strategy-config toggle
allow_headers=["Content-Type", "Authorization", "X-API-Key", "X-Requested-With"],
expose_headers=["X-Request-Id"],
)
@@ -520,7 +645,10 @@ _rate_limit_counter = 0 # cleanup 트리거 카운터
async def rate_limit_middleware(request: Request, call_next):
"""IP 기반 간이 rate limiter — 인증 없는 엔드포인트 DoS 방어."""
global _rate_limit_counter
client_ip = request.client.host if request.client else "unknown"
# [M-2 2026-10-02] 소켓 주소는 항상 nginx 컨테이너라 전 사용자가 한 버킷을 공유했다.
# 신뢰 프록시가 덮어쓴 X-Real-IP 기준으로 센다.
from app.core.security import client_ip_v2
client_ip = client_ip_v2(request)
now = time.time()
# /health, /ws/, OPTIONS(CORS preflight) 는 rate limit 제외
@@ -602,9 +730,32 @@ async def root():
return {"message": "Auto Stock Trading System API", "version": "1.0.0", "status": "running"}
_health_cache: dict = {"at": 0.0, "value": None}
_health_lock = asyncio.Lock()
@app.get("/health")
async def health_check():
"""DB / Redis 연결 상태를 포함한 헬스체크."""
"""DB / Redis 연결 상태를 포함한 헬스체크.
[L-4 2026-10-02] 결과를 HEALTH_CACHE_SECONDS 동안 캐시하고 동시 요청은 한 번만 계산한다 —
공개 엔드포인트라 폴링·스캔이 몰리면 매 요청 DB 커넥션을 잡아 매매 경로와 풀을 경쟁했다.
"""
ttl = float(settings.HEALTH_CACHE_SECONDS)
cached = _health_cache["value"]
if cached is not None and (time.monotonic() - _health_cache["at"]) < ttl:
return cached
async with _health_lock:
cached = _health_cache["value"]
if cached is not None and (time.monotonic() - _health_cache["at"]) < ttl:
return cached
value = await _health_check_impl()
_health_cache["value"] = value
_health_cache["at"] = time.monotonic()
return value
async def _health_check_impl():
from sqlalchemy import text as _sa_text
from app.core.redis import get_redis
@@ -615,22 +766,34 @@ async def health_check():
async with engine.connect() as conn:
await conn.execute(_sa_text("SELECT 1"))
db_ok = True
except Exception:
pass
except Exception as _swallowed:
warn_throttled(logger, "main:618", "[health] DB 연결 확인 실패: %s", _swallowed)
try:
r = get_redis()
if r:
await r.ping()
redis_ok = True
except Exception:
pass
except Exception as _swallowed:
warn_throttled(logger, "main:626", "[health] Redis ping 실패: %s", _swallowed)
status = "healthy" if (db_ok and redis_ok) else "degraded"
# [M-5] 키 설정 경고 — 상세 사유는 공개하지 않는다(기동 로그 error 참조)
from app.core.security import security_key_issues
# [L1] DLQ 건수만(내용 비공개). manual = 사람이 확인해야 하는 커밋 여부 불명 쓰기
try:
from app.services.db_worker import dlq_status_counts
_dlq = await dlq_status_counts()
dlq_info = {"pending": _dlq["pending"], "manual": _dlq["manual"]}
except Exception as _swallowed:
warn_throttled(logger, "main:health_dlq", "[health] DLQ 건수 확인 실패: %s", _swallowed)
dlq_info = {"pending": -1, "manual": -1}
return {
"status": status,
"db": "ok" if db_ok else "error",
"redis": "ok" if redis_ok else "error",
"security": "warn" if security_key_issues() else "ok",
"dlq": dlq_info,
}
@@ -652,9 +815,17 @@ async def websocket_endpoint(websocket: WebSocket, client_id: str):
"""
# WebSocket 인증: 운영 환경에서는 토큰 검증 (API_KEY/SECRET_KEY 또는 로그인 세션 토큰 허용)
if not settings.DEBUG:
from app.core.security import is_valid_credential
from app.core.security import is_valid_credential_v2
token = websocket.query_params.get("token", "")
if not token or not is_valid_credential(token):
# [H-2] 세션 사용자의 is_active 까지 확인 (revoke 후 재연결 차단)
# DB-READ-EXCEPTION: 핸드셰이크 1회, 캐시 미스일 때만 users SELECT(commit 없음) —
# security._fetch_session_user_flags_impl. 연결 후 메시지 루프에서는 DB 를 읽지 않는다.
try:
_ws_ok = bool(token) and await is_valid_credential_v2(token)
except Exception as _ws_auth_err: # 사용자 상태 조회 불가(503) — 접속 거부(재연결로 회복)
logger.warning(f"[WS] 인증 상태 확인 실패 — 접속 거부: {_ws_auth_err}")
_ws_ok = False
if not _ws_ok:
await websocket.close(code=4001, reason="Unauthorized")
return
@@ -673,8 +844,8 @@ async def websocket_endpoint(websocket: WebSocket, client_id: str):
kis_stream.notify_subscription_change()
logger.info(f"[auto-sub] {cid}: 보유종목 {len(codes)}개 자동 구독 → {codes[:5]}...")
except Exception as e:
logger.debug(f"[auto-sub] 보유종목 자동 구독 실패: {e}")
asyncio.create_task(_auto_subscribe_portfolio(client_id))
logger.warning(f"[auto-sub] 보유종목 자동 구독 실패: {e}")
_spawn_background(_auto_subscribe_portfolio(client_id))
try:
while True:
@@ -764,8 +935,8 @@ async def websocket_endpoint(websocket: WebSocket, client_id: str):
await get_chart_data_cached(code, "D", 200)
logger.debug(f"[warmup] {code} 차트 캐시 완료")
except Exception as e:
logger.debug(f"[warmup] {code} 캐시 실패: {e}")
asyncio.create_task(_warmup_chart_cache(codes))
warn_throttled(logger, "main:767", f"[warmup] {code} 캐시 실패: {e}")
_spawn_background(_warmup_chart_cache(codes))
continue
if msg_type == "get_chart":
@@ -811,7 +982,7 @@ async def websocket_endpoint(websocket: WebSocket, client_id: str):
websocket_manager.disconnect(client_id)
logger.error(f"Client {client_id} unexpected WS error: {type(e).__name__}: {e}")
@app.post("/api/admin/reset-daily-loss")
@app.post("/api/admin/reset-daily-loss", dependencies=[Depends(_require_admin_v2)])
async def reset_daily_loss(api_key: str = Depends(_require_admin_key)):
"""테스트용 일일 손실 한도 강제 리셋 (인증 필수)"""
from app.services.mdd_manager import mdd_manager