fix: 하드닝 루프 8회차 — 체결 보정·고아 감지·청산 경보·실시간/차트·리스크 가드 일괄 수정

- 스윙 체결가 확인 전 executed_price NULL + fill_sweep, 진입가 resolve(ActiveTrade↔Portfolio), 0체결/부분체결 매수 장부 원복
- DT 고아 감지(근거 한정)·0체결 매도 복원·stale 점검 확대, 시간손절 -0.3% 원복
- 폴백 손절 ATR 캐시, 거래량부족-차단 신호 매수 차단, 현금 5% 유보 재적용, MDD peak/reset 수정
- KIS 서킷브레이커 청산 우회·경보, db_worker 재큐잉 순서, heartbeat·health-watch 사이드카
- 실시간 WS 재구독·41건 우선순위·다건 프레임 분할, 차트 샘플 제거·당일봉 합성, WS 서브프로토콜 인증
- 프론트: 미관리 그룹·확인 모달·차트 실시간 반영·예수금 표시 정정
- 전문가 에이전트 6종 추가(.claude/agents)
- 유닛 스위트 2667 passed (소켓 차단 하), tsc OK

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y83Za98XsNw9zuA4FTUytN
This commit is contained in:
2026-10-10 12:17:49 +09:00
parent 5c4803b339
commit a264561576
230 changed files with 41160 additions and 2334 deletions

View File

@@ -21,6 +21,7 @@ from app.models.models import StrategyOptimizationLog, TradeJournal
from app.core.timeutil import now_kst
from app.services.trade_analyzer import (
analyze_performance,
analyze_performance_v2,
build_insights,
create_journal_record,
generate_journal_for_trade,
@@ -53,6 +54,19 @@ async def get_performance(
return {"error": "성과 분석 실패 — 서버 로그 확인", "summary": {}, "total_trades": 0}
@router.get("/performance_v2")
async def get_performance_v2(
days: int = Query(default=30, ge=1, le=365, description="분석 기간 (일)"),
db: AsyncSession = Depends(get_db),
) -> Dict[str, Any]:
"""보정판 성과: manual/external 제외 시스템 성과, 포지션 단위 승률, 0 채움 Sharpe."""
try:
return await analyze_performance_v2(db, days=days)
except Exception as e:
logger.error(f"performance_v2 분석 실패: {e}", exc_info=True)
return {"error": "성과 분석 실패 — 서버 로그 확인", "summary": {}, "total_trades": 0}
# ─────────────────────────────────────────────────────────────────────────────
# GET /insights
# ─────────────────────────────────────────────────────────────────────────────
@@ -64,7 +78,7 @@ async def get_insights(
) -> Dict[str, Any]:
"""전략 인사이트 및 개선 제안 반환."""
try:
perf = await analyze_performance(db, days=days)
perf = await analyze_performance_v2(db, days=days)
insights = build_insights(perf)
last_opt_date = await get_last_optimization_date(db)
return {
@@ -91,7 +105,7 @@ async def _generate_journals_task(days: int = 7) -> Dict[str, Any]:
if not trades:
return {"created": 0, "message": "생성할 미처리 거래 없음"}
perf = await analyze_performance(db, days=30)
perf = await analyze_performance_v2(db, days=30)
created = 0
for trade in trades:
try:

File diff suppressed because it is too large Load Diff

View File

@@ -21,6 +21,7 @@ from pydantic import BaseModel, Field
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.background_tasks import spawn_tracked_v2
from app.core.config import settings
from app.core.database import get_db
from app.core.security import require_api_key
@@ -135,11 +136,44 @@ async def _buy_stock_impl(request: BuyRequest, stock_code: str, db: AsyncSession
logger.info(f"🟢 수동 매수 요청: {stock_name}({stock_code}) {quantity}주 @ {request.price or '시장가'}")
# ── 주문 (세마포어 + 분산락) ─────────────────────────────────────────
from app.services.trade_executor import safe_place_order, _delayed_sync_portfolio
from app.services.trade_executor import (
safe_place_order, _delayed_sync_portfolio, _is_order_outcome_unknown_v2,
)
order_result = await safe_place_order(
stock_code=stock_code, order_type="BUY", quantity=quantity,
price=request.price, is_domestic=True,
)
# [R3 2026-10-09] 전송 후 결과 불명(KIS_TRANSPORT_UNKNOWN·msg_cd 없음·응답 없음)은 실제
# 접수·체결됐을 수 있다. 단순 실패로 처리하면 쿨다운 없이 재클릭 → Portfolio 미동기 상태에서
# 4% 누적 상한이 기존 보유분을 못 봐 이중 매수. 쿨다운을 세우고 PENDING 으로 기록한다.
if _is_order_outcome_unknown_v2(order_result):
from app.services.db_worker import enqueue_trade_history as _eth
_now = _kst_now()
_last_done[stock_code] = time.monotonic()
logger.error(
f"[MANUAL_BUY] {stock_code} 주문 결과 불명(전송 후 응답 유실) — 쿨다운 {int(_DUP_COOLDOWN_SEC)}초, "
f"PENDING 기록 후 잔고 동기화. 포트폴리오 확인 전 재시도 금지"
)
await _eth({
"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": None,
"executed_quantity": 0,
"total_amount": float(order_price * quantity),
"status": "PENDING",
"order_id": None,
"message": "수동 매수 결과 불명 (접수 여부 미확인 — 포트폴리오 확인 필요)",
"strategy_type": "manual",
"created_at": _now,
})
spawn_tracked_v2(_delayed_sync_portfolio(3))
raise HTTPException(
status_code=504,
detail="매수 주문 결과 불명 — 접수·체결됐을 수 있습니다. 포트폴리오 확인 후 재시도하세요",
)
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}")
@@ -154,23 +188,27 @@ async def _buy_stock_impl(request: BuyRequest, stock_code: str, db: AsyncSession
# ── DB 기록 (db_worker 큐) ────────────────────────────────────────────
from app.services.db_worker import enqueue_trade_history, enqueue_portfolio_upsert
now = _kst_now()
# [R3 2026-10-09] 접수(rt_cd 0)만으로 체결을 단정하지 않는다. 지정가는 미체결일 수 있어
# PENDING·미체결 수량 0 으로 기록하고 Portfolio 선생성을 생략한다(체결 확인/sync 담당).
is_limit = bool(request.price)
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,
# [2026-10-08] 체결가는 체결 확인(swing/fill_sweep)이 채운다 — 주문 기준가를 체결가로 쓰지 않는다.
"executed_price": None,
"executed_quantity": 0 if is_limit else quantity,
"total_amount": float(order_price * quantity),
"status": "COMPLETED",
"status": "PENDING" if is_limit else "COMPLETED",
"order_id": order_id,
"message": "수동 매수 (추천 패널)",
"strategy_type": "manual",
"created_at": now,
"executed_at": now,
"executed_at": None if is_limit else now,
})
if holding is None:
if holding is None and not is_limit:
await enqueue_portfolio_upsert({
"stock_code": stock_code,
"stock_name": stock_name,
@@ -182,8 +220,26 @@ async def _buy_stock_impl(request: BuyRequest, stock_code: str, db: AsyncSession
"currency_code": "KRW",
"exchange_code": "KRX",
})
# [R7] 접수된 주문(ODNO)은 체결 확인 태스크로 PENDING→COMPLETED/CANCELLED 확정한다.
# 없으면 체결된 지정가 수동 매수가 영원히 PENDING·0주로 남아 통계·저널에서 빠진다.
# (미체결 잔량이 KIS 에 살아 있으면 PENDING 유지 — 취소는 하지 않는다.)
if order_id:
from app.services.trade_executor import _verify_and_sync_execution_v3
spawn_tracked_v2(_verify_and_sync_execution_v3(
order_id=order_id,
stock_code=stock_code,
stock_name=stock_name,
ordered_quantity=quantity,
order_price=float(order_price),
order_type="BUY",
is_domestic=True,
exchange_code="KRX",
db_trade_id=0,
delay=3,
sell_pnl_basis={"strategy_type": "manual"},
))
# 체결 후 KIS 잔고 기준 재동기화 (평균단가·수량 정확화)
asyncio.create_task(_delayed_sync_portfolio(3))
spawn_tracked_v2(_delayed_sync_portfolio(3))
_last_done[stock_code] = time.monotonic()
logger.info(f"✅ 수동 매수 주문 완료: {stock_name} {quantity}주 @ {order_price:,.0f}원 (주문번호 {order_id})")

View File

@@ -9,7 +9,9 @@
# 필요한 라이브러리 및 모듈 임포트
from datetime import datetime, timedelta
import asyncio
import time
import random
import re
import logging
import pytz
@@ -260,16 +262,16 @@ async def _fetch_chart_from_db(stock_code: str, limit: int) -> list:
DB에 데이터가 없으면 빈 리스트 반환.
[READ-ONLY 함수 — DB 쓰기 없음]
SELECT만 수행하므로 CLAUDE.md 3.1 "실시간 계층 DB I/O 금지" 규칙의 예외 대상.
단, WebSocket 경로에서 이 함수를 직접 호출하면 안 됩니다.
WebSocket 핸들러는 get_chart_data_cached()를 통해 인메모리 캐시(1차)를 우선 사용하고,
캐시 미스 시에도 이 함수를 호출하지 말고 캐시된 값(빈 리스트 포함)을 반환해야 합니다.
REST 엔드포인트에서만 DB 폴백이 허용됩니다.
REST /chart 와 WebSocket get_chart(백그라운드 태스크, 인메모리 캐시 미스 시) 양쪽에서 호출된다.
SELECT 1회만 수행하고 add/commit 이 없다. KIS 수신 루프·주문 경로에서는 호출되지 않는다.
[M-5] OHLC 중 0/결측이 있는 행(거래정지·수집 결함)은 제외 — 네이버 경로와 동일 기준.
"""
try:
from app.core.database import AsyncSessionLocal
from app.models.models import StockDailyPrice
# DB-READ-EXCEPTION: 차트 일봉 SELECT 전용(commit·add 없음). WS get_chart 는 메시지 루프가 아닌
# 백그라운드 태스크에서 캐시 미스일 때만 도달한다. 쓰기는 이 경로에 추가하지 말 것.
async with AsyncSessionLocal() as db:
result = await db.execute(
select(StockDailyPrice)
@@ -284,51 +286,348 @@ async def _fetch_chart_from_db(stock_code: str, limit: int) -> list:
# DB 결과는 newest first → oldest first로 역순 변환
chart_data = []
skipped = 0
for row in reversed(rows):
o = float(row.open_price or 0)
h = float(row.high_price or 0)
l = float(row.low_price or 0)
c = float(row.close_price or 0)
if o <= 0 or h <= 0 or l <= 0 or c <= 0:
skipped += 1
continue
chart_data.append({
"date": row.trade_date.strftime("%Y%m%d"),
"open": float(row.open_price or 0),
"high": float(row.high_price or 0),
"low": float(row.low_price or 0),
"close": float(row.close_price or 0),
"open": o,
"high": h,
"low": l,
"close": c,
"volume": int(row.volume or 0),
})
if skipped:
logger.info(f"차트 DB 0/결측 OHLC 행 제외 ({stock_code}): {skipped}행")
return chart_data
except Exception as e:
logger.warning(f"DB 차트 조회 실패 ({stock_code}): {e}")
return []
async def get_chart_data_cached(stock_code: str, period: str, limit: int) -> list:
"""캐시된 차트 데이터 반환 — REST endpoint와 WebSocket 핸들러가 공유.
_CHART_LIVE_TTL = 30 # 당일 캔들을 합성/병합한 결과의 캐시 TTL(초)
조회 우선순위:
1. 인메모리 캐시 (10분 TTL)
2. DB (stock_daily_prices) — 일봉 전용, 배치가 사전 수집한 데이터
3. KIS API 폴백 (DB에 데이터 부족 시)
realtime 보정 없음 (현재가는 WebSocket tick이 처리).
def _chart_session_state(now_kst: datetime) -> tuple:
"""(expected_last_date, in_session) — 일봉에 있어야 할 가장 최근 거래일과 장중 여부.
expected_last_date: 오늘이 거래일이고 09:00 이후면 오늘, 아니면 직전 거래일(YYYYMMDD).
"""
from app.core.market_schedule import is_krx_holiday
d = now_kst.date()
is_trading_day = d.weekday() < 5 and not is_krx_holiday(d)
started = now_kst.time() >= datetime.strptime("09:00", "%H:%M").time()
in_session = is_trading_day and started and now_kst.time() < datetime.strptime("15:30", "%H:%M").time()
if is_trading_day and started:
return d.strftime("%Y%m%d"), in_session
d -= timedelta(days=1)
while d.weekday() >= 5 or is_krx_holiday(d):
d -= timedelta(days=1)
return d.strftime("%Y%m%d"), False
def _naver_rows_to_bars(naver_data: list) -> list:
rows = []
for item in naver_data or []:
o = float(item.get("stck_oprc") or 0)
h = float(item.get("stck_hgpr") or 0)
l = float(item.get("stck_lwpr") or 0)
c = float(item.get("stck_clpr") or 0)
d = item.get("stck_bsop_date", "")
if not d or c <= 0 or o <= 0 or h <= 0 or l <= 0:
continue
rows.append({"date": d, "open": o, "high": h, "low": l, "close": c,
"volume": int(item.get("acml_vol") or 0)})
return rows
_DAYBAR_KEY_PREFIX = "daybar:"
_DAYBAR_MIN_TTL = 60 # 저장 TTL 하한(초)
_CHART_POST_CLOSE_END = "15:40" # 장 마감 직후(단일가 체결 전 값 가능) 짧은 캐시 구간 끝
def _daybar_ttl_seconds(now_kst: datetime) -> int:
"""daybar 키 TTL — 당일 15:40(KST)까지 남은 초(하한 _DAYBAR_MIN_TTL)."""
end = datetime.strptime(_CHART_POST_CLOSE_END, "%H:%M").time()
end_dt = now_kst.replace(hour=end.hour, minute=end.minute, second=0, microsecond=0)
return max(_DAYBAR_MIN_TTL, int((end_dt - now_kst).total_seconds()))
def _is_post_close_window(now_kst: datetime) -> bool:
"""거래일 15:30~15:40 — 네이버 당일봉이 단일가 체결 전 값일 수 있어 길게 캐시하지 않는다."""
from app.core.market_schedule import is_krx_holiday
d = now_kst.date()
if d.weekday() >= 5 or is_krx_holiday(d):
return False
t = now_kst.time()
return (datetime.strptime("15:30", "%H:%M").time() <= t
< datetime.strptime(_CHART_POST_CLOSE_END, "%H:%M").time())
def _parse_bar(raw, expected: str) -> dict | None:
"""OHLC 가 모두 양수인 당일 봉 dict 만 통과(날짜는 expected 와 같아야 함)."""
if not isinstance(raw, dict):
return None
try:
price = float(raw.get("price", raw.get("close")) or 0)
op = float(raw.get("open") or 0)
hi = float(raw.get("high") or 0)
lo = float(raw.get("low") or 0)
vol = int(raw.get("volume") or 0)
except (TypeError, ValueError):
return None
if price <= 0 or op <= 0 or hi <= 0 or lo <= 0:
return None
return {"date": expected, "open": op, "high": max(hi, price, op),
"low": min(lo, price, op), "close": price, "volume": vol}
async def _today_bar_from_tick(stock_code: str, expected: str) -> dict | None:
"""Redis tick:{code}(KIS 실시간 체결, 시가/고가/저가 포함)으로 당일 봉 합성.
tick 은 TTL 10초라 저유동 종목은 자주 비므로, 합성한 당일봉을 daybar:{code}(TTL 15:40 까지)에
누적(고가=max, 저가=min)해 두고 tick 이 없을 때 그 값을 쓴다. 둘 다 없거나 불완전하면 None.
"""
from app.core.redis import get_json, setex_json
tick = None
try:
tick = await get_json(f"tick:{stock_code}")
except Exception as e:
warn_throttled(logger, "market_data:tick_bar", "tick 캐시 조회 실패 (%s): %s", stock_code, e)
saved = None
try:
raw_saved = await get_json(f"{_DAYBAR_KEY_PREFIX}{stock_code}")
if isinstance(raw_saved, dict) and str(raw_saved.get("date")) == expected: # 전일 잔존 값 무시
saved = _parse_bar(raw_saved, expected)
except Exception as e:
warn_throttled(logger, "market_data:daybar_get", "daybar 조회 실패 (%s): %s", stock_code, e)
bar = _parse_bar(tick, expected) if isinstance(tick, dict) else None
if bar is None:
return saved
if saved is not None:
# 시가는 tick(KIS 당일 시가) 우선, 고저는 누적 극값 유지
bar["high"] = max(bar["high"], saved["high"])
bar["low"] = min(bar["low"], saved["low"])
try:
await setex_json(f"{_DAYBAR_KEY_PREFIX}{stock_code}",
_daybar_ttl_seconds(datetime.now(_KST)), bar)
except Exception as e:
warn_throttled(logger, "market_data:daybar_set", "daybar 저장 실패 (%s): %s", stock_code, e)
return bar
def _missing_trading_days_v2(bars: list, now_kst: datetime) -> list:
"""bars 첫 날짜~마지막 날짜 사이, 최근 10 캘린더일 창 안에서 빠진 거래일(YYYYMMDD)."""
from app.core.market_schedule import is_krx_holiday
try:
have = {str(b.get("date"))[:8] for b in bars}
first = datetime.strptime(min(have), "%Y%m%d").date()
last = datetime.strptime(max(have), "%Y%m%d").date()
except (ValueError, TypeError):
return []
d = max(first, now_kst.date() - timedelta(days=10))
missing = []
while d < last:
ds = d.strftime("%Y%m%d")
if d.weekday() < 5 and not is_krx_holiday(d) and ds not in have:
missing.append(ds)
d += timedelta(days=1)
return missing
_CHART_SPLIT_MISMATCH_RATIO = 0.20 # 분할·수정주가 판정 임계(종가 비율 편차)
async def _merge_recent_daily_bars(stock_code: str, bars: list, now_kst: datetime | None = None) -> tuple:
"""DB 일봉이 최신 거래일보다 뒤처졌을 때 누락 봉을 채운다. (bars, live_merged) 반환.
원인(2026-10-08): DB(stock_daily_prices) 갱신이 하루 이상 늦어도 _CHART_STALE_DAYS(7일)
미만이면 폴백이 없고, 장중 당일 봉은 어디서도 합성되지 않아 차트가 멈춰 보였다.
1) 누락 거래일: 네이버 최근 일봉으로 보충 (읽기 전용 HTTP, DB 쓰기 없음)
2) 장중: 당일 봉을 Redis tick(시가/고가/저가/현재가/누적거래량)으로만 합성 (KIS REST 호출 없음)
예외는 경고만 남기고 원본 bars 를 반환한다(차트는 계속 나와야 한다).
"""
now_kst = now_kst or datetime.now(_KST)
expected, in_session = _chart_session_state(now_kst)
out = list(bars)
last = str(out[-1].get("date", ""))[:8] if out else ""
live = False
# 최근 10일 창 안의 중간 결손 거래일(예: DB 일괄 수집 누락) 탐지 — last 이전 결손도 백필 대상
gaps = _missing_trading_days_v2(out, now_kst) if out else []
if not last or last < expected or gaps:
try:
from app.services.naver_finance import get_naver_daily_chart
naver_rows = _naver_rows_to_bars(await get_naver_daily_chart(stock_code, days=10))
have = {str(b.get("date"))[:8]: b for b in out}
# 장중 네이버의 당일 진행 봉은 부분봉이다 → 버린다(당일봉은 tick 합성만 인정)
if in_session:
naver_rows = [r for r in naver_rows if r["date"] != expected]
# 원주가/수정주가 혼재 방지: 겹치는 날짜 종가가 분할 수준(>20%)으로 어긋날 때만 병합 안 함.
# 네이버-KRX 일상적 차이(최대 ~1.3%)는 DB 봉을 유지하고 누락 날짜만 보충한다.
mismatch = any(
float(have[r["date"]].get("close") or 0) > 0
and abs(r["close"] / float(have[r["date"]]["close"]) - 1) > _CHART_SPLIT_MISMATCH_RATIO
for r in naver_rows if r["date"] in have
)
if mismatch:
logger.warning(f"차트 보충 생략({stock_code}): DB/네이버 종가 불일치(분할·수정주가 추정)")
else:
first = str(out[0].get("date"))[:8] if out else ""
added = [r for r in naver_rows if r["date"] not in have and r["date"] > first]
if added:
out.extend(added)
out.sort(key=lambda b: b["date"])
last = out[-1]["date"]
logger.info(f"차트 누락 봉 보충({stock_code}): {[a['date'] for a in added]}")
except Exception as e:
logger.warning(f"차트 누락 봉 보충 실패 ({stock_code}): {e}")
if in_session:
# [M-7] 실시간 체결 캐시(Redis tick:{code}, TTL 10초)로 합성
tick_bar = await _today_bar_from_tick(stock_code, expected)
if tick_bar is not None:
if out and str(out[-1].get("date"))[:8] == expected:
out[-1] = tick_bar
else:
out.append(tick_bar)
return out, True
# [R1 하드닝] 차트 경로는 KIS REST 를 호출하지 않는다(주문·청산과 전역 쓰로틀 공유 방지).
# tick 이 없으면(WS 등록 한도 밖 종목 등) 당일봉 없이 DB/네이버 봉만 반환한다.
return out, live
_CHART_MAX_LIMIT = 240
# 네이버 폴백 조회 기간(캘린더일) — 주/월봉은 집계 전 일봉이 충분해야 한다(L-4).
# 캘린더 ×7 / ×31 ≈ 거래일 ×5 / ×22.
_NAVER_DAYS_PER_BAR = {"D": 2, "W": 7, "M": 31}
_NAVER_MAX_DAYS = 7500
_OVERSEAS_TICKER_RE = re.compile(r"[A-Za-z][A-Za-z.\-]{0,9}")
def _is_overseas_ticker(stock_code: str) -> bool:
"""미국 티커 여부. 국내 단축코드(6자리, 숫자 포함 — 예: 005930, 0004V0)는 False."""
c = (stock_code or "").strip()
return bool(c) and not any(ch.isdigit() for ch in c) and bool(_OVERSEAS_TICKER_RE.fullmatch(c))
def parse_chart_limit_v2(raw, default: int = 200) -> int:
"""[L-3] 클라이언트가 보낸 limit 을 안전하게 정수화(1~240). 변환 불가면 default."""
try:
value = int(raw)
except (TypeError, ValueError):
return default
return max(1, min(value, _CHART_MAX_LIMIT))
def is_chart_cache_fresh_v2(stock_code: str, period: str = "D", limit: int = 200) -> bool:
"""[M-7] 차트 캐시 엔트리가 자신의 ttl(장중 합성 30초 / 그 외 10분) 안에 있는지."""
cached = chart_cache.get(f"{stock_code}__{period}__{min(limit, _CHART_MAX_LIMIT)}")
if not cached:
return False
age = (datetime.now() - cached["timestamp"]).total_seconds()
return age < cached.get("ttl", _CHART_CACHE_TTL)
def _chart_cache_put(cache_key: str, data: list, ttl: int, current_time: datetime) -> None:
# 캐시 크기 제한: 초과 시 만료 항목 → 가장 오래된 항목 순으로 제거
if len(chart_cache) >= _CHART_CACHE_MAX_SIZE:
expired_keys = [
k for k, v in chart_cache.items()
if (current_time - v["timestamp"]).total_seconds() >= v.get("ttl", _CHART_CACHE_TTL)
]
for k in expired_keys:
del chart_cache[k]
while len(chart_cache) >= _CHART_CACHE_MAX_SIZE:
oldest_key = min(chart_cache, key=lambda k: chart_cache[k]["timestamp"])
del chart_cache[oldest_key]
chart_cache[cache_key] = {"timestamp": current_time, "data": data, "ttl": ttl}
def _overseas_rows_to_bars(result) -> list:
"""KIS 해외 일봉(HHDFS76240000) output2 → 차트 봉 (oldest first). 0/결측 OHLC 제외."""
items = result.get("output2", []) if isinstance(result, dict) else (result or [])
bars = []
for item in items or []:
try:
o = float(item.get("open") or 0)
h = float(item.get("high") or 0)
l = float(item.get("low") or 0)
c = float(item.get("clos") or 0)
d = str(item.get("xymd") or "")
vol = int(float(item.get("tvol") or 0))
except (TypeError, ValueError, AttributeError):
continue
if len(d) != 8 or o <= 0 or h <= 0 or l <= 0 or c <= 0:
continue
bars.append({"date": d, "open": o, "high": h, "low": l, "close": c, "volume": vol})
bars.sort(key=lambda b: b["date"])
return bars
async def _get_overseas_chart_impl(stock_code: str, period: str, limit: int) -> dict:
"""[H-6] 미국 티커 차트 — 기존 해외 차트 경로(KIS HHDFS76240000, Redis 5분 캐시) 사용."""
cache_key = f"{stock_code}__{period}__{limit}"
current_time = datetime.now()
try:
from app.services.exchange_detector import detect_exchange
result = await korea_investment_api.get_overseas_daily_chart(
stock_code, detect_exchange(stock_code), period
)
bars = _overseas_rows_to_bars(result)[-limit:]
except Exception as e:
warn_throttled(logger, "market_data:overseas_chart",
"해외 차트 조회 실패 (%s): %s", stock_code, e)
bars = []
if not bars:
return {"data": [], "is_sample": False, "reason": "overseas_chart_unavailable", "source": None}
_chart_cache_put(cache_key, bars, _CHART_CACHE_TTL, current_time)
return {"data": list(bars), "is_sample": False, "reason": None, "source": "overseas"}
async def get_chart_data_cached_v2(stock_code: str, period: str, limit: int) -> dict:
"""차트 데이터 + 메타 반환 — REST /chart 와 WebSocket get_chart 가 공유.
반환: {"data": [...], "is_sample": False, "reason": None|"no_data"|"overseas_chart_unavailable",
"source": "cache"|"db"|"naver"|"overseas"|None}
조회 우선순위 (국내):
1. 인메모리 캐시 (엔트리별 ttl — 장중 합성 30초 / 그 외 10분)
2. DB (stock_daily_prices) — 0/결측 OHLC 제외
3. 네이버 일봉 폴백 (DB 없음/오래됨)
+ 누락 거래일 보충·장중 당일봉 합성(_merge_recent_daily_bars)
[H-6] 데이터가 없으면 난수 샘플을 만들지 않고 빈 배열 + reason 을 돌려준다.
(과거 _generate_sample_chart 난수 차트가 실데이터처럼 표시됐다.)
"""
if period not in ("D", "W", "M"):
period = "D"
limit = min(limit, 240)
limit = max(1, min(int(limit), _CHART_MAX_LIMIT))
cache_key = f"{stock_code}__{period}__{limit}"
current_time = datetime.now()
# 1차: 인메모리 캐시 (TTL 만료 항목 삭제 + 크기 제한)
# 1차: 인메모리 캐시 (TTL 만료 항목 삭제)
cached = chart_cache.get(cache_key)
if cached:
if (current_time - cached["timestamp"]).total_seconds() < _CHART_CACHE_TTL:
return list(cached["data"])
else:
# 만료 항목 즉시 삭제
del chart_cache[cache_key]
if (current_time - cached["timestamp"]).total_seconds() < cached.get("ttl", _CHART_CACHE_TTL):
return {"data": list(cached["data"]), "is_sample": False, "reason": None, "source": "cache"}
del chart_cache[cache_key]
# 2차: DB 조회 (KIS API 사용 안 함 — 배치가 수집한 일봉 데이터만 사용)
# 주/월봉은 일봉 데이터에서 집계
if _is_overseas_ticker(stock_code):
return await _get_overseas_chart_impl(stock_code, period, limit)
# 2차: DB 조회 — 주/월봉은 일봉 데이터에서 집계
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))
source = "db"
# DB 데이터가 없거나 오래됐으면(마지막 봉이 _CHART_STALE_DAYS일 이상 전) 네이버 차트 폴백
# — KRX 일괄 수집(DATAGOKR)이 멈춘 종목은 DB가 수개월 전에서 끊겨 차트가 현재가와 어긋난다.
@@ -344,56 +643,61 @@ async def get_chart_data_cached(stock_code: str, period: str, limit: int) -> lis
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:
naver_rows = []
for item in naver_data:
o = float(item.get("stck_oprc") or 0)
h = float(item.get("stck_hgpr") or 0)
l = float(item.get("stck_lwpr") or 0)
c = float(item.get("stck_clpr") or 0)
d = item.get("stck_bsop_date", "")
if not d or c <= 0 or o <= 0 or h <= 0 or l <= 0:
continue
naver_rows.append({
"date": d,
"open": o,
"high": h,
"low": l,
"close": c,
"volume": int(item.get("acml_vol") or 0),
})
if naver_rows:
db_data = naver_rows[-limit:]
naver_days = min(limit * _NAVER_DAYS_PER_BAR[period], _NAVER_MAX_DAYS)
naver_rows = _naver_rows_to_bars(await get_naver_daily_chart(stock_code, days=naver_days))
if naver_rows:
# 일봉은 limit 개, 주/월봉은 집계 전이므로 전부 유지(집계 후 limit 으로 자른다)
db_data = naver_rows[-limit:] if period == "D" else naver_rows
source = "naver"
logger.info(f"차트 네이버 폴백: {stock_code} ({len(db_data)}행)")
except Exception as e:
logger.warning(f"네이버 차트 폴백 실패 ({stock_code}): {e}")
if not db_data:
return _generate_sample_chart(stock_code, period, limit).get("data", [])
return {"data": [], "is_sample": False, "reason": "no_data", "source": None}
# 주/월봉 집계
if period in ("W", "M"):
db_data = _aggregate_weekly_monthly(db_data, period)
db_data = db_data[-limit:] # 최신 N개만
# 최신 거래일 봉 보충 + 장중 당일 봉 합성 (일봉 원본에 먼저 적용 → 주/월봉에도 반영)
db_data, _live = await _merge_recent_daily_bars(stock_code, db_data)
if period == "D":
db_data = db_data[-limit:]
else:
db_data = _aggregate_weekly_monthly(db_data, period)[-limit:]
# 캐시 크기 제한: 초과 시 가장 오래된 항목 제거
if len(chart_cache) >= _CHART_CACHE_MAX_SIZE:
# 만료 항목 우선 정리
expired_keys = [
k for k, v in chart_cache.items()
if (current_time - v["timestamp"]).total_seconds() >= _CHART_CACHE_TTL
]
for k in expired_keys:
del chart_cache[k]
# 그래도 초과면 가장 오래된 항목 제거
while len(chart_cache) >= _CHART_CACHE_MAX_SIZE:
oldest_key = min(chart_cache, key=lambda k: chart_cache[k]["timestamp"])
del chart_cache[oldest_key]
# 장중이면 tick 유무와 무관하게 짧은 TTL — 구독 워밍업이 첫 tick 보다 먼저 끝나
# 당일봉 없는 결과가 10분 캐시되는 것을 막는다.
_now_kst = datetime.now(_KST)
_, _in_session = _chart_session_state(_now_kst)
_short = _live or _in_session or _is_post_close_window(_now_kst)
_chart_cache_put(cache_key, db_data,
_CHART_LIVE_TTL if _short else _CHART_CACHE_TTL, current_time)
return {"data": list(db_data), "is_sample": False, "reason": None, "source": source}
chart_cache[cache_key] = {"timestamp": current_time, "data": db_data}
logger.debug(f"차트 DB 히트: {stock_code} period={period} ({len(db_data)}행)")
return db_data
async def get_chart_data_cached(stock_code: str, period: str, limit: int) -> list:
"""캐시된 차트 데이터(list) 반환 — 호환 래퍼. 메타가 필요하면 get_chart_data_cached_v2.
데이터가 없으면 빈 리스트(샘플 난수 차트를 반환하지 않는다 — H-6).
"""
return (await get_chart_data_cached_v2(stock_code, period, limit))["data"]
async def build_ws_chart_response_v2(stock_code: str, period, limit_raw) -> dict:
"""WebSocket `chart_data` 응답 생성. 예외는 빈 배열 + reason/error 로 변환한다.
필드: type, stock_code, period(요청값 그대로 — 프론트가 이것으로 응답을 매칭), data,
is_sample(항상 False), reason(None|no_data|overseas_chart_unavailable|chart_fetch_failed),
error(None|예외 종류명)
"""
limit = parse_chart_limit_v2(limit_raw)
try:
res = await get_chart_data_cached_v2(stock_code, period, limit)
return {"type": "chart_data", "stock_code": stock_code, "period": period,
"data": res["data"], "is_sample": False, "reason": res["reason"], "error": None}
except Exception as e:
logger.error(f"get_chart 오류 ({stock_code}): {type(e).__name__}: {e}")
return {"type": "chart_data", "stock_code": stock_code, "period": period,
"data": [], "is_sample": False, "reason": "chart_fetch_failed",
"error": type(e).__name__}
@router.get("/chart/{stock_code}")
@@ -403,32 +707,38 @@ async def get_stock_chart(
realtime: bool = Query(False, description="최신 현재가를 포함하여 실시간으로 보정된 마지막 캔들 반환"),
limit: int = Query(100, description="조회할 데이터 개수 (기본: 100, 최대: 240)")
):
"""주식 차트 데이터 조회 엔드포인트 (DB 전용 — KIS API 미사용)
"""주식 차트 데이터 조회 엔드포인트
Args:
stock_code: 종목 코드
stock_code: 종목 코드 (국내 6자리 또는 미국 티커)
period: D(일봉), W(주봉), M(월봉)
limit: 조회할 데이터 개수 (일봉 100개는 약 3개월치 데이터)
데이터가 없으면 data=[] + reason. is_sample 은 항상 False(샘플 난수 차트를 만들지 않는다).
"""
try:
chart_data = await get_chart_data_cached(stock_code, period, limit)
res = await get_chart_data_cached_v2(stock_code, period, parse_chart_limit_v2(limit, 100))
return {
"stock_code": stock_code,
"period": period,
"data": chart_data,
"is_sample": not chart_data,
"data": res["data"],
"is_sample": False,
"reason": res["reason"],
"realtime": False,
"latest": None,
}
except Exception as e:
sample = _generate_sample_chart(stock_code, period)
sample["error"] = str(e)
sample["is_sample"] = True
sample["realtime"] = False
sample["latest"] = None
return sample
logger.error(f"차트 조회 오류 ({stock_code}): {type(e).__name__}: {e}")
return {
"stock_code": stock_code,
"period": period,
"data": [],
"is_sample": False,
"reason": "chart_fetch_failed",
"error": type(e).__name__,
"realtime": False,
"latest": None,
}
@router.get("/news")
@@ -488,13 +798,134 @@ async def get_market_news(limit: int = 10):
][:limit]
# ── 종목명 해석 ─────────────────────────────────────────────────────────────
# KIS inquire-price(FHKST01010100)/호가 응답에는 종목명(hts_kor_isnm)이 없거나 비어 있어
# 화면에 "009420 009420" 으로 보였다(2026-10-08). 폴백 순서:
# KIS 응답 필드 → 프로세스 캐시 → DB(StockAnalysis → Portfolio → StockDailyPrice) → 네이버 basic API
_STOCK_NAME_MEM: Dict[str, str] = {}
_STOCK_NAME_MEM_MAX = 2000
_STOCK_NAME_MISS: Dict[str, float] = {} # code -> monotonic ts (이름 못 찾음 네거티브 캐시)
_STOCK_NAME_MISS_TTL = 600.0 # 초
def _name_from_kis_output(*outputs) -> str:
for o in outputs:
if not isinstance(o, dict):
continue
for k in ("hts_kor_isnm", "prdt_abrv_name", "stck_prdt_name", "prdt_name"):
v = str(o.get(k) or "").strip()
if v:
return v
return ""
async def _resolve_stock_name(stock_code: str, kis_name: str = "") -> str:
"""종목명 해석. 못 찾으면 "" (코드로 대체하지 않는다 — 표시는 프론트 몫)."""
name = (kis_name or "").strip()
if name:
_STOCK_NAME_MEM[stock_code] = name
return name
cached = _STOCK_NAME_MEM.get(stock_code)
if cached:
return cached
_miss_ts = _STOCK_NAME_MISS.get(stock_code)
if _miss_ts is not None:
if (time.monotonic() - _miss_ts) < _STOCK_NAME_MISS_TTL:
return ""
_STOCK_NAME_MISS.pop(stock_code, None)
try:
from app.core.database import AsyncSessionLocal
from app.models.models import StockAnalysis, Portfolio, StockDailyPrice
# DB-READ-EXCEPTION: READ-ONLY SELECT (REST 엔드포인트, commit 없음)
async with AsyncSessionLocal() as _db:
for model, order in (
(StockAnalysis, StockAnalysis.analysis_date.desc()),
(Portfolio, None),
(StockDailyPrice, StockDailyPrice.trade_date.desc()),
):
q = select(model.stock_name).where(
model.stock_code == stock_code,
model.stock_name.isnot(None),
model.stock_name != "",
)
if order is not None:
q = q.order_by(order)
row = (await _db.execute(q.limit(1))).scalar_one_or_none()
if row and str(row).strip() and str(row).strip() != stock_code:
name = str(row).strip()
break
except Exception as e:
warn_throttled(logger, "market_data:name_db", "종목명 DB 조회 실패 (%s): %s", stock_code, e)
if not name:
try:
from app.services.naver_finance import get_naver_stock_name
name = await get_naver_stock_name(stock_code)
except Exception as e:
warn_throttled(logger, "market_data:name_naver", "종목명 네이버 조회 실패 (%s): %s", stock_code, e)
if name:
if len(_STOCK_NAME_MEM) >= _STOCK_NAME_MEM_MAX:
_STOCK_NAME_MEM.pop(next(iter(_STOCK_NAME_MEM)), None)
_STOCK_NAME_MEM[stock_code] = name
_STOCK_NAME_MISS.pop(stock_code, None)
else:
# 이름 없음도 짧게 캐시 — 호가 폴링마다 DB 3회 + 네이버 1회 반복 방지
if len(_STOCK_NAME_MISS) >= _STOCK_NAME_MEM_MAX:
_STOCK_NAME_MISS.pop(next(iter(_STOCK_NAME_MISS)), None)
_STOCK_NAME_MISS[stock_code] = time.monotonic()
return name
# 호가 응답이 4.5~13초였던 원인(2026-10-08): 호가 1건이 KIS 전역 쓰로틀 락·세마포어에서
# 현재가/잔고/스케줄러 호출 뒤에 줄을 서고, 화면 폴링마다 같은 종목을 중복 조회했다.
# → 종목별 짧은 TTL 캐시 + 동시 요청 합치기(in-flight). 실패 응답은 캐시하지 않는다.
_ORDERBOOK_TTL = 1.5 # 초
_ORDERBOOK_CACHE_MAX = 200
_orderbook_cache: Dict[str, tuple] = {} # code -> (monotonic ts, payload)
_orderbook_inflight: Dict[str, "asyncio.Future"] = {}
@router.get("/orderbook/{stock_code}")
async def get_orderbook(stock_code: str):
"""호가 정보 조회 엔드포인트 (매수/매도 10단계 호가)"""
"""호가 정보 조회 엔드포인트 (매수/매도 10단계 호가) — 1.5초 TTL 캐시 + 동시요청 합치기."""
import time as _time
now = _time.monotonic()
hit = _orderbook_cache.get(stock_code)
if hit and now - hit[0] < _ORDERBOOK_TTL:
return hit[1]
fut = _orderbook_inflight.get(stock_code)
if fut is not None:
return await asyncio.shield(fut)
fut = asyncio.get_running_loop().create_future()
_orderbook_inflight[stock_code] = fut
try:
result = await _get_orderbook_impl(stock_code)
if isinstance(result, dict) and result.get("asks") and not result.get("error"):
if len(_orderbook_cache) >= _ORDERBOOK_CACHE_MAX:
_orderbook_cache.pop(min(_orderbook_cache, key=lambda k: _orderbook_cache[k][0]), None)
_orderbook_cache[stock_code] = (_time.monotonic(), result)
fut.set_result(result)
return result
except BaseException as e:
if not fut.done():
fut.set_exception(e)
fut.exception() # 대기자가 없을 때 'never retrieved' 경고 방지
raise
finally:
_orderbook_inflight.pop(stock_code, None)
async def _get_orderbook_impl(stock_code: str):
try:
logger.debug(f"Fetching orderbook for stock: {stock_code}")
# 한국투자증권 API를 통해 호가 데이터 조회
data = await korea_investment_api.get_orderbook(stock_code)
# 종목명 해석은 KIS 호출과 병렬로 (추가 지연 방지)
_name_task = asyncio.create_task(_resolve_stock_name(stock_code))
try:
# 한국투자증권 API를 통해 호가 데이터 조회
data = await korea_investment_api.get_orderbook(stock_code)
except BaseException:
_name_task.cancel()
raise
# API 응답 코드가 '0'(성공)이 아닌 경우 에러 처리
if data.get("rt_cd") != "0":
@@ -518,7 +949,9 @@ async def get_orderbook(stock_code: str):
# 호가 데이터를 담을 딕셔너리 초기화
orderbook = {
"stock_code": stock_code,
"stock_name": output1.get("hts_kor_isnm", ""),
"stock_name": await _resolve_stock_name(
stock_code, _name_from_kis_output(output1, output2) or (await _name_task)
),
"current_price": current_price,
"current_volume": current_volume,
"asks": [], # 매도 호가 (낮은 가격 -> 높은 가격 순으로 임시 저장 후 reverse)
@@ -847,10 +1280,10 @@ async def get_ai_analysis(stock_code: str, db: AsyncSession = Depends(get_db)):
signals.append(f"RSI 과매수({rsi:.1f})")
elif rsi < 40: # 매수 고려
buy_signals += 0.5
signals.append(f"RSI 매수권장({rsi:.1f})")
signals.append(f"RSI 저점권({rsi:.1f}, 약한 매수 우위)")
elif rsi > 60: # 매도 고려
sell_signals += 0.5
signals.append(f"RSI 매도권장({rsi:.1f})")
signals.append(f"RSI 고점권({rsi:.1f}, 약한 매도 우위)")
# 이동평균선(MA) 분석
ma5 = indicators.get("ma5")
@@ -961,9 +1394,26 @@ async def get_ai_analysis(stock_code: str, db: AsyncSession = Depends(get_db)):
trend = "DOWN"
# 4. 종합 결과 반환
# 제목/추천/목표가는 최종 결론(recommendation)과 반드시 일치해야 한다.
# (2026-10-08: HOLD 13% 인데 '매수 추천'·목표가·손절가가 함께 표시된 모순)
_targets = _suggest_targets(current_price, _atr_for_targets)
if recommendation != "BUY":
# 목표가·손절가는 '신규 매수 진입' 기준 값이므로 BUY 결론에서만 의미가 있다.
_targets = {
"target_price": None,
"stop_loss_price": None,
"target_pct": None,
"stop_loss_pct": None,
"risk_reward": None,
"target_note": f"{recommendation} 결론 — 신규 진입 목표가·손절가 제시 안 함",
}
_headline = {"BUY": "매수 추천", "SELL": "매도 추천", "HOLD": "관망"}.get(recommendation, "관망")
_stock_name = await _resolve_stock_name(stock_code, _name_from_kis_output(output))
return {
"stock_code": stock_code,
"stock_name": output.get("hts_kor_isnm", "") or output.get("stck_prdt_name", ""),
"stock_name": _stock_name,
"action": recommendation,
"headline": _headline,
"current_price": current_price,
"high_price": high_price,
"low_price": low_price,
@@ -976,7 +1426,7 @@ async def get_ai_analysis(stock_code: str, db: AsyncSession = Depends(get_db)):
# 매수 추천 금액 (실제 자동매매 사이징과 동일 기준)
**_suggest_buy_amount(current_price, recommendation, confidence, stock_code),
# 목표가·손절가 (CLAUDE.md §5.2)
**_suggest_targets(current_price, _atr_for_targets),
**_targets,
"analyzed_at": datetime.now(_KST).isoformat(), # KST +09:00 포함
}

View File

@@ -90,8 +90,20 @@ async def _get_playbook() -> dict:
async def _get_news_sentiment(stock_code: Optional[str] = None) -> dict:
"""news_sentiment.get_market_news_score() 호출. 파일 없으면 빈 결과."""
try:
from app.services.news_sentiment import get_market_news_score # type: ignore
return await get_market_news_score(stock_code=stock_code)
from app.services.news_sentiment import ( # type: ignore
get_market_news_score,
get_stock_news_score,
)
if stock_code:
score = float(await get_stock_news_score(stock_code))
else:
score = float(await get_market_news_score())
return {
"market_score": score,
"top_positive": [],
"top_negative": [],
"reason": None,
}
except ImportError:
return {
"market_score": None,

View File

@@ -32,6 +32,7 @@ from datetime import datetime, timedelta
from app.core.timeutil import now_kst
from app.core.log_throttle import warn_throttled # noqa: E402
from app.core.background_tasks import spawn_tracked_v2 # noqa: E402
logger = logging.getLogger(__name__)
@@ -79,6 +80,26 @@ _SEEDED_AT_ENTRY_REASON_PREFIX = "외부 유입 포지션 — 동기화 시 자
# (PENDING 을 넣으면 실현손익이 오염된다) 여기는 "매수 시도 흔적이 있는가"로
# 의미가 다르다. 같은 상수로 묶으면 한쪽을 고칠 때 반대쪽이 조용히 망가진다.
_NON_FILL_BUY_STATUSES = ("FAILED",)
from app.core.trade_status import REALIZED_PNL_STATUSES # noqa: E402 (상태 집합 단일 출처)
# ── 팬텀 ActiveTrade close 유예 (2026-10-09) ─────────────────────────────
# 매수/분할매도 직후 KIS 잔고·Portfolio 반영이 늦으면 held=0 으로 보여 갓 만든 행이
# 즉시 closed 되고 'synced' 로 재시딩되어 remaining_pct·전략명을 잃었다.
_PHANTOM_CLOSE_GRACE_SEC = 300.0 # 행 생성/수정 후 이 시간 안에는 close 하지 않는다
_PHANTOM_CLOSE_CONFIRM_SYNCS = 2 # 연속 미관측 횟수
_PHANTOM_RECENT_TRADE_SEC = 1800.0 # 이 시간 내 체결(BUY/SELL)이 있으면 보류
_PHANTOM_INHERIT_WINDOW_SEC = 1800.0 # 재시딩 시 승계 대상 closed 행의 최대 경과
_PHANTOM_INHERIT_SELL_LOOKBACK_SEC = 120.0 # close 직전 이 시간 안의 SELL 도 그 close 의 매도로 본다
_PHANTOM_INHERIT_MAX_ENTRY_DIVERGENCE = 0.03 # 옛 진입가-현재 평단 괴리가 이를 넘으면 다른 포지션으로 본다
_INHERIT_ENTRY_REASON_PREFIX = "외부 유입 포지션 — 재시딩 승계: " # exit_service 시딩 표식 접두사와 동일 계열
_phantom_miss_state: dict = {} # {stock_code: 연속 미관측 횟수}
def _phantom_state_reset(stock_code: str = None) -> None:
if stock_code is None:
_phantom_miss_state.clear()
else:
_phantom_miss_state.pop(stock_code, None)
# ── 유입 잔고 강등(swing → external) 확정 규칙 ──────────────────────────────
# [2026-09-30] 사용자 결정: "스윙으로 편입하는 건 없어야 해" → 승격뿐 아니라 강등도
@@ -645,11 +666,127 @@ def _should_correct_active_trade_quantity(
return True
async def _recent_closed_non_dt_row_v2(db, stock_code: str, now=None):
"""같은 종목에서 최근(_PHANTOM_INHERIT_WINDOW_SEC 이내) closed 된 비-DT ActiveTrade 중 최신 1행. 없으면/실패 시 None.
읽기 전용 SELECT (sync_portfolio 의 기존 트랜잭션 안).
"""
try:
from app.services.db_worker import not_dt_strategy_clause
_n = now or _get_kst_now()
# 오승계 방지: 전량 청산 후 다른 경로로 재편입한 포지션이 옛 단계를 물려받지 않도록 짧게.
_since = _n - timedelta(seconds=_PHANTOM_INHERIT_WINDOW_SEC)
r = await db.execute(
select(ActiveTrade).where(
ActiveTrade.stock_code == stock_code,
ActiveTrade.status == "closed",
ActiveTrade.updated_at >= _since,
not_dt_strategy_clause(ActiveTrade.strategy_name),
).order_by(ActiveTrade.id.desc()).limit(1)
)
return r.scalars().first()
except Exception as e:
logger.warning(f"[ActiveTrade] 최근 closed 행 조회 실패({stock_code}): {e}")
return None
async def _closed_row_was_real_exit_v2(db, row, now=None):
"""closed 행이 실제 전량 청산(SELL 체결)으로 닫혔는가. True/False, 판정 불가면 None.
팬텀 close 는 SELL 없이 닫힌다(최근 체결이 있으면 close 자체가 유예됨). close 시각
(updated_at) 직전 _PHANTOM_INHERIT_SELL_LOOKBACK_SEC 이후의 비-DT COMPLETED SELL 수량
합이 행 수량 이상이면 실제 전량 청산 → 승계 대상이 아니다. 읽기 전용 SELECT.
"""
try:
from app.services.db_worker import not_dt_strategy_clause
_closed_at = getattr(row, "updated_at", None) or getattr(row, "created_at", None)
_qty = int(getattr(row, "quantity", 0) or 0)
if _closed_at is None or _qty <= 0:
return None
_lo = _closed_at - timedelta(seconds=_PHANTOM_INHERIT_SELL_LOOKBACK_SEC)
r = await db.execute(
select(TradeHistory.executed_quantity, TradeHistory.order_quantity).where(
TradeHistory.stock_code == row.stock_code,
TradeHistory.order_type == "SELL",
TradeHistory.status == "COMPLETED",
TradeHistory.created_at >= _lo,
not_dt_strategy_clause(TradeHistory.strategy_type),
)
)
_sold = 0
for _eq, _oq in r.all():
_sold += int(_eq if _eq is not None else (_oq or 0))
return _sold >= _qty
except Exception as e:
logger.warning(f"[ActiveTrade] closed 행 실청산 여부 조회 실패({getattr(row, 'stock_code', '?')}): {e}")
return None
async def _inheritable_closed_row_v3(db, stock_code: str, avg_price: float, now=None):
"""재시딩 승계 대상 행. 최근 closed 비-DT 행 중 (a) 실제 전량 청산이 아니고(판정 불가도 제외)
(b) 옛 진입가가 현재 평단과 크게 다르지 않은 것만. 없으면 None."""
_row = await _recent_closed_non_dt_row_v2(db, stock_code, now)
if _row is None:
return None
_real = await _closed_row_was_real_exit_v2(db, _row, now)
if _real is not False:
logger.warning(
f"[ActiveTrade] 재시딩 승계 건너뜀({stock_code}): 이전 행 id={_row.id} "
f"{'실제 전량 청산' if _real else '청산 여부 불명'} — 새 포지션으로 취급"
)
return None
_ep = float(getattr(_row, "entry_price", 0) or 0)
_avg = float(avg_price or 0)
if _ep > 0 and _avg > 0 and abs(_ep - _avg) / _avg > _PHANTOM_INHERIT_MAX_ENTRY_DIVERGENCE:
logger.warning(
f"[ActiveTrade] 재시딩 승계 건너뜀({stock_code}): 옛 진입가 {_ep:,.0f} 와 평단 {_avg:,.0f} 괴리 "
f"> {_PHANTOM_INHERIT_MAX_ENTRY_DIVERGENCE:.0%} — 다른 포지션"
)
return None
return _row
def _inherited_entry_reason_v2(reason: str) -> str:
"""승계 행의 entry_reason 에 시딩 표식 접두사를 붙인다 — created_at 이 재시딩 시각이므로
swing/exit_service._is_seeded_active_trade_v2 가 인식해 원 BUY 일자로 시계·승패를 보정하게 한다."""
if reason.startswith(("동기화 유입", "외부 유입 포지션")):
return reason
return _INHERIT_ENTRY_REASON_PREFIX + reason
def _phantom_close_defer_reason(code, row, recent_trade_codes, now=None):
"""보유 0 으로 보이는 단일 active 행을 지금 close 해도 되는가. 유예 사유 문자열 or None.
recent_trade_codes=None 은 "최근 체결 조회 실패" → 확인 불가이므로 보류(fail-safe).
"""
if recent_trade_codes is None:
return "recent_trades_unknown"
if code in recent_trade_codes:
return "recent_trade"
_now = now or _get_kst_now()
_ts = getattr(row, "updated_at", None) or getattr(row, "created_at", None)
if _ts is not None:
try:
if (_now - _ts).total_seconds() < _PHANTOM_CLOSE_GRACE_SEC:
return "row_recent"
except Exception as e: # tz 혼재 등 — 판단 불가는 보류
logger.warning(f"[ActiveTrade] 행 나이 계산 실패({code}): {e}")
return "row_age_unknown"
_n = _phantom_miss_state.get(code, 0) + 1
_phantom_miss_state[code] = _n
if _n < _PHANTOM_CLOSE_CONFIRM_SYNCS:
return f"miss_{_n}"
return None
def _reconcile_active_trade_quantities(
active_rows_by_code: dict,
held_qty: dict,
strategy_by_code: dict,
system_qty: dict = None,
phantom_guard: bool = False,
recent_trade_codes: set = None,
now: datetime = None,
) -> list:
"""active ActiveTrade 행을 실제 보유 수량에 맞춘다 (ORM 객체를 **제자리 수정**).
@@ -678,7 +815,19 @@ def _reconcile_active_trade_quantities(
for _code, _rows in (active_rows_by_code or {}).items():
_actual = int(held_qty.get(_code, 0) or 0)
_tag = strategy_by_code.get(_code, "swing")
if _actual > 0:
_phantom_miss_state.pop(_code, None)
if len(_rows) == 1 and _actual == 0:
if phantom_guard:
_defer = _phantom_close_defer_reason(
_code, _rows[0], recent_trade_codes, now
)
if _defer:
actions.append({"action": "close_deferred", "stock_code": _code,
"from": int(_rows[0].quantity or 0), "to": 0,
"reason": _defer})
continue
_phantom_miss_state.pop(_code, None)
_rows[0].status = "closed"
actions.append({"action": "closed", "stock_code": _code,
"from": int(_rows[0].quantity or 0), "to": 0})
@@ -799,6 +948,15 @@ def _calculate_portfolio_values(holdings: list) -> dict:
}
def _quantize_price_v2(value) -> Decimal:
"""가격 반올림 정책 — 소수 2자리 ROUND_HALF_UP (DB Numeric(15,2) 와 동일)."""
from decimal import ROUND_HALF_UP, InvalidOperation
try:
return Decimal(str(value or 0)).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
except (InvalidOperation, ValueError, TypeError):
return Decimal("0")
def _extract_balance_data(balance_data: dict) -> dict:
"""Extract cash and total assets from KIS API balance response.
@@ -898,13 +1056,107 @@ def _extract_overseas_balance(overseas_balance_data: dict) -> dict:
if isinstance(ovrs_out2, list):
ovrs_out2 = ovrs_out2[0] if ovrs_out2 else {}
# tot_evlu_amt: 해외주식 평가금액 합계 (원화)
overseas_total_krw = _pick_float(ovrs_out2, ["tot_evlu_amt", "frcr_evlu_tota"])
# [2026-10-08] frcr_evlu_tota 는 **외화(USD)** 평가금액이라 원화 폴백으로 쓰면 단위가 섞인다
# → 제거. 원화 값이 없으면 cache_summary 가 보유 USD × 환율로 환산한다.
overseas_total_krw = _pick_float(ovrs_out2, ["tot_evlu_amt"])
if overseas_total_krw > 0:
logger.debug(f"💵 해외 잔고 합산: {overseas_total_krw:,.0f}원")
return {"overseas_total_krw": overseas_total_krw}
# ── [2026-10-08] USD/KRW 환율 해석 ────────────────────────────────────────────
# 이전엔 sync_portfolio 가 cache_summary(balance, overseas) 로 환율 인자를 넘기지 않아 항상 0 →
# 미국 보유 원화 환산이 0 이었다. 우선순위: KIS 응답에서 얻은 환율 → 마지막 성공값 →
# settings.US_DEFAULT_FX_RATE → (모두 없으면) 0 = 환산 생략 + exchange_rate_unavailable 플래그.
# 해외잔고(TTTS3012R) 응답에는 환율 필드가 없을 수 있어 실제로는 config 폴백이 흔하다.
_FX_RATE_SANE_MIN = 500.0
_FX_RATE_SANE_MAX = 3000.0
_last_good_fx_rate: float = 0.0
def _fx_from_overseas_balance(overseas_balance_data: dict | None) -> float:
"""해외잔고 응답에서 USD/KRW 환율 추출 (없으면 0). 순수 함수."""
if not isinstance(overseas_balance_data, dict):
return 0.0
out1 = overseas_balance_data.get("output1")
out2 = overseas_balance_data.get("output2")
if isinstance(out2, list):
out2 = out2[0] if out2 else {}
out2 = out2 if isinstance(out2, dict) else {}
rows = out1 if isinstance(out1, list) else []
# 1) 명시적 환율 필드
for src in [out2] + [r for r in rows if isinstance(r, dict)]:
v = _pick_float(src, ["bass_exrt", "frst_bltn_exrt", "exrt"])
if v > 0:
return v
# 2) 원화 총평가 / 외화 총액 역산 (두 값이 모두 있을 때만)
tot_krw = _pick_float(out2, ["tot_evlu_amt"])
usd = _pick_float(out2, ["frcr_evlu_tota", "usd_amt"]) # 첫 양수 1개(합산 시 이중 계상)
if tot_krw > 0 and usd > 0:
return tot_krw / usd
return 0.0
def _resolve_fx_rate_v2(overseas_balance_data: dict | None) -> tuple[float, str]:
"""(환율, 출처) — 출처: kis | last_good | config_default | unavailable."""
global _last_good_fx_rate
from app.core.config import settings as _settings
v = _fx_from_overseas_balance(overseas_balance_data)
if _FX_RATE_SANE_MIN <= v <= _FX_RATE_SANE_MAX:
_last_good_fx_rate = float(v)
return float(v), "kis"
if v > 0:
logger.warning(
f"[FX] KIS 응답 환율 {v:,.4f} 이 정상 범위({_FX_RATE_SANE_MIN:,.0f}~{_FX_RATE_SANE_MAX:,.0f}) 밖 — 무시"
)
if _last_good_fx_rate > 0:
return _last_good_fx_rate, "last_good"
cfg = float(getattr(_settings, "US_DEFAULT_FX_RATE", 0) or 0)
if cfg > 0:
return cfg, "config_default"
return 0.0, "unavailable"
def _cash_breakdown_v2(balance_data: dict | None, total_assets_domestic: float) -> dict:
"""[2026-10-08] 총자산·예수금 기준 분해 (순수 함수).
KIS 국내잔고 output2 의 항등식: tot_evlu_amt(총평가) = scts_evlu_amt(유가증권 평가)
+ prvs_rcdl_excc_amt(가수도정산금액 = D+2 예수금). 화면의 '예수금'(available_cash)은 매수용
**주문가능현금**(inquire-psbl-order ord_psbl_cash)이라 D+2 예수금과 다르다(미체결 매수
증거금·당일 매도대금 반영 차이) → '주식 + 예수금 ≠ 총자산' 의 원인.
반환 필드(summary 에 그대로 병합):
settlement_cash : D+2 예수금(prvs_rcdl_excc_amt) — 총자산을 구성하는 현금 기준
orderable_cash : 주문가능현금(_ord_psbl_cash, 없으면 None)
deposit_total : 예수금총액(dnca_tot_amt, 참고용)
cash_basis_gap : settlement_cash − available_cash(화면 예수금)
total_assets_residual : tot_evlu_amt − (scts_evlu_amt + prvs_rcdl_excc_amt) — 0 이 정상
cash_for_total_assets : 총자산(국내) − 주식평가(scts) — '주식 + 예수금 = 총자산' 이 정확히
성립하는 현금. 화면 예수금 표시는 이 값을 쓴다(주문가능은 별도 표기).
"""
out2 = {}
if isinstance(balance_data, dict) and balance_data.get("output2"):
o = balance_data["output2"]
out2 = (o[0] if isinstance(o, list) and o else o) if o else {}
out2 = out2 if isinstance(out2, dict) else {}
settlement = _pick_float(out2, ["prvs_rcdl_excc_amt"])
scts = _pick_float(out2, ["scts_evlu_amt"])
tot = _pick_float(out2, ["tot_evlu_amt"])
ord_raw = out2.get("_ord_psbl_cash")
orderable = float(ord_raw) if ord_raw not in (None, "") else None
residual = (tot - scts - settlement) if tot > 0 else 0.0
cash_for_total = (total_assets_domestic - scts) if (total_assets_domestic > 0 and scts >= 0 and tot > 0) else None
return {
"settlement_cash": settlement,
"orderable_cash": orderable,
"deposit_total": _pick_float(out2, ["dnca_tot_amt"]),
"total_assets_residual": residual,
"cash_for_total_assets": cash_for_total,
"total_assets_basis": "kis_tot_evlu_amt" if tot > 0 else "cash_plus_holdings",
}
# 단순 인메모리 캐시 (프로세스 단위). 다중 프로세스 환경이면 Redis 등 필요.
class PortfolioCache:
def __init__(self):
@@ -1092,6 +1344,10 @@ class PortfolioCache:
if overseas_total_krw > 0:
total_assets += overseas_total_krw
elif overseas_value_krw > 0:
# [2026-10-08] KIS 원화 총평가가 없으면 보유 USD × 환율 환산값을 더한다
# (total_value 에는 이미 포함 — 화면의 주식+예수금과 총자산을 같은 범위로 맞춘다)
total_assets += overseas_value_krw
# 5. Calculate daily profit/loss
today_profit_loss = (total_assets - bfdy_total_assets) if bfdy_total_assets > 0 else 0.0
@@ -1126,6 +1382,70 @@ class PortfolioCache:
async with self.summary_lock:
self.summary = summary_data
async def cache_summary_v2(
self,
balance_data: dict = None,
overseas_balance_data: dict = None,
exchange_rate: float | None = None,
exchange_rate_source: str | None = None,
):
"""[2026-10-08] cache_summary + 환율 해석 + 현금 기준 분해 필드.
exchange_rate 가 None 이면 `_resolve_fx_rate_v2` 로 정한다(마지막 성공값/설정값 폴백).
0 이면 해외 원화 환산을 생략하고 exchange_rate_unavailable=True 로 표시한다.
"""
if exchange_rate is None:
exchange_rate, exchange_rate_source = _resolve_fx_rate_v2(overseas_balance_data)
exchange_rate = float(exchange_rate or 0)
# 설정 기본값(1350 등)은 추정치다 — 총자산(=4% 사이징·당일손익 기준)에 합산하면
# 실환율과 어긋난 만큼 리스크 지표가 왜곡되고 화면은 확정값처럼 보인다. 합산에서 제외하고
# (KIS 가 준 원화 총평가가 있으면 그것만 합산) 추정 환율은 별도 필드로만 노출한다.
_fx_estimate = 0.0
if exchange_rate_source == "config_default" and exchange_rate > 0:
_fx_estimate = exchange_rate
exchange_rate = 0.0
await self.cache_summary(balance_data, overseas_balance_data, exchange_rate)
_has_overseas = any(
not _is_domestic(h.stock_code, getattr(h, "market", None))
for h in (self.holdings or []) if (h.quantity or 0) > 0
)
_unavailable = exchange_rate <= 0
if _unavailable and _has_overseas:
warn_throttled(
logger, "portfolio:fx_unavailable",
"[FX] USD/KRW 환율 없음(KIS·마지막 성공값·설정값 모두 0) — 해외 보유 원화 환산 생략",
)
# _extract_balance_data 는 INFO 로그 3줄을 남기므로 재호출하지 않는다 — 원화 총평가는
# _cash_breakdown_v2 가 같은 output2 에서 직접 읽는다.
_kis_total = 0.0
if isinstance(balance_data, dict) and balance_data.get("output2"):
_o2 = balance_data["output2"]
_o2 = (_o2[0] if isinstance(_o2, list) and _o2 else _o2) or {}
_kis_total = _pick_float(_o2, ["tot_evlu_amt"]) if isinstance(_o2, dict) else 0.0
extra = {
"exchange_rate_source": exchange_rate_source or ("unavailable" if _unavailable else "caller"),
"exchange_rate_unavailable": bool(_unavailable),
"fx_conversion_skipped": bool(_unavailable and _has_overseas),
}
if _fx_estimate > 0:
extra["exchange_rate_estimate"] = _fx_estimate
extra["exchange_rate_source"] = "config_default"
if balance_data:
extra.update(_cash_breakdown_v2(balance_data, _kis_total))
if abs(float(extra.get("total_assets_residual") or 0)) > 1000:
warn_throttled(
logger, "portfolio:total_assets_residual",
f"[Portfolio] 총자산 항등식 불일치: tot_evlu_amt − (scts_evlu_amt + prvs_rcdl_excc_amt) = "
f"{float(extra['total_assets_residual']):,.0f}원 — KIS 필드 정의 확인 필요",
)
async with self.summary_lock:
if self.summary is not None:
self.summary.update(extra)
if "settlement_cash" in extra:
_avail = float(self.summary.get("available_cash") or 0)
self.summary["cash_basis_gap"] = float(extra.get("settlement_cash") or 0) - _avail
portfolio_cache = PortfolioCache()
from pydantic import BaseModel
@@ -1143,11 +1463,131 @@ class PortfolioResponse(BaseModel):
profit_loss_rate: Optional[Decimal] = None
market: str = "KRX"
strategy_type: str = "swing"
# [2026-10-08] 전략별 분해 (기존 필드 의미 불변 — quantity/average_price 는 계좌 합산·혼합 평단).
# `_annotate_strategy_breakdown_v2` 가 채운다. 정의는 그 함수 docstring 참조.
swing_quantity: Optional[int] = None
swing_avg_price: Optional[float] = None
swing_avg_price_basis: Optional[str] = None
swing_profit_loss: Optional[float] = None
swing_profit_loss_rate: Optional[float] = None
dt_quantity: Optional[int] = None
dt_avg_price: Optional[float] = None
avg_price_mixed: Optional[bool] = None
class Config:
from_attributes = True
_BREAKDOWN_FIELDS: tuple[str, ...] = (
"swing_quantity", "swing_avg_price", "swing_avg_price_basis",
"swing_profit_loss", "swing_profit_loss_rate",
"dt_quantity", "dt_avg_price", "avg_price_mixed",
)
def _round_price_v2(value) -> Optional[float]:
"""API 응답 가격 반올림 정책: 소수 2자리(DB Numeric(15,2) 와 동일). 0/None → None."""
try:
v = float(value or 0)
except (TypeError, ValueError):
return None
return round(v, 2) if v > 0 else None
def _strategy_breakdown_v2(holding, swing_entry_price, dt_pos: dict | None) -> dict:
"""[2026-10-08] 보유 행 1개의 전략별 수량·평단 (순수 함수).
Portfolio 행은 stock_code UNIQUE 라 스윙+DT 가 합산돼 있고 평단도 혼합된다(§3.5).
dt_quantity / dt_avg_price : DT 엔진 인메모리 포지션(수량·체결가 보정된 진입가)
swing_quantity : 행 수량 − DT 수량 (DT 아닌 보유분: swing/external/manual 포함)
swing_avg_price : 스윙 ActiveTrade.entry_price (resolve_swing_entry_price_v2 —
괴리 30% 초과면 보수적 max, 없으면 Portfolio 평단 폴백)
swing_avg_price_basis : active_trade | conservative_max | portfolio_avg | none
swing_profit_loss(_rate) : (현재가 − swing_avg_price) × swing_quantity, 비용 전
avg_price_mixed : DT 보유가 있거나 스윙 진입가가 Portfolio 평단과 다르면 True
"""
from app.services.swing.position_manager import resolve_swing_entry_price_v2
qty = int(getattr(holding, "quantity", 0) or 0)
dt_qty = int((dt_pos or {}).get("quantity", 0) or 0)
dt_qty = max(0, min(dt_qty, qty))
dt_avg = _round_price_v2((dt_pos or {}).get("entry_price")) if dt_qty > 0 else None
swing_qty = max(0, qty - dt_qty)
pa = float(getattr(holding, "average_price", 0) or 0)
cur = float(getattr(holding, "current_price", 0) or 0)
swing_avg = None
basis = "none"
pl = None
pl_rate = None
if swing_qty > 0:
if dt_qty > 0 and not swing_entry_price:
# DT 공존 + 스윙 진입가 출처 없음 → 혼합 평단에서 DT 분을 빼서 역산
_rest = pa * qty - float((dt_pos or {}).get("entry_price") or 0) * dt_qty
_derived = _rest / swing_qty if swing_qty > 0 else 0.0
if _derived > 0:
swing_avg, basis = _derived, "derived_from_mixed"
if swing_avg is None:
swing_avg, basis = resolve_swing_entry_price_v2(
swing_entry_price, pa, getattr(holding, "stock_code", ""),
)
swing_avg = _round_price_v2(swing_avg)
if swing_avg and cur > 0:
pl = round((cur - swing_avg) * swing_qty, 2)
pl_rate = round((cur / swing_avg - 1.0) * 100, 4)
mixed = dt_qty > 0 or (swing_avg is not None and pa > 0 and abs(swing_avg - round(pa, 2)) >= 0.01)
return {
"swing_quantity": swing_qty,
"swing_avg_price": swing_avg,
"swing_avg_price_basis": basis,
"swing_profit_loss": pl,
"swing_profit_loss_rate": pl_rate,
"dt_quantity": dt_qty,
"dt_avg_price": dt_avg,
"avg_price_mixed": bool(mixed),
}
async def _annotate_strategy_breakdown_v2(db: AsyncSession, holdings: list) -> list:
"""보유 ORM 객체에 전략별 분해 필드를 **비매핑 속성**으로 붙인다(DB 쓰기 없음).
API 응답 직렬화(PortfolioResponse / jsonable_encoder)가 이 속성을 그대로 내보낸다.
조회 실패 시 warning 후 원본 그대로 반환(화면 표시용 — 매매 판정 경로 아님).
"""
if not holdings:
return holdings
try:
from app.services.db_worker import not_dt_strategy_clause
codes = [h.stock_code for h in holdings]
rows = (await db.execute(
select(ActiveTrade.stock_code, ActiveTrade.entry_price, ActiveTrade.id)
.where(
ActiveTrade.status == "active",
ActiveTrade.stock_code.in_(codes),
not_dt_strategy_clause(ActiveTrade.strategy_name),
)
.order_by(ActiveTrade.id.desc())
)).all()
swing_entry: dict = {}
for code, entry, _id in rows:
swing_entry.setdefault(code, entry) # 최신 행 우선(get_swing_active_trade 와 동일)
except Exception as e:
warn_throttled(logger, "portfolio:breakdown", f"[Portfolio] 전략별 평단 조회 실패 — 분해 필드 생략: {e}")
return holdings
try:
from app.services.daytrading.position_manager import day_trading_state as _dts
dt_positions = dict(_dts.positions)
except Exception as e:
warn_throttled(logger, "portfolio:breakdown_dt", f"[Portfolio] DT 인메모리 조회 실패 — DT 0 으로 표시: {e}")
dt_positions = {}
for h in holdings:
try:
info = _strategy_breakdown_v2(h, swing_entry.get(h.stock_code), dt_positions.get(h.stock_code))
for k, v in info.items():
setattr(h, k, v)
except Exception as e:
warn_throttled(logger, "portfolio:breakdown_row", f"[Portfolio] {h.stock_code} 전략별 분해 실패: {e}")
return holdings
async def _get_latest_executed_trade_id(db: AsyncSession) -> int | None:
try:
result = await db.execute(
@@ -1170,7 +1610,9 @@ async def get_portfolio(db: AsyncSession = Depends(get_db)):
if portfolio_cache.holdings and not portfolio_cache.should_sync():
logger.debug("Portfolio cache hit (holdings)")
# 수량 0인 종목 필터링 (안전장치)
return [h for h in portfolio_cache.holdings if h.quantity > 0]
return await _annotate_strategy_breakdown_v2(
db, [h for h in portfolio_cache.holdings if h.quantity > 0]
)
# 필요하면 동기화 시도
try:
await sync_portfolio(db, force=False)
@@ -1178,7 +1620,9 @@ async def get_portfolio(db: AsyncSession = Depends(get_db)):
# 강제 동기화가 아닌 경우에는 오류를 사용자에게 전파하지 않고 캐시/빈 목록 반환
logger.warning("get_portfolio: sync failed, returning cached holdings")
# 수량 0인 종목 필터링 (안전장치)
return [h for h in portfolio_cache.holdings if h.quantity > 0]
return await _annotate_strategy_breakdown_v2(
db, [h for h in portfolio_cache.holdings if h.quantity > 0]
)
@router.get("/sync")
@@ -1258,7 +1702,7 @@ async def sync_portfolio(db: AsyncSession = Depends(get_db), force: bool = True)
# 현금 계산이 영영 막히지 않도록 holdings 기반 부분값(available_cash=0)이라도 채워둔다.
if portfolio_cache.summary is None:
try:
await portfolio_cache.cache_summary() # available_cash=0이지만 holdings는 즉시 반영
await portfolio_cache.cache_summary_v2() # available_cash=0이지만 holdings는 즉시 반영
logger.info("📦 summary 최초 미존재 — holdings 기반 부분값으로 채움 (available_cash=0)")
except Exception as summary_err:
logger.warning(f"⚠️ 부분 summary 생성 실패 (summary는 None 유지): {summary_err}")
@@ -1369,6 +1813,34 @@ async def sync_portfolio(db: AsyncSession = Depends(get_db), force: bool = True)
# db_worker 큐 적체 여부 — 강등 보류 판단(사이클당 1회 조회).
_queue_busy = _db_queue_busy()
# 최근 체결 종목(팬텀 ActiveTrade close 유예용) — 조회 실패 시 None(= close 보류).
_recent_trade_codes = None
try:
_rt_since = now - timedelta(seconds=_PHANTOM_RECENT_TRADE_SEC)
_rt_rows = await db.execute(
select(TradeHistory.stock_code).where(
TradeHistory.executed_at >= _rt_since,
TradeHistory.status != "FAILED",
).distinct()
)
_recent_trade_codes = {c for c in _rt_rows.scalars().all() if c}
except Exception as _rt_err:
logger.warning(f"최근 체결 조회 실패 — ActiveTrade close 보류: {_rt_err}")
# DT active ActiveTrade 종목(DB) — 'daytrading' 태그 해제 판단 근거. 실패 시 None(해제 안 함).
_dt_db_codes = None
try:
from app.services.db_worker import DT_STRATEGY_NAMES as _DT_NAMES
_dtdb = await db.execute(
select(ActiveTrade.stock_code).where(
ActiveTrade.status == "active",
ActiveTrade.strategy_name.in_(list(_DT_NAMES)),
)
)
_dt_db_codes = {c for c in _dtdb.scalars().all() if c}
except Exception as _dtdb_err:
logger.warning(f"DT ActiveTrade 조회 실패 — daytrading 태그 해제 보류: {_dtdb_err}")
def _classify_durable(_code: str, _qty: int) -> str:
"""레지스트리를 **제외한** 근거만으로 판정 — 승격 판정 전용 (M-2).
@@ -1454,7 +1926,10 @@ async def sync_portfolio(db: AsyncSession = Depends(get_db), force: bool = True)
logger.debug(f"Skipping {stock_name}({stock_code}) with 0 quantity")
continue
average_price = Decimal(str(item.get("pchs_avg_pric", 0)))
# [2026-10-08] KIS pchs_avg_pric 는 소수 4자리("67323.0769")다. DB 컬럼은
# Numeric(15,2) 라 커밋 후엔 2자리인데 캐시 ORM 객체는 원문을 들고 있어 화면에
# 67,323.076 처럼 보였다 → 저장 규약(소수 2자리, 반올림)으로 맞춘다.
average_price = _quantize_price_v2(item.get("pchs_avg_pric", 0))
# 실시간 현재가 (병렬 조회 결과 참조)
price_resp = _realtime_price_map.get(stock_code)
@@ -1495,6 +1970,21 @@ async def sync_portfolio(db: AsyncSession = Depends(get_db), force: bool = True)
# 전량이 DT 포지션일 때만 태깅(혼재 시 스윙 청산 대상 유지)
if stock_code in _dt_codes and quantity <= _dt_qty.get(stock_code, 0):
h.strategy_type = "daytrading"
elif (
(h.strategy_type or "swing") == "daytrading"
and stock_code not in _dt_codes
and _dt_db_codes is not None
and stock_code not in _dt_db_codes
):
# DT 인메모리·DB active 모두 없는데 태그만 남았다(비-DT 잔량/유입 잔존).
# 'daytrading' 이면 스윙 청산·DT 고아 청산·추천 매도 모두에서 빠져
# 손절이 무력화된다(105560 KB금융). durable 근거로 재분류한다.
_re = _classify_durable(stock_code, quantity)
logger.warning(
f"🏷️ daytrading 태그 해제: {stock_name}({stock_code}) {quantity}주 "
f"daytrading → {_re} (DT 포지션 없음)"
)
h.strategy_type = _re
elif (
(h.strategy_type or "swing") == EXTERNAL_STRATEGY_TYPE
and _classify_durable(stock_code, quantity) == "swing"
@@ -1575,7 +2065,10 @@ async def sync_portfolio(db: AsyncSession = Depends(get_db), force: bool = True)
stock_name = item.get("ovrs_item_name", stock_code)
exchange_code = item.get("ovrs_excg_cd", "NASD")
average_price = Decimal(str(item.get("pchs_avg_pric", 0)))
# [2026-10-08] KIS pchs_avg_pric 는 소수 4자리("67323.0769")다. DB 컬럼은
# Numeric(15,2) 라 커밋 후엔 2자리인데 캐시 ORM 객체는 원문을 들고 있어 화면에
# 67,323.076 처럼 보였다 → 저장 규약(소수 2자리, 반올림)으로 맞춘다.
average_price = _quantize_price_v2(item.get("pchs_avg_pric", 0))
# 실시간 현재가
price_resp = _ovrs_price_map.get(stock_code)
@@ -1691,12 +2184,20 @@ async def sync_portfolio(db: AsyncSession = Depends(get_db), force: bool = True)
h.stock_code: (h.strategy_type or "swing") for h in new_holdings
}
for _act in _reconcile_active_trade_quantities(
_by_code, _held, _strategy_by_code, _system_qty_durable
_by_code, _held, _strategy_by_code, _system_qty_durable,
phantom_guard=True,
recent_trade_codes=_recent_trade_codes,
now=now,
):
if _act["action"] == "closed":
logger.info(
f"🧹 ActiveTrade 정리: {_act['stock_code']} 보유 없음 → closed"
)
elif _act["action"] == "close_deferred":
logger.info(
f"⏳ ActiveTrade 정리 유예: {_act['stock_code']} 보유 0 관측 "
f"({_act.get('reason')})"
)
elif _act["action"] == "corrected":
logger.info(
f"🧹 ActiveTrade 잔량 교정: {_act['stock_code']} "
@@ -1753,6 +2254,7 @@ async def sync_portfolio(db: AsyncSession = Depends(get_db), force: bool = True)
if _seed_name == "swing":
_seed_name = "synced"
# DLQ 직렬화(json) 대비 — Decimal 대신 순수 float/int/str 만 담는다.
_inh = await _inheritable_closed_row_v3(db, _h.stock_code, _avg, now)
_seed_payload = {
"stock_code": _h.stock_code,
"stock_name": _h.stock_name,
@@ -1768,6 +2270,33 @@ async def sync_portfolio(db: AsyncSession = Depends(get_db), force: bool = True)
"currency_code": _h.currency_code or "KRW",
"exchange_code": _h.exchange_code,
}
if _inh is not None:
# 방금 닫힌 비-DT 행의 분할 단계·전략명·진입근거를 승계 — 안 하면
# remaining_pct 가 1.0 으로 되돌아 1차 익절이 재실행되고 모멘텀/눌림목이
# 평균회귀 청산을 탄다. (created_at 은 payload 로 못 넣어 승계 불가 → entry_reason 시딩 접두사로 원 BUY 일자 보정)
_inh_rem = float(_inh.remaining_pct if _inh.remaining_pct is not None else 1.0)
_seed_payload["remaining_pct"] = max(0.0, min(1.0, _inh_rem))
if _inh.strategy_name:
_seed_payload["strategy_name"] = _inh.strategy_name
if _inh.entry_reason:
_seed_payload["entry_reason"] = _inherited_entry_reason_v2(_inh.entry_reason)
_inh_hi = float(_inh.highest_since_entry or 0)
if _inh_hi > _seed_payload["highest_since_entry"]:
_seed_payload["highest_since_entry"] = _inh_hi
_inh_sl = float(_inh.stop_loss or 0)
if _inh_sl > 0:
_seed_payload["stop_loss"] = _inh_sl
_inh_tp = float(_inh.take_profit or 0)
if _inh_tp > 0:
_seed_payload["take_profit"] = _inh_tp
_inh_ep = float(_inh.entry_price or 0)
if _inh_ep > 0:
_seed_payload["entry_price"] = _inh_ep
logger.warning(
f"♻️ ActiveTrade 재시딩 승계: {_h.stock_code} 이전 행 id={_inh.id} "
f"remaining={_seed_payload['remaining_pct']} "
f"strategy={_seed_payload['strategy_name']}"
)
_seed_uid = getattr(_h, "user_id", None)
if _seed_uid is not None:
_seed_payload["user_id"] = int(_seed_uid)
@@ -1832,7 +2361,8 @@ async def sync_portfolio(db: AsyncSession = Depends(get_db), force: bool = True)
)
portfolio_cache.holdings = [h for h in new_holdings if h.quantity > 0]
await portfolio_cache.cache_summary(balance_data, overseas_balance_data) # 국내·해외 잔고 전달
# [2026-10-08] v2 — 환율(0 금지: 마지막 성공값/설정값 폴백) + 현금 기준 분해 필드
await portfolio_cache.cache_summary_v2(balance_data, overseas_balance_data) # 국내·해외 잔고 전달
portfolio_cache.last_synced = datetime.now()
portfolio_cache.change_flag = False
portfolio_cache.last_trade_id = await _get_latest_executed_trade_id(db)
@@ -1887,7 +2417,7 @@ async def get_portfolio_summary(db: AsyncSession = Depends(get_db)):
realized_result = await db.execute(
select(func.sum(TradeHistory.net_profit_loss)).where(
TradeHistory.order_type == "SELL",
TradeHistory.status.in_(["EXECUTED", "COMPLETED"]),
TradeHistory.status.in_(list(REALIZED_PNL_STATUSES)),
TradeHistory.net_profit_loss.isnot(None),
)
)
@@ -1945,6 +2475,13 @@ def _build_combined_resp() -> dict:
@router.get("/combined")
async def get_portfolio_combined(db: AsyncSession = Depends(get_db)):
"""보유종목 + 요약 — [2026-10-08] holdings 에 전략별 분해 필드를 붙인다(_impl 은 기존 본체)."""
resp = await _get_portfolio_combined_impl(db)
await _annotate_strategy_breakdown_v2(db, resp.get("holdings") or [])
return resp
async def _get_portfolio_combined_impl(db: AsyncSession):
"""보유종목 목록 + 요약을 반환.
응답 우선순위:
@@ -1965,7 +2502,7 @@ async def get_portfolio_combined(db: AsyncSession = Depends(get_db)):
# ② 캐시 만료, 매매 변경 없음 → stale 데이터 즉시 반환 + 백그라운드 갱신
if portfolio_cache.holdings and portfolio_cache.summary and not portfolio_cache.change_flag:
logger.debug("Portfolio cache stale (no trade) → returning cached, syncing in background")
asyncio.create_task(_sync_portfolio_background())
spawn_tracked_v2(_sync_portfolio_background())
return _build_combined_resp()
# ③ 캐시 비어있음 (서버 재시작) → DB에서 즉시 로드 + 백그라운드 KIS 갱신
@@ -1974,9 +2511,9 @@ async def get_portfolio_combined(db: AsyncSession = Depends(get_db)):
db_holdings = list(result.scalars().all())
if db_holdings:
portfolio_cache.holdings = db_holdings
await portfolio_cache.cache_summary() # available_cash=0이지만 holdings는 즉시 표시
await portfolio_cache.cache_summary_v2() # available_cash=0이지만 holdings는 즉시 표시
logger.info(f"Portfolio cold-start: DB {len(db_holdings)}종목 즉시 반환, KIS 갱신 백그라운드 시작")
asyncio.create_task(_sync_portfolio_background())
spawn_tracked_v2(_sync_portfolio_background())
return _build_combined_resp()
# ④ 매매 발생 또는 DB도 비어있음 → KIS API 동기 호출
@@ -2317,7 +2854,7 @@ async def check_and_execute_exit_trades(db: AsyncSession):
logger.info(f"자동 매도 완료: {stock_name} {sell_quantity}주 @ {latest_price:,.0f}원")
# 매매일지 백그라운드 생성 — trade_id 는 워커가 order_id 로 해소
asyncio.create_task(_create_journal_background(
spawn_tracked_v2(_create_journal_background(
trade_id=None,
order_id=_order_id,
stock_code=stock_code,
@@ -2530,7 +3067,9 @@ async def sell_stock(
"order_type": "SELL",
"order_price": float(price) if price else None,
"order_quantity": quantity,
"executed_price": float(executed_price),
# [2026-10-08] 체결가는 체결 확인(swing/fill_sweep)이 채운다 — 현재가/평단을 체결가로
# 쓰지 않는다. total_amount·손익은 확인 전 추정치.
"executed_price": None,
"executed_quantity": quantity,
"total_amount": float(executed_price * quantity),
"status": "COMPLETED",
@@ -2615,7 +3154,7 @@ async def sell_stock(
logger.info(f"✅ 수동 매도 완료: {stock_name} {quantity}주 @ {executed_price:,.0f}원 (손익: {profit_loss:+,.0f}원)")
# 매매일지 백그라운드 생성 — trade_id 는 워커가 order_id 로 해소
asyncio.create_task(_create_journal_background(
spawn_tracked_v2(_create_journal_background(
trade_id=None,
order_id=order_result.get('output', {}).get('ODNO'),
stock_code=stock_code,

View File

@@ -48,7 +48,7 @@ from app.services.manual_sell_service import (
)
from app.services.tenant.context import UserContext
from app.services.tenant.registry import get_user_context, user_context_registry
from app.services.trade_executor import safe_place_order_v2
from app.services.trade_executor import _is_order_outcome_unknown_v2, safe_place_order_v2
from app.services.utils import _is_domestic
from app.api.endpoints.manual_buy import BuyRequest
@@ -376,6 +376,35 @@ async def _buy_stock_impl_v2(request: BuyRequest, stock_code: str, db: AsyncSess
ctx, stock_code=stock_code, order_type="BUY", quantity=quantity,
price=request.price, is_domestic=True,
)
# [R7 2026-10-10] manual_buy R3 와 동일: 전송 후 결과 불명은 접수됐을 수 있다 — 쿨다운 + PENDING.
if _is_order_outcome_unknown_v2(order_result):
_last_done_v2[(ctx.user_id, stock_code)] = time.monotonic()
logger.error(
f"[MANUAL_BUY v2] user_id={ctx.user_id} {stock_code} 주문 결과 불명 — "
f"쿨다운 {int(_DUP_COOLDOWN_SEC)}초, PENDING 기록. 포트폴리오 확인 전 재시도 금지"
)
_unk = {
"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": None,
"executed_quantity": 0,
"total_amount": float(order_price * quantity),
"status": "PENDING",
"order_id": None,
"message": "수동 매수 결과 불명 (v2, 접수 여부 미확인 — 포트폴리오 확인 필요)",
"strategy_type": "manual",
"created_at": _kst_now(),
}
if not ctx.is_legacy:
_unk["user_id"] = ctx.user_id
await enqueue_trade_history(_unk)
raise HTTPException(
status_code=504,
detail="매수 주문 결과 불명 — 접수·체결됐을 수 있습니다. 포트폴리오 확인 후 재시도하세요",
)
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}")
@@ -385,6 +414,8 @@ async def _buy_stock_impl_v2(request: BuyRequest, stock_code: str, db: AsyncSess
from app.services.system_buy_registry import note_system_buy
note_system_buy(stock_code, quantity)
# [R7] 접수(rt_cd 0)≠체결. 지정가는 PENDING·체결 0 으로 기록하고 Portfolio 선생성을 생략한다.
is_limit = bool(request.price)
now = _kst_now()
trade_history_payload = {
"stock_code": stock_code,
@@ -392,15 +423,17 @@ async def _buy_stock_impl_v2(request: BuyRequest, stock_code: str, db: AsyncSess
"order_type": "BUY",
"order_price": float(request.price) if request.price else None,
"order_quantity": quantity,
"executed_price": float(order_price),
"executed_quantity": quantity,
# [2026-10-08] 체결가는 체결 확인(swing/fill_sweep)이 채운다 — 주문 기준가를 체결가로 쓰지 않는다.
# total_amount 는 확인 전 추정치.
"executed_price": None,
"executed_quantity": 0 if is_limit else quantity,
"total_amount": float(order_price * quantity),
"status": "COMPLETED",
"status": "PENDING" if is_limit else "COMPLETED",
"order_id": order_id,
"message": "수동 매수 (v2, 사용자 스코프)",
"strategy_type": "manual",
"created_at": now,
"executed_at": now,
"executed_at": None if is_limit else now,
}
# M3: legacy 컨텍스트는 user_id를 payload에 넣지 않는다 — 스윙/데이트레이딩 등
# 기존 레거시 쓰기 경로가 전부 user_id 없이 쓰므로(NULL), 정합을 맞춰야
@@ -408,7 +441,7 @@ async def _buy_stock_impl_v2(request: BuyRequest, stock_code: str, db: AsyncSess
if not ctx.is_legacy:
trade_history_payload["user_id"] = ctx.user_id
await enqueue_trade_history(trade_history_payload)
if holding is None:
if holding is None and not is_limit:
portfolio_payload = {
"stock_code": stock_code,
"stock_name": stock_name,
@@ -485,6 +518,32 @@ async def sell_stock_v2(
order_result = await safe_place_order_v2(
ctx, stock_code=stock_code, order_type="SELL", quantity=quantity, price=price, is_domestic=True,
)
# [R7 2026-10-10] 결과 불명 SELL: 체결 여부를 모르므로 원장·보유 상태를 바꾸지 않고
# PENDING 만 남긴다(잘못 차감하면 재매도 과다/추적 상실). 잔고 sync 가 보정한다.
if _is_order_outcome_unknown_v2(order_result):
logger.error(f"[SELL v2] user_id={ctx.user_id} {stock_code} 주문 결과 불명 — PENDING 기록, 상태 미변경")
_unk = {
"stock_code": stock_code,
"stock_name": stock_name,
"order_type": "SELL",
"order_price": float(price) if price else None,
"order_quantity": quantity,
"executed_price": None,
"executed_quantity": 0,
"total_amount": float((price or float(holding.current_price or 0)) * quantity),
"status": "PENDING",
"order_id": None,
"message": "수동 매도 결과 불명 (v2, 접수 여부 미확인 — 포트폴리오 확인 필요)",
"strategy_type": "manual",
"created_at": _kst_now(),
}
if not ctx.is_legacy:
_unk["user_id"] = ctx.user_id
await enqueue_trade_history(_unk)
raise HTTPException(
status_code=504,
detail="매도 주문 결과 불명 — 접수·체결됐을 수 있습니다. 포트폴리오 확인 후 재시도하세요",
)
if not order_result or order_result.get("rt_cd") != "0":
error_msg = order_result.get("msg1", "알 수 없는 오류") if order_result else "API 호출 실패"
# order_lock_busy = 자동 청산 등 다른 경로가 같은 종목을 주문 중 → 재시도 가능(409)
@@ -495,28 +554,42 @@ async def sell_stock_v2(
profit_loss = (executed_price - float(average_price or 0)) * quantity
profit_loss_rate = ((executed_price / float(average_price)) - 1) * 100 if average_price else 0.0
# [R7] 접수≠체결: 지정가 매도는 PENDING·체결 0, 보유/ActiveTrade/연패 상태를 건드리지 않는다
# (미체결인데 차감하면 손절·청산 추적이 끊긴다). 체결 후 sync 가 반영한다.
is_limit_sell = bool(price)
sell_history_payload = {
"stock_code": stock_code,
"stock_name": stock_name,
"order_type": "SELL",
"order_price": float(price) if price else None,
"order_quantity": quantity,
"executed_price": float(executed_price),
"executed_quantity": quantity,
# [2026-10-08] 체결가는 체결 확인(swing/fill_sweep)이 채운다. 금액·손익은 확인 전 추정치.
"executed_price": None,
"executed_quantity": 0 if is_limit_sell else quantity,
"total_amount": float(executed_price * quantity),
"status": "COMPLETED",
"status": "PENDING" if is_limit_sell else "COMPLETED",
"order_id": (order_result.get("output") or {}).get("ODNO"),
"message": f"수동 청산 (v2): 손익 {profit_loss:+,.0f}원 ({profit_loss_rate:+.2f}%)",
"exit_reason": f"사용자 수동 청산 (손익: {profit_loss:+,.0f}원, 손익률: {profit_loss_rate:+.2f}%)",
"net_profit_loss": profit_loss,
"profit_loss_rate": profit_loss_rate,
"net_profit_loss": None if is_limit_sell else profit_loss,
"profit_loss_rate": None if is_limit_sell else profit_loss_rate,
"strategy_type": "manual",
"created_at": _kst_now(),
"executed_at": _kst_now(),
"executed_at": None if is_limit_sell else _kst_now(),
}
if not ctx.is_legacy:
sell_history_payload["user_id"] = ctx.user_id
await enqueue_trade_history(sell_history_payload)
if is_limit_sell:
logger.info(f"[SELL v2] user_id={ctx.user_id} {stock_code} 지정가 매도 접수 — 체결 확인 전 상태 미변경")
return {
"success": True,
"message": f"{stock_name} {quantity}주 지정가 매도 주문 접수 (체결 대기)",
"order_id": (order_result.get("output") or {}).get("ODNO"),
"profit_loss": 0.0,
"profit_loss_rate": 0.0,
"pending": True,
}
# [M-5 2026-10-02] ActiveTrade·Portfolio·DT 인메모리 반영.
# 기존: (1) 보유 행의 strategy_type 한 가지로만 close/upsert → 합산 행(스윙+DT)에서

View File

@@ -10,6 +10,7 @@ from fastapi import APIRouter, HTTPException, Query, Depends
from typing import List, Optional
from datetime import datetime, date, timedelta
import asyncio
import re
import logging
from collections import namedtuple
@@ -39,6 +40,228 @@ def _today_kst() -> date:
return datetime.now(_KST).date()
def _row_price_date_v2(stock) -> tuple:
"""DB 행의 (price_date 'YYYYMMDD', stale 여부). stock_analysis 에는 price_date 컬럼이 없다.
장전(09:00 이전) 갱신 행은 차트 마지막 봉이 전일이라 analysis_date 를 가격일로 쓰면 거짓이다.
갱신 시각(KST naive)이 analysis_date 당일 09:00 이전이면 가격일 미상("")+stale=True.
"""
ad = stock.analysis_date
if isinstance(ad, datetime):
ad = ad.date()
ts = getattr(stock, "updated_at", None)
if isinstance(ts, datetime) and ts < datetime(ad.year, ad.month, ad.day, 9, 0):
return "", True
return ad.strftime("%Y%m%d"), False
def _persistable_items_v2(items, today: date) -> list:
"""분석 결과 중 DB(analysis_date=오늘)에 저장해도 되는 것만 남긴다.
price_date 가 오늘이 아닌(장전 전일 봉 등) 결과를 오늘 날짜로 UPSERT 하면 소비처
(analysis_date >= today 후보 쿼리)가 전일 데이터를 오늘 것으로 읽는다. 가격일 미상("")은 유지.
"""
ts = today.strftime("%Y%m%d")
out = []
for it in items:
pd_ = str(getattr(it[2], "price_date", "") or "")
if pd_ and pd_ != ts:
continue
out.append(it)
return out
# ── 스캐너 표시용 매수 차단 판정 (2026-10-08) ────────────────────────────────
# 신호 문자열에 이미 "…(거래량부족-차단)", "이격도 과열… - 매수금지", "급등과열: …" 처럼
# 차단 사유가 섞여 있는데 화면은 이를 구분하지 못해 '추천'과 '차단'이 같은 줄에 표시됐다.
_BLOCK_MARKERS = ("차단", "매수금지", "급등과열", "이격도 과열")
def _block_reasons_v2(signals) -> list:
out = []
for sig in (signals or []):
text = str(sig)
if any(m in text for m in _BLOCK_MARKERS):
out.append(text)
return out
_VOL_RATIO_SIGNAL_RE = re.compile(r"거래량 실린 상승 \(([0-9]+(?:\.[0-9]+)?)배\)")
def _stock_volume_ratio_v2(stock):
"""종목의 거래량 배수(20일 평균 대비). 없으면 None.
StockRecommendation 에는 volume_ratio 필드가 없으므로(market_analyzer 소유),
1) 속성이 있으면 사용, 2) 없으면 market_analyzer 가 남기는 신호 문자열
'거래량 실린 상승 (N.N배)' 에서 복원한다. 이 신호는 배수>=1.5 & 상승일 때만 생성되므로
복원 불가면 None(= 배수 미확인, 호출부 fail-closed). 장중 부분거래량은 배수를 낮게 만들 뿐이라
보수적 방향으로만 틀린다.
"""
ratio = getattr(stock, "volume_ratio", None)
if ratio is not None:
return ratio
for sig in (getattr(stock, "signals", None) or []):
m = _VOL_RATIO_SIGNAL_RE.search(str(sig))
if m:
return float(m.group(1))
return None
def _generic_rule_matches_v2(rule, stock, conditions: dict) -> bool:
"""realtime 빌더 else 분기(비-MEAN_REVERSION 규칙)의 후보 판정.
[하드닝 R1] 이전에는 rule_type 을 가리지 않아 SELL 규칙까지 rule_id 가 붙은 매수 후보가
되었고, trade_executor 의 has_rule 강제매수가 '거래량부족-차단' 등 차단 신호를 덮어썼다.
- SELL(및 EXIT/STOP 계열) 규칙은 매수 후보에서 제외
- 차단 신호(_block_reasons_v2)가 있는 종목은 규칙 후보에서 제외
RSI 임계(기본 40)는 기존 값을 유지한다(변경은 백테스트 필요).
"""
if str(getattr(rule, "rule_type", "") or "").upper() in ("SELL", "EXIT", "STOP_LOSS", "TAKE_PROFIT"):
return False
if _block_reasons_v2(getattr(stock, "signals", None)):
return False
conditions = conditions or {}
has_rsi_key = ("rsi_below" in conditions) or ("rsi_threshold" in conditions)
has_vol_key = "volume_above_average" in conditions
if conditions and not (has_rsi_key or has_vol_key):
# 해석 가능한 조건 키가 하나도 없으면 규칙 의도를 알 수 없다 -> fail-closed
return False
if stock.rsi is None:
return False
# rsi_below(엄격 미만) / rsi_threshold(이하, 기존 의미). 둘 다 있으면 더 엄격한 쪽.
if has_rsi_key:
try:
if "rsi_below" in conditions and not (stock.rsi < float(conditions["rsi_below"])):
return False
if "rsi_threshold" in conditions and not (stock.rsi <= float(conditions["rsi_threshold"])):
return False
except (TypeError, ValueError):
return False
elif not (stock.rsi <= 40): # RSI 키가 없는 조건의 기존 기본 동작 유지
return False
if has_vol_key:
# 거래량 배수를 알 수 없으면(필드 미노출) 통과시키지 않는다(fail-closed).
ratio = _stock_volume_ratio_v2(stock)
try:
if ratio is None or not (float(ratio) >= float(conditions["volume_above_average"])):
return False
except (TypeError, ValueError):
return False
return True
# trade_executor 의 'ML HOLD(conf≥0.6) → 매수 차단' 게이트와 같은 신뢰도 기준(리터럴 단일화).
_ML_HOLD_BLOCK_CONF_V2 = 0.6
def _ml_block_reasons_v2(ml_signal, ml_confidence) -> list:
"""trade_executor 실제 매수 ML 게이트와 같은 정의의 ML 차단 사유.
- ML HOLD & 신뢰도 ≥ 0.6 & ML_ENSEMBLE_ENABLED → 매수 차단('ML HOLD(conf≥0.6)')
- ML SELL 은 ML_SELL_SIGNAL_ENABLED=True 일 때만 차단 사유(False 면 trade_executor 가
매수를 막지 않으므로 사유에 넣지 않는다 — 참고 정보는 ml_signal 필드로 노출).
"""
sig = str(ml_signal or "").upper()
reasons = []
try:
conf = float(ml_confidence or 0)
except (TypeError, ValueError):
conf = 0.0
if sig == "HOLD" and settings.ML_ENSEMBLE_ENABLED and conf >= _ML_HOLD_BLOCK_CONF_V2:
reasons.append(f"ML HOLD(conf≥{_ML_HOLD_BLOCK_CONF_V2:g})")
if sig == "SELL" and settings.ML_SELL_SIGNAL_ENABLED:
reasons.append("ML 신호 SELL")
return reasons
def _scanner_flags_v2(stock, rule=None) -> dict:
"""스캐너 항목의 규칙 출처·차단 사유·buy_allowed·ML 판정. 기존 필드는 건드리지 않는다.
buy_allowed = 신호 차단 사유 없음 AND ML 게이트 비차단(`_ml_block_reasons_v2`, trade_executor 와 동일 정의).
(MDD·일일한도 등 실행 시점 게이트는 여기서 판정하지 않는다 — 표시용 1차 판정)
"""
reasons = _block_reasons_v2(getattr(stock, "signals", None))
ml_reasons = _ml_block_reasons_v2(getattr(stock, "ml_signal", None), getattr(stock, "ml_confidence", None))
reasons = reasons + ml_reasons
matched = rule is not None
return {
"match_source": "rule" if matched else "fallback_high_score",
"match_source_label": "활성 규칙 매칭" if matched else "고점수 분석 종목(규칙 미충족 대체)",
"block_reasons": reasons,
"buy_allowed": not reasons,
"ml_blocks_buy": bool(ml_reasons),
}
def _batch_time_iso_v2(stocks) -> str:
"""배치 실제 스캔 시각 — KST 타임존 포함 ISO(예: 2026-10-08T09:10:03+09:00).
DB updated_at/created_at 은 KST naive(core.timeutil). 시각이 없으면 분석일 00:00 KST.
(이전: 날짜만 내려가 브라우저가 UTC 자정 → KST 09:00 으로 표시)
"""
latest = None
for st in stocks:
ts = getattr(st, "updated_at", None) or getattr(st, "created_at", None)
if isinstance(ts, datetime) and (latest is None or ts > latest):
latest = ts
if latest is None:
d = max((st.analysis_date for st in stocks), default=_today_kst())
if isinstance(d, datetime):
d = d.date()
latest = datetime(d.year, d.month, d.day)
if latest.tzinfo is None:
latest = _KST.localize(latest)
else:
latest = latest.astimezone(_KST)
return latest.isoformat()
async def _overlay_realtime_v2(result: dict) -> dict:
"""Redis 실시간 틱(tick:{code}, TTL 10s)이 있으면 현재가와 등락률을 **같은 소스**로 덮어쓴다.
배치 값(change_rate)은 배치 시점 기준이라 실시간 가격과 섞이면 서로 어긋난다.
원본 result 는 변경하지 않는다(자동매매 스냅샷과 공유될 수 있음) — 항목 dict 를 복사한다.
틱이 없는 종목은 배치 값 유지 + price_source="batch".
"""
from app.core.redis import get_json
lists = [result.get("recommendations") or [], result.get("analyzed_stock_list") or []]
for g in result.get("recommendations_by_rule") or []:
lists.append(g.get("stocks") or [])
codes = sorted({it["stock_code"] for lst in lists for it in lst if it.get("stock_code")})[:400]
if not codes:
return result
ticks = await asyncio.gather(*[get_json(f"tick:{c}") for c in codes], return_exceptions=True)
tick_by = {
c: t for c, t in zip(codes, ticks)
if isinstance(t, dict) and (t.get("price") or 0) > 0 and t.get("change_rate") is not None
}
def _apply(item: dict) -> dict:
new = dict(item)
t = tick_by.get(item.get("stock_code"))
if t:
new["batch_current_price"] = item.get("current_price")
new["batch_change_rate"] = item.get("change_rate")
new["current_price"] = int(t["price"])
new["change_rate"] = round(float(t["change_rate"]), 2)
new["price_source"] = "realtime"
else:
new["price_source"] = "batch"
return new
out = dict(result)
out["recommendations"] = [_apply(i) for i in lists[0]]
out["analyzed_stock_list"] = [_apply(i) for i in lists[1]]
out["recommendations_by_rule"] = [
{**g, "stocks": [_apply(i) for i in (g.get("stocks") or [])]}
for g in (result.get("recommendations_by_rule") or [])
]
out["realtime_overlay_count"] = len(tick_by)
return out
# 매매 규칙 namedtuple
Rule = namedtuple('Rule', ['id', 'name', 'description', 'rule_type', 'priority', 'conditions', 'actions', 'is_active'])
@@ -60,7 +283,10 @@ def _stock_analysis_to_dict(stock, rule=None, actions=None) -> dict:
"ml_score": stock.ml_score,
"ml_confidence": round(float(stock.ml_confidence), 4) if stock.ml_confidence else None,
"ml_signal": stock.ml_signal,
"price_date": stock.analysis_date.strftime("%Y%m%d"),
"price_date": _row_price_date_v2(stock)[0],
"price_stale": _row_price_date_v2(stock)[1],
"price_source": "batch",
**_scanner_flags_v2(stock, rule),
}
if rule is not None:
_a = actions or {}
@@ -167,6 +393,17 @@ async def get_recommendations(
"recommendations": recommendations[:20]
}
# 이벤트 루프는 태스크를 약참조로만 보관 — 주문 경로가 중간 GC 되지 않도록 강참조 보관
_bg_trade_tasks: set = set()
def _spawn_bg_trade_v2(coro) -> "asyncio.Task":
task = asyncio.create_task(coro)
_bg_trade_tasks.add(task)
task.add_done_callback(_bg_trade_tasks.discard)
return task
@router.get("/rule-based")
async def get_rule_based_recommendations(
realtime: bool = Query(False, description="실시간 분석 여부 (느림, 30초)"),
@@ -188,10 +425,50 @@ async def get_rule_based_recommendations(
await execute_auto_trade_background(rec_snapshot, bg_db)
except Exception as e:
logger.error(f"[_bg_trade] 백그라운드 자동매매 실행 중 예외: {e}", exc_info=True)
asyncio.create_task(_bg_trade())
_spawn_bg_trade_v2(_bg_trade())
# 표시용 실시간 보정(현재가+등락률 동일 소스). 매매 스냅샷(rec_snapshot)은 원본 유지.
if not realtime:
try:
return await _overlay_realtime_v2(result)
except Exception as e:
logger.warning(f"[rule-based] 실시간 오버레이 실패 — 배치 값 사용: {e}")
return result
def _merge_json_mean_reversion_rule_v2(rules: list) -> list:
"""DB 활성 규칙에 MEAN_REVERSION 이 없으면 trading_rules_config.json 의 MR 규칙을 병합한다.
운영 DB 에는 BUY/SELL/SELL 3개뿐이라 `is_mean_reversion_candidate`(own_trend·ADX·%B 게이트)가
한 번도 호출되지 않았다. 진입 집합이 크게 바뀌므로 플래그
`MEAN_REVERSION_RULE_MERGE_ENABLED`(기본 False, §8.4 백테스트 후 켤 것)로 제어한다.
플래그/파일 처리는 병합 자체에만 영향 — 읽기 실패는 warning 후 DB 규칙 그대로 반환.
"""
if not getattr(settings, "MEAN_REVERSION_RULE_MERGE_ENABLED", False):
return rules
if any(str(getattr(r, "rule_type", "") or "").upper() == "MEAN_REVERSION" for r in rules):
return rules
try:
import json as _json
from pathlib import Path as _Path
config_path = _Path(__file__).parent.parent.parent.parent / "trading_rules_config.json"
with open(config_path, 'r', encoding='utf-8') as f:
config = _json.load(f)
mr = [r for r in config.get("rules", [])
if r.get("is_active") and str(r.get("rule_type", "")).upper() == "MEAN_REVERSION"]
except Exception as e:
logger.warning(f"JSON MEAN_REVERSION 규칙 병합 실패 — DB 규칙만 사용: {e}")
return rules
if not mr:
return rules
r = mr[0]
next_id = max([int(x.id) for x in rules if x.id is not None] + [0]) + 1
return rules + [Rule(
id=next_id, name=r["name"], description=r.get("description", ""),
rule_type="MEAN_REVERSION", priority=r.get("priority", 1),
conditions=r.get("conditions", {}), actions=r.get("actions", {}), is_active=True,
)]
async def _load_active_rules_from_db(db: AsyncSession) -> list:
from app.models.models import TradingRule as TradingRuleModel
try:
@@ -202,12 +479,13 @@ async def _load_active_rules_from_db(db: AsyncSession) -> list:
)
db_rules = result.scalars().all()
if db_rules:
return [
rules = [
Rule(id=r.id, name=r.name, description=r.description or "",
rule_type=r.rule_type, priority=r.priority,
conditions=r.conditions, actions=r.actions, is_active=True)
for r in db_rules
]
return _merge_json_mean_reversion_rule_v2(rules)
except Exception as e:
logger.warning(f"trading_rules DB 조회 실패: {e}")
@@ -451,6 +729,16 @@ async def get_rule_based_recommendations_from_db(db: AsyncSession):
continue
already_added.add(stock.stock_code)
stock_dict = _stock_analysis_to_dict(stock)
# 규칙 미충족 대체 목록은 매수 후보가 아니다: MR 게이트(own_trend·ADX·%B·RSI)를 통과한
# 종목만 매수 가능, 나머지는 청산 판정 전용(_exit_only — trade_executor 가 should_buy 에서 SKIP).
# 게이트 판정 예외는 fail-closed(청산 전용).
try:
_mr_ok = bool(is_mean_reversion_candidate(stock, {}, current_market_regime))
except Exception as e:
logger.warning(f"[rule-based] 대체 목록 MR 게이트 판정 실패 — 청산 전용 처리 {stock.stock_code}: {e}")
_mr_ok = False
stock_dict["_exit_only"] = not _mr_ok
stock_dict["buy_allowed"] = bool(stock_dict.get("buy_allowed")) and _mr_ok
fallback_stocks.append(stock_dict)
rule_matches.append(stock_dict)
@@ -479,13 +767,20 @@ async def get_rule_based_recommendations_from_db(db: AsyncSession):
"stochastic_k": round(float(s.stochastic_k), 2) if s.stochastic_k else None,
"signals": s.signals or [],
"score": s.score or 0,
"ml_signal": s.ml_signal,
"ml_confidence": round(float(s.ml_confidence), 4) if s.ml_confidence else None,
"price_source": "batch",
"block_reasons": _block_reasons_v2(s.signals) + _ml_block_reasons_v2(s.ml_signal, s.ml_confidence),
"buy_allowed": not (_block_reasons_v2(s.signals) + _ml_block_reasons_v2(s.ml_signal, s.ml_confidence)),
}
for s in all_analyzed_stocks
],
"recommendations": rule_matches,
"recommendations_by_rule": recommendations_by_rule,
"rules_applied": [{"id": r.id, "name": r.name, "priority": r.priority} for r in active_rules],
"batch_time": (lambda d: d.date() if isinstance(d, datetime) else d)(
# batch_time: 실제 스캔 시각(KST 오프셋 포함 ISO). 날짜만 필요하면 batch_date.
"batch_time": _batch_time_iso_v2(all_analyzed_stocks),
"batch_date": (lambda d: d.date() if isinstance(d, datetime) else d)(
max((s.analysis_date for s in all_analyzed_stocks), default=_today_kst())
).isoformat(),
}
@@ -627,8 +922,7 @@ async def get_rule_based_recommendations_realtime():
elif rule.rule_type in ("TREND", "BREAKOUT", "MOMENTUM"):
matches = False
else:
rsi_threshold = conditions.get("rsi_threshold", 40)
matches = (stock.rsi is not None and stock.rsi <= rsi_threshold)
matches = _generic_rule_matches_v2(rule, stock, conditions)
if matches:
rule_matched_stocks.append(stock)
@@ -673,8 +967,11 @@ async def get_rule_based_recommendations_realtime():
# db_worker 큐 경유 — 실시간 분석 경로에서 AsyncSessionLocal 직접 사용 금지 (CLAUDE.md 3.2)
from app.services.analysis_db_service import queue_bulk_upsert_analysis as _queue_bulk
_today = _today_kst()
_items = [(rec.stock_code, rec.stock_name, rec) for rec in valid_analyzed_stocks]
await _queue_bulk(_items, _today)
_items = _persistable_items_v2(
[(rec.stock_code, rec.stock_name, rec) for rec in valid_analyzed_stocks], _today
)
if _items:
await _queue_bulk(_items, _today)
return {
"total": len(rule_matches),

View File

@@ -22,6 +22,14 @@ from app.core.database import get_db
_KST = timezone(timedelta(hours=9))
def _fill_or_created_at_v2():
"""체결 시각 표현 — executed_at 이 NULL 인 DT·스윙 청산·MDD 행은 created_at 으로 대체.
trade_executor._fill_or_created_at_v2 와 동일 정의(무거운 import 회피를 위해 로컬 정의).
"""
return func.coalesce(TradeHistory.executed_at, TradeHistory.created_at)
def _today_kst() -> date:
"""KST 기준 오늘 날짜 반환 (UTC 기준 date.today() 대신 항상 이 함수 사용)"""
return datetime.now(_KST).date()
@@ -38,13 +46,125 @@ MAX_POSITION_SIZE_RATIO = settings.MAX_POSITION_SIZE # config.py: 0.04 (4%) —
# 데이트레이딩은 설계상 종목당 6%(DAY_TRADE_MAX_CAPITAL_RATIO, 5종목×6%=30%)이므로
# 스윙 상한(4%)으로 검사하면 정상 DT 주문이 '위반'으로 오판정된다 → 전략별로 분기한다.
DT_POSITION_SIZE_RATIO = settings.DAY_TRADE_MAX_CAPITAL_RATIO
MAX_DAILY_LOSS_RATIO = 0.03 # 일일 3% 손실 시 중단
MAX_CONSECUTIVE_LOSSES = 3 # 3연속 손실 시 중단
# 한도는 settings 우선(config.py 에 키가 생기면 자동 반영), 없으면 기존 값 유지 — 값 변경 없음.
MAX_DAILY_LOSS_RATIO = float(getattr(settings, "RISK_MAX_DAILY_LOSS_RATIO", 0.03)) # 일일 3% 손실 시 중단
MAX_CONSECUTIVE_LOSSES = int(getattr(settings, "RISK_MAX_CONSECUTIVE_LOSSES", 3)) # 3연속 손실 시 중단
_DT_STRATEGIES = ("daytrading", "day_trade", "dt")
COMMISSION_RATE = 0.00015 # 수수료 0.015%
TRADE_TAX_RATE = 0.0023 # 거래세 0.23% (매도 시만)
TOTAL_COST_RATE = COMMISSION_RATE * 2 + TRADE_TAX_RATE # 왕복 약 0.26%
def _resolve_account_balance(caller_balance: float):
"""호출자 잔고와 서버 잔고(mdd_manager 최근 갱신값, 없으면 peak 미사용)를 대조한다.
서버값이 있으면 min(호출자, 서버) — 큰 값 주입으로 한도를 희석하는 우회를 막는 보수적 선택.
서버값이 없으면 호출자 값을 쓰되 note 로 미검증을 표시한다.
"""
from app.services.mdd_manager import mdd_manager
try:
server = float(getattr(mdd_manager, "_last_balance", 0.0) or 0.0)
except (TypeError, ValueError):
server = 0.0
caller = float(caller_balance)
if server > 0:
if caller > server:
logger.warning(
f"[check-risk-limits] 호출자 잔고 {caller:,.0f} > 서버 잔고 {server:,.0f} — 서버값 사용"
)
return server, "server_balance_used"
return caller, "ok"
return caller, "server_balance_unverified"
async def _server_side_block_reason_v2(strategy: str, account_balance: float) -> Optional[str]:
"""TradingSession 행(기록 주체 없음)과 무관하게 서버 인메모리 상태로 신규 주문 차단 사유를 낸다.
- mdd_manager.can_open_new_position(): MDD -10% 정지 / 갱신 블라인드 fail-safe
- DT 전략: day_trading_state.daily_pnl 이 일일 손실한도(DAY_TRADE_DAILY_LOSS_LIMIT) 이하
조회 예외는 fail-closed(차단)로 처리한다.
"""
from app.services.mdd_manager import mdd_manager
try:
if not await mdd_manager.can_open_new_position():
return "MDD 정지 또는 MDD 갱신 불가(fail-safe) — 신규 진입 차단"
if (strategy or "").strip().lower() in ("daytrading", "day_trade", "dt"):
from app.services.daytrading.position_manager import day_trading_state
from app.services.day_trading_strategy import is_daily_loss_exceeded
pnl = float(day_trading_state.daily_pnl or 0.0)
assets = float(day_trading_state.last_total_assets or 0.0) or float(account_balance)
if is_daily_loss_exceeded(assets, pnl):
return (f"DT 일일 손실한도 도달 (pnl {pnl:,.0f}, "
f"한도 {float(settings.DAY_TRADE_DAILY_LOSS_LIMIT) * 100:+.1f}%)")
except Exception as e:
logger.warning(f"[check-risk-limits] 서버 상태 조회 실패 — fail-closed: {e}")
return "서버 리스크 상태 조회 실패 — 신규 진입 차단(fail-closed)"
return None
_STREAK_EXCLUDED_STRATEGIES = ("manual", "mdd_reduction", "external")
async def _swing_realized_today_v2(db, stock_code: Optional[str] = None) -> tuple:
"""TradingSession 기록 주체가 없으므로 TradeHistory 에서 스윙 당일 실현손익·연속 손실을 직접 계산한다.
반환: (당일 순손익 합, 최근 연속 손실 횟수). SELECT 전용. 조회 실패 시 예외를 그대로 올린다
(호출부가 fail-closed 처리). DT 행은 제외한다.
연패는 §5.1 의미(동일 종목)에 맞춰 stock_code 가 주어질 때만 해당 종목으로 집계하고,
manual/mdd_reduction/external 청산은 제외한다. stock_code 가 없으면 0
(종목 무관 전역 연패는 §5.1 정의가 아니므로 loss_tracker 에 맡긴다).
"""
start = now_kst().replace(hour=0, minute=0, second=0, microsecond=0)
res = await db.execute(
select(TradeHistory.net_profit_loss, TradeHistory.stock_code, TradeHistory.strategy_type)
.where(TradeHistory.order_type == "SELL")
.where(TradeHistory.status.in_(["EXECUTED", "COMPLETED", "FILLED"]))
.where(TradeHistory.created_at >= start)
.where(TradeHistory.net_profit_loss.isnot(None))
.where(TradeHistory.strategy_type.notin_(["daytrading", "day_trade"]))
.order_by(desc(TradeHistory.created_at))
)
rows = res.all()
total = sum(float(r[0]) for r in rows)
streak = 0
if stock_code:
for pl, code, st in rows:
if code != stock_code or st in _STREAK_EXCLUDED_STRATEGIES:
continue
if float(pl) < 0:
streak += 1
else:
break
return total, streak
def _mdd_state_v2() -> dict:
"""실제 MDD 게이트(mdd_manager) 상태 스냅샷. 읽기 전용, DB 접근 없음.
drawdown_pct 는 (peak - 최근 관측 잔고) / peak * 100 (양수 = 하락폭).
peak 또는 최근 잔고를 모르면 None (0% '안전' 으로 오표시하지 않는다).
"""
from app.services.mdd_manager import mdd_manager
peak = float(getattr(mdd_manager, "peak_balance", 0.0) or 0.0)
last = float(getattr(mdd_manager, "_last_balance", 0.0) or 0.0)
dd_pct = round((peak - last) / peak * 100, 2) if peak > 0 and last > 0 else None
return {
"peak_balance": peak if peak > 0 else None,
"last_balance": last if last > 0 else None,
"drawdown_pct": dd_pct,
"is_paused": bool(mdd_manager.is_paused),
"is_short_term_strategy_paused": bool(getattr(mdd_manager, "is_short_term_strategy_paused", False)),
"reduction_applied": mdd_manager.reduction_applied,
"update_health": mdd_manager.get_update_health(),
}
@router.get("/mdd-status")
async def get_mdd_status_v2():
"""실제 MDD 게이트(-10%/-20%) 상태. /risk-metrics 의 손익곡선과 정의가 다르다."""
return _mdd_state_v2()
@router.get("/risk-metrics")
async def get_risk_metrics(
days: int = Query(30, description="분석 기간 (일)"),
@@ -53,7 +173,7 @@ async def get_risk_metrics(
"""
📊 리스크 관리 메트릭 조회
- Maximum Drawdown (MDD): 최대 손실 깊이
- 실현손익 누적곡선의 최대 낙폭(원, 참고용). 실제 MDD 는 mdd_status / /mdd-status
- 누적 손익 곡선
- 현재 계좌 상태
"""
@@ -66,14 +186,15 @@ async def get_risk_metrics(
.where(TradeHistory.created_at >= since_date)
.where(TradeHistory.order_type == "SELL")
.where(TradeHistory.status.in_(["EXECUTED", "COMPLETED", "FILLED"]))
.order_by(TradeHistory.executed_at)
.order_by(_fill_or_created_at_v2(), TradeHistory.id)
)
trades = result.scalars().all()
if not trades:
return {
"max_drawdown": 0,
"max_drawdown_pct": 0,
"max_drawdown_pct": None,
"mdd_status": _mdd_state_v2(),
"current_equity": 0,
"peak_equity": 0,
"trades_count": 0,
@@ -112,24 +233,16 @@ async def get_risk_metrics(
if drawdown > max_drawdown:
max_drawdown = drawdown
max_drawdown_pct = (max_drawdown / peak * 100) if peak > 0 else 0
# MDD 위험 레벨
risk_level = "안전"
if max_drawdown_pct > 30:
risk_level = "위험 (시스템 중지 권장)"
elif max_drawdown_pct > 20:
risk_level = "경고 (포지션 축소 권장)"
elif max_drawdown_pct > 10:
risk_level = "주의"
# 참고용 실현손익 곡선 — 낙폭 % 는 peak 가 손익 누적값이라 의미가 없어 판정하지 않는다.
return {
"max_drawdown": round(float(max_drawdown), 2),
"max_drawdown_pct": round(max_drawdown_pct, 2),
"max_drawdown_pct": None,
"current_equity": round(cumulative_pl, 2),
"peak_equity": round(peak, 2),
"trades_count": len(trades),
"risk_level": risk_level,
"risk_level": None,
"note": "실현손익 누적곡선(참고용). 리스크 수준 판단은 mdd_status 를 사용",
"mdd_status": _mdd_state_v2(),
"equity_curve": equity_curve[-50:], # 최근 50개만
"drawdown_curve": drawdown_curve[-50:]
}
@@ -161,6 +274,7 @@ async def get_sharpe_ratio(
.where(TradeHistory.order_type == "SELL")
.where(TradeHistory.status.in_(["EXECUTED", "COMPLETED", "FILLED"]))
.where(TradeHistory.profit_loss_rate.isnot(None))
.order_by(_fill_or_created_at_v2(), TradeHistory.id)
)
trades = result.scalars().all()
@@ -240,28 +354,33 @@ async def get_today_session(db: AsyncSession = Depends(get_db)):
)
session = result.scalar_one_or_none()
_mdd = _mdd_state_v2()
if not session:
# 오늘 세션이 없으면 생성
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)
await db.commit()
await db.refresh(session)
# [하드닝 R4] GET 은 DB 를 쓰지 않는다(§3.2: 쓰기는 db_task_queue 경유, 무인증 GET 부하 방지).
# 세션 행을 쓰는 엔드포인트는 없다(R1 에서 /update-session 제거).
return {
"session_date": today.isoformat(),
"consecutive_losses": 0,
"consecutive_wins": 0,
"daily_profit_loss": 0,
"daily_trades_count": 0,
"is_paused": _mdd["is_paused"],
"pause_reason": "MDD 정지" if _mdd["is_paused"] else None,
"mdd_status": _mdd,
"starting_balance": None,
"ending_balance": None,
"peak_balance": None,
}
return {
"session_date": session.session_date.isoformat(),
"consecutive_losses": session.consecutive_losses,
"consecutive_wins": session.consecutive_wins,
"daily_profit_loss": float(session.daily_profit_loss) if session.daily_profit_loss else 0,
"daily_trades_count": session.daily_trades_count,
"is_paused": session.is_paused,
"pause_reason": session.pause_reason,
"is_paused": bool(session.is_paused) or _mdd["is_paused"],
"pause_reason": session.pause_reason or ("MDD 정지" if _mdd["is_paused"] else None),
"mdd_status": _mdd,
"starting_balance": float(session.starting_balance) if session.starting_balance else None,
"ending_balance": float(session.ending_balance) if session.ending_balance else None,
"peak_balance": float(session.peak_balance) if session.peak_balance else None
@@ -277,11 +396,15 @@ async def check_risk_limits(
order_value: float = Query(..., description="주문 금액"),
account_balance: float = Query(..., description="계좌 잔고"),
strategy: str = Query("swing", description="전략 구분: swing | daytrading (종목당 한도 분기)"),
stock_code: Optional[str] = Query(None, description="종목 단위 연패 집계용(없으면 연패 가드 생략)"),
db: AsyncSession = Depends(get_db)
):
"""
⚠️ 주문 전 리스크 제한 확인
[미사용 표기] 2026-10 기준 코드베이스(py/ts/mcp-server)에 호출자가 없다. 실제 진입 가드는
trade_executor/entry_service/buy_guards 에 있으며 이 엔드포인트 보강은 매매 안전에 기여하지 않는다.
- 포지션 사이즈 제한 (전략별: 스윙 MAX_POSITION_SIZE / DT DAY_TRADE_MAX_CAPITAL_RATIO)
- 일일 손실 한도
- 연속 손실 제한
@@ -289,6 +412,31 @@ async def check_risk_limits(
[2026-09-28] `strategy` 는 기본값이 있는 추가 파라미터다(기존 호출부 동작 불변).
이전에는 DT 주문(설계상 6%)을 스윙 상한 4%로 검사해 정상 주문을 위반으로 냈다.
"""
# [하드닝 R4] 호출자 제공 잔고 신뢰 제거 — 서버 잔고(mdd_manager)가 있으면 더 작은 쪽 사용.
# (가드는 try 밖) 큰 값을 넘겨 손실비율·포지션비율을 희석하는 우회를 막는다.
account_balance, _bal_note = _resolve_account_balance(account_balance)
# [하드닝 R1] 서버측 실상태 가드(try 밖): MDD 정지/블라인드, DT 일일 손실한도.
_block = await _server_side_block_reason_v2(strategy, account_balance)
if _block:
return {"allowed": False, "reason": _block, "risk_level": "BLOCKED"}
# [하드닝 R3] 스윙 일일손실·연패는 TradingSession(기록 주체 없음) 대신 TradeHistory 실계산으로 가드(try 밖, fail-closed).
_sw_pnl, _sw_streak = 0.0, 0
if (strategy or "swing").strip().lower() not in _DT_STRATEGIES:
try:
_sw_pnl, _sw_streak = await _swing_realized_today_v2(db, stock_code=stock_code)
except Exception as e:
logger.warning(f"[check-risk-limits] 스윙 당일 손익 조회 실패 — fail-closed: {e}")
return {"allowed": False, "reason": "당일 손익 조회 실패 — 신규 진입 차단(fail-closed)",
"risk_level": "BLOCKED"}
if _sw_pnl < 0 and account_balance > 0 and abs(_sw_pnl) / account_balance > MAX_DAILY_LOSS_RATIO:
_r = abs(_sw_pnl) / account_balance
return {"allowed": False,
"reason": f"일일 손실 한도 도달 ({_r*100:.1f}% > {MAX_DAILY_LOSS_RATIO*100}%)",
"risk_level": "CRITICAL", "daily_loss": _sw_pnl,
"daily_loss_ratio": round(_r * 100, 2)}
if _sw_streak >= MAX_CONSECUTIVE_LOSSES:
return {"allowed": False, "reason": f"연속 {_sw_streak}회 손실 발생",
"risk_level": "CRITICAL", "consecutive_losses": _sw_streak}
try:
# 1. 오늘 세션 확인
today = _today_kst()
@@ -299,18 +447,16 @@ async def check_risk_limits(
session = result.scalar_one_or_none()
if not session:
# [하드닝 R4] 비영속 기본값 — 호출자 제공 잔고를 하루 기준으로 영속하지 않고,
# 직접 INSERT/commit(동시 호출 시 session_date 중복 경합)도 하지 않는다.
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)
# 2. 거래 중지 상태 확인
if session.is_paused:
@@ -324,13 +470,12 @@ async def check_risk_limits(
daily_loss_ratio = abs(float(session.daily_profit_loss)) / account_balance if account_balance > 0 else 0
if session.daily_profit_loss < 0 and daily_loss_ratio > MAX_DAILY_LOSS_RATIO:
# 자동 중지
session.is_paused = True
session.pause_reason = f"일일 손실 한도 도달 ({daily_loss_ratio*100:.1f}% > {MAX_DAILY_LOSS_RATIO*100}%)"
await db.commit()
# is_paused 영속은 하지 않는다(직접 commit 제거). 손익·연패는 세션에 남아 있어
# 매 호출마다 같은 판정이 재현된다.
_reason = f"일일 손실 한도 도달 ({daily_loss_ratio*100:.1f}% > {MAX_DAILY_LOSS_RATIO*100}%)"
return {
"allowed": False,
"reason": session.pause_reason,
"reason": _reason,
"risk_level": "CRITICAL",
"daily_loss": float(session.daily_profit_loss),
"daily_loss_ratio": round(daily_loss_ratio * 100, 2)
@@ -338,13 +483,9 @@ async def check_risk_limits(
# 4. 연속 손실 제한 확인
if session.consecutive_losses >= MAX_CONSECUTIVE_LOSSES:
session.is_paused = True
session.pause_reason = f"연속 {session.consecutive_losses}회 손실 발생"
await db.commit()
return {
"allowed": False,
"reason": session.pause_reason,
"reason": f"연속 {session.consecutive_losses}회 손실 발생",
"risk_level": "CRITICAL",
"consecutive_losses": session.consecutive_losses
}
@@ -380,7 +521,8 @@ async def check_risk_limits(
"warnings": warnings,
"position_ratio": round(position_ratio * 100, 2),
"daily_loss_ratio": round(daily_loss_ratio * 100, 2),
"consecutive_losses": session.consecutive_losses
"consecutive_losses": session.consecutive_losses,
"balance_note": _bal_note,
}
except Exception as e:
@@ -388,65 +530,9 @@ async def check_risk_limits(
raise HTTPException(status_code=500, detail=str(e))
@router.post("/update-session", dependencies=[Depends(require_api_key)])
async def update_trading_session(
trade_result: str = Query(..., description="win 또는 loss"),
profit_loss: float = Query(..., description="거래 손익"),
db: AsyncSession = Depends(get_db)
):
"""
🔄 거래 결과에 따라 세션 업데이트
- 연속 손실/승리 카운터
- 일일 손익 누적
"""
try:
today = _today_kst()
result = await db.execute(
select(TradingSession)
.where(TradingSession.session_date == today)
)
session = result.scalar_one_or_none()
if not session:
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))
session.daily_trades_count += 1
# 연속 손실/승리 업데이트
if trade_result == "loss":
session.consecutive_losses += 1
session.consecutive_wins = 0
elif trade_result == "win":
session.consecutive_wins += 1
session.consecutive_losses = 0
await db.commit()
await db.refresh(session)
return {
"session_date": session.session_date.isoformat(),
"consecutive_losses": session.consecutive_losses,
"consecutive_wins": session.consecutive_wins,
"daily_profit_loss": float(session.daily_profit_loss),
"daily_trades_count": session.daily_trades_count,
"is_paused": session.is_paused
}
except Exception as e:
logger.error(f"Error updating session: {e}", exc_info=True)
raise HTTPException(status_code=500, detail=str(e))
# [하드닝 R1] POST /update-session 제거: 직접 commit(§3.2 위반)·입력 무검증이었고 호출부가 0건이라
# 세션 행이 생기지 않아 check-risk-limits 의 일일손실/연패 가드가 항상 통과했다.
# 서버측 실상태(mdd_manager, DT daily_pnl)를 check-risk-limits 가 직접 읽는다.
@router.get("/win-rate")
@@ -533,7 +619,7 @@ async def reset_mdd():
await mdd_manager.reset()
logger.info("✅ MDD 수동 초기화 완료")
return {
"message": "MDD 초기화 완료 — 신규 진입 가능",
"message": "MDD 초기화 완료 — peak 를 현재 잔고로 재설정(이후 다시 -10% 하락 시 축소 재발동), 신규 진입 가능",
"before": before,
"after": {
"is_paused": mdd_manager.is_paused,

View File

@@ -18,7 +18,11 @@ from sqlalchemy import select, update
from app.core.database import AsyncSessionLocal
from app.models.models import StrategyConfig
from app.services.strategy_config_service import strategy_cfg
from app.services.strategy_config_service import (
get_effective_dt_params_v2,
serialize_config_v2,
strategy_cfg,
)
router = APIRouter()
logger = logging.getLogger(__name__)
@@ -39,6 +43,7 @@ class StrategyConfigResponse(BaseModel):
max_value: Optional[str]
is_active: bool
updated_by: Optional[str]
read_only: bool = False
class Config:
from_attributes = True
@@ -74,7 +79,7 @@ async def list_configs(
if active_only:
stmt = stmt.where(StrategyConfig.is_active == True)
result = await session.execute(stmt)
return result.scalars().all()
return [serialize_config_v2(c) for c in result.scalars().all()]
@router.get("/groups", response_model=List[str])
@@ -83,6 +88,12 @@ async def list_groups():
return ["INDICATOR", "RISK", "FILTER", "DAY_TRADE", "MEAN_REVERSION", "GENERAL"]
@router.get("/dt-effective", response_model=Dict[str, Any])
async def get_dt_effective():
"""DT 엔진이 실제로 쓰는 값(settings 단일 소스). 읽기 전용."""
return get_effective_dt_params_v2()
@router.get("/cache-snapshot", response_model=Dict[str, Any])
async def get_cache_snapshot():
"""현재 인메모리 캐시 상태 조회 (디버그용)."""
@@ -99,7 +110,7 @@ async def get_config(key: str):
cfg = result.scalar_one_or_none()
if not cfg:
raise HTTPException(status_code=404, detail=f"파라미터 '{key}'를 찾을 수 없습니다.")
return cfg
return serialize_config_v2(cfg)
@router.put("/{key}", response_model=StrategyConfigResponse)
@@ -134,7 +145,10 @@ async def bulk_update(body: BulkUpdateRequest):
@router.post("/{key}/reset", response_model=StrategyConfigResponse)
async def reset_config(key: str):
"""단일 파라미터를 기본값으로 리셋."""
cfg = await strategy_cfg.reset_to_default(key)
try:
cfg = await strategy_cfg.reset_to_default(key)
except ValueError as e:
raise HTTPException(status_code=422, detail=str(e))
if not cfg:
raise HTTPException(status_code=404, detail=f"파라미터 '{key}'를 찾을 수 없습니다.")
return cfg

View File

@@ -40,7 +40,10 @@ class SwingSellRequest(BaseModel):
@router.get("/portfolio")
async def get_swing_portfolio(db: AsyncSession = Depends(get_db)):
"""스윙 전용 포지션 목록 (strategy_type='swing')."""
holdings = await get_swing_holdings(db)
holdings = list(await get_swing_holdings(db))
# [2026-10-08] 전략별 분해(swing_* / dt_*) — 기존 필드는 계좌 합산·혼합 평단 그대로
from app.api.endpoints.portfolio import _annotate_strategy_breakdown_v2, _BREAKDOWN_FIELDS
await _annotate_strategy_breakdown_v2(db, holdings)
return {
"count": len(holdings),
"positions": [
@@ -53,6 +56,7 @@ async def get_swing_portfolio(db: AsyncSession = Depends(get_db)):
"profit_loss": float(h.profit_loss or 0),
"profit_loss_rate": float(h.profit_loss_rate or 0),
"strategy_type": h.strategy_type,
**{k: getattr(h, k, None) for k in _BREAKDOWN_FIELDS},
}
for h in holdings
],
@@ -62,9 +66,21 @@ async def get_swing_portfolio(db: AsyncSession = Depends(get_db)):
@router.get("/portfolio/summary")
async def get_swing_summary(db: AsyncSession = Depends(get_db)):
"""스윙 포지션 수익률 요약."""
holdings = await get_swing_holdings(db)
total_investment = sum(float(h.average_price or 0) * h.quantity for h in holdings)
total_value = sum(float(h.current_price or 0) * h.quantity for h in holdings)
holdings = list(await get_swing_holdings(db))
# [2026-10-08] 스윙 보유분(swing_quantity)·스윙 진입가(swing_avg_price) 기준 — 혼합 평단 회피
from app.api.endpoints.portfolio import _annotate_strategy_breakdown_v2
await _annotate_strategy_breakdown_v2(db, holdings)
def _sq(h):
v = getattr(h, "swing_quantity", None)
return int(h.quantity or 0) if v is None else int(v)
def _sp(h):
v = getattr(h, "swing_avg_price", None)
return float(h.average_price or 0) if v is None else float(v)
total_investment = sum(_sp(h) * _sq(h) for h in holdings)
total_value = sum(float(h.current_price or 0) * _sq(h) for h in holdings)
total_pl = total_value - total_investment
total_pl_rate = (total_pl / total_investment * 100) if total_investment > 0 else 0.0
@@ -80,11 +96,14 @@ async def get_swing_summary(db: AsyncSession = Depends(get_db)):
@router.get("/exit-candidates")
async def get_exit_candidates(db: AsyncSession = Depends(get_db)):
"""청산 조건에 근접한 스윙 후보 종목 조회 (읽기 전용, 실제 청산 없음)."""
holdings = await get_swing_holdings(db)
holdings = list(await get_swing_holdings(db))
from app.api.endpoints.portfolio import _annotate_strategy_breakdown_v2
await _annotate_strategy_breakdown_v2(db, holdings)
candidates = []
for h in holdings:
avg_price = float(h.average_price or 0)
# [2026-10-08] 스윙 진입가 기준(혼합 평단이면 손익률이 왜곡된다)
avg_price = float(getattr(h, "swing_avg_price", None) or h.average_price or 0)
current_price = float(h.current_price or 0)
if avg_price <= 0 or current_price <= 0:
continue
@@ -178,38 +197,73 @@ async def manual_swing_sell(
order_id = (order_resp.get("output") or {}).get("ODNO")
from app.services.db_worker import enqueue_trade_history
avg_price = float(holding.average_price or 0)
# [2026-10-08] 스윙 진입가(혼합 평단 회피) — 스윙 ActiveTrade 우선
from app.services.swing.position_manager import (
get_swing_active_trade, resolve_swing_entry_price_v2,
)
# 주문은 이미 전송됐다 — 진입가 조회 실패가 TradeHistory 기록을 막으면 원장이 유실되므로
# 실패 시 Portfolio 평단으로 폴백(기록 손익은 추정치, 체결 확인이 보정한다).
try:
_at = await get_swing_active_trade(db, req.stock_code)
except Exception as _at_err:
logger.warning(
f"[SwingManualSell] {req.stock_code} 스윙 진입가 조회 실패 — Portfolio 평단으로 기록: "
f"{type(_at_err).__name__}: {_at_err}"
)
_at = None
avg_price, _ = resolve_swing_entry_price_v2(
getattr(_at, "entry_price", None) if _at is not None else None,
float(holding.average_price or 0), req.stock_code,
)
pl = (price - avg_price) * quantity
pl_rate = (price - avg_price) / avg_price * 100 if avg_price > 0 else 0.0
# [하드닝 R2 2026-10-09] 순손익(비용 차감, §5.1) — 자동 경로(swing/exit_service)와 동일 정의.
# 시장 구분 정보가 Portfolio 에 없어 KOSPI 세율 적용(추정치, 체결 확인이 보정).
# TradeHistory enqueue 전에 이전 레그 누적을 조회해야 이번 레그가 이중 합산되지 않는다.
from app.services.swing.exit_service import (
_swing_net_pnl_v2, _position_is_loss_v2, _prior_position_realized_net_v2,
)
alloc = allocate_sell_v2(quantity, row_qty, dt_qty, swing_only=True)
_pnl = _swing_net_pnl_v2(avg_price, price, quantity, "KOSPI")
net_pl = float(_pnl["net_profit_loss"])
# fill_sweep.derive_sell_entry_price_v2 가 entry = p − (net + commission)/q 로 역산하므로
# 비용 차감 net 을 기록할 땐 commission 도 함께 기록해야 한다(자동 경로와 동일 규약).
total_cost = float(_pnl["total_cost"])
prior_net = None
if alloc.swing_left <= 0 and _at is not None:
prior_net = await _prior_position_realized_net_v2(db, req.stock_code, _at, "KOSPI")
await enqueue_trade_history({
"stock_code": req.stock_code,
"stock_name": holding.stock_name,
"order_type": "SELL",
"order_price": price,
"order_quantity": quantity,
"executed_price": price,
# [2026-10-08] 시장가 — 체결가는 체결 확인(swing/fill_sweep)이 채운다. 금액·손익은 추정치.
"executed_price": None,
"executed_quantity": quantity,
"total_amount": price * quantity,
"status": "COMPLETED",
"order_id": order_id,
"exit_reason": "수동 청산",
"net_profit_loss": pl,
"net_profit_loss": net_pl,
"commission": total_cost,
"profit_loss_rate": pl_rate,
"strategy_type": "swing",
})
# ActiveTrade(비-DT 행만) + Portfolio(행 − 매도분, 큐 경유·캐시 반영)
alloc = allocate_sell_v2(quantity, row_qty, dt_qty, swing_only=True)
await sync_active_trade_after_manual_sell_v2(req.stock_code, alloc, price=float(price))
from app.services.portfolio_cache_bridge import reflect_sell
await reflect_sell(req.stock_code, quantity, float(price), remaining_qty=row_qty - quantity)
# LossTracker — 스윙분 전량 청산 시에만(분할 매도 이중 카운트 방지, swing/exit_service 와 동일)
if alloc.swing_left <= 0:
_is_loss = _position_is_loss_v2(prior_net, net_pl) # try 밖: 판정 예외가 기록을 삼키지 않게
try:
from app.services.loss_tracker import loss_tracker
await loss_tracker.record_trade_result_safe(stock_code=req.stock_code, is_loss=pl < 0)
await loss_tracker.record_trade_result_safe(stock_code=req.stock_code, is_loss=_is_loss)
await loss_tracker.save_to_db(stock_code=req.stock_code)
except Exception as e:
logger.warning(f"[SwingAPI] LossTracker 기록 실패 {req.stock_code}: {e}")

View File

@@ -172,10 +172,16 @@ async def get_trade_stats(
executed_trades = len([t for t in trades if t.status in ("EXECUTED", "COMPLETED")])
failed_trades = len([t for t in trades if t.status == "FAILED"])
# 총 거래 금액 계산
# 총 거래 금액 계산 — [2026-10-08] total_amount(체결 확인 시 체결가×체결수량으로 갱신) 우선.
# executed_price 는 체결 확인 전 NULL 이므로 곱셈만 쓰면 미확인 건이 0 으로 빠진다.
def _amount(t) -> float:
if t.total_amount is not None:
return float(t.total_amount)
price = t.executed_price if t.executed_price is not None else t.order_price
return float(price or 0) * (t.executed_quantity or 0)
total_amount = sum(
float(t.executed_price or 0) * (t.executed_quantity or 0)
for t in trades if t.status in ("EXECUTED", "COMPLETED")
_amount(t) for t in trades if t.status in ("EXECUTED", "COMPLETED")
)
return {
@@ -192,7 +198,11 @@ async def get_trade_stats(
@router.delete("/pending")
async def delete_pending_trades(db: AsyncSession = Depends(get_db)):
"""PENDING과 FAILED 상태의 매수 시도 내역 정리 — **전일 이전 행만**.
"""FAILED 상태의 주문 시도 내역 정리 — **전일 이전 행만, PENDING 은 보존**.
[R7] PENDING(지정가 미체결·결과 불명 BUY)은 원장 BUY 합계와 강등 방어의 근거라 지우지 않는다.
쓰기는 FAILED 전용 정리(기존 동작 유지). db_worker 에 delete task 가 없어 직접 삭제를 유지한다
(관리자 전용 등록은 security.ADMIN_ONLY_ROUTES 소관 — 별도 보고).
[L 2026-10-02] 기존에는 기간 무제한이라 장중 호출 시 당일 PENDING/FAILED 행까지
지웠다. 당일 행은 `already_ordered_today`·일일 스윙 매수 한도(`_count_swing_buys_today_v2`
@@ -204,7 +214,9 @@ async def delete_pending_trades(db: AsyncSession = Depends(get_db)):
cutoff = get_daily_cutoff_utc()
result = await db.execute(
select(TradeHistory).where(
TradeHistory.status.in_(['PENDING', 'FAILED']),
# [R7] PENDING 은 보존한다 — 체결 확인 실패 BUY 가 PENDING 으로 남아
# portfolio 원장 BUY 합계·유입잔고 강등(swing→external) 방어의 근거가 된다(main.py 기동 정리와 동일 정책).
TradeHistory.status == 'FAILED',
TradeHistory.created_at < cutoff,
)
)
@@ -215,9 +227,9 @@ async def delete_pending_trades(db: AsyncSession = Depends(get_db)):
await db.delete(trade)
await db.commit()
logger.info(f"✅ 정리 완료: {count}개의 PENDING/FAILED 매수 시도 삭제 (기준 {cutoff} 이전, 당일 행 보존)")
logger.info(f"✅ 정리 완료: {count}개의 FAILED 시도 삭제(PENDING 보존) (기준 {cutoff} 이전, 당일 행 보존)")
return {
"deleted_count": count,
"message": f"Cleaned up {count} pending/failed trades before {cutoff.isoformat()} (today's rows kept)",
"message": f"Cleaned up {count} failed trades (PENDING kept) before {cutoff.isoformat()} (today's rows kept)",
}

View File

@@ -109,10 +109,13 @@ async def delete_pending_trades_v2(
본인 행만)을 적용해 legacy 컨텍스트(관리자/API 키, MT=False)는 기존 동작과 동일하고,
일반 사용자는 본인 이력만 지운다.
"""
from app.services.trade_helpers import get_daily_cutoff_utc
target_user_id = await _resolve_target_user_id(user, None, db)
query = select(TradeHistory).where(
TradeHistory.status.in_(["PENDING", "FAILED"]),
# [R7] PENDING 은 원장/강등 방어 근거라 보존, 당일 행은 한도 근거라 보존(trades.py 와 동일)
TradeHistory.status == "FAILED",
TradeHistory.created_at < get_daily_cutoff_utc(),
_user_scope_filter(TradeHistory.user_id, target_user_id),
)
result = await db.execute(query)