fix: 재기동 내구성 + DT 진입 복구 + 유입 잔고 격리 + 대시보드 표시 오류
## 재기동 내구성 - scheduler: 복원 직후 reset_if_new_day()가 복원분을 전부 폐기하던 버그 (begin_restore()로 날짜 경계를 선확정. 복원 성공 로그는 뜨는데 실제로는 지워졌다) - 일일 안전장치(daily_pnl/loss_streak_today/closed_today) Redis 영속화 날짜를 키와 페이로드 양쪽에 실어 이중 게이트, 복원은 보수적 병합 (손익 min/연패 max/청산목록 합집합 — 복원이 안전장치를 약화시킬 수 없다) - db_worker 종료 drain이 insert_trade_history만 커밋하고 포지션 추적 행은 DLQ로 버리던 비대칭 제거 + DLQ 재처리 경로 신설(쓰기 전용이었다) - 장중 기동 시 ORB 소급 수집(backfill), 전일 캔들 혼입 차단 - swing/exit_service "보유 종목 없음" debug→info, 10회 연속 warning ## DT 진입 복구 - 워밍업 회귀 복구: 1b9257c(2026-09-28)가 조회 계층에 당일 필터를 넣어 5분봉 RSI(14)/BB(20) 워밍업이 죽고 VWAP/RSI_BB가 10:15/10:40 이전 발화 불가가 됐다. 필터를 삭제하지 않고 용도별로 분리 (get_minute_chart_full_day_v2 신설, 기존 메서드·3개 호출부 동작 무변경) - 거래량 게이트 비교 대상 교정: 형성 중 봉 → 완성봉 (5분봉·ORB 1분봉 양쪽) 임계값(1.3/1.5)은 불변, 고친 것은 비교 대상뿐 - 세션 경계 차단: _last_session_candles()로 거래량 비교가 전일 봉을 잡아 개장 직후 게이트가 자동 통과하는 것을 방지 - 스캔 선조회(동시성 4)·종목 타임아웃·시간예산·라운드로빈 + 잘린 종목 수 로그 - watchlist 30분 갱신 시 ORB 보유 종목 우선 유지 ## 유입 잔고 격리 - sync_portfolio()가 시스템 매수 근거 없는 KIS 잔고를 자기 스윙 포지션으로 편입해 자동 손절로 처분하던 문제 → strategy_type='external' 격리 (승격만 허용, 강등 없음. 기존 행은 수동 SQL 필요) - 격리분 손절선 이탈은 집행 없이 warning + summary 노출 - 수동 청산 경로에 record_trade_result 누락 보강, 전략 간 당일 쿨다운 ## 4% 상한 - market_data 표시 경로 클램프 누락 + confidence 배율 후 재클램프 - 회귀 방지 테스트(AST로 가드가 try 밖에 있는지 검증) ## 프론트 - 총자산 보정 가드(3%·종목수·가격결손·국내분 한정), 원인 미규명 명시 - cumulative_realized_pl이 /portfolio/combined에 없어 항상 0이던 문제 테스트 579 → 677 passed / 0 failed (소켓 차단 하 통과, 실호출 없음) 전략 임계값 변경 0건 ⚠️ 워밍업 복구의 효과 크기는 미확정 — KIS 분봉 API에 날짜 파라미터가 없어 미래 ref_time에 전일 데이터를 주는지가 미검증 전제다. 배포 후 "[분봉] 워밍업 5분봉 N봉 (당일 a + 전일 b)" 로그의 b로 확정할 것. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -681,12 +681,17 @@ def _suggest_buy_amount(
|
||||
out["sizing_note"] = "잔고 조회 전 — 금액 계산 불가"
|
||||
return out
|
||||
|
||||
position_cap = float(getattr(_cfg, "MAX_POSITION_SIZE", 0.06))
|
||||
# [2026-09-28] MAX_POSITION_SIZE 는 CLAUDE.md §5.1 종목당 **절대 상한**이다.
|
||||
# 적응형 모드(Normal 6% / Aggressive 7%)를 그대로 쓰면 이 화면이 한도를 넘는
|
||||
# 금액을 제안하고, 운영자가 그대로 수동매수하면 상한이 깨진다.
|
||||
# 실행 경로(trade_executor.py:898, 981-987)와 동일하게 min() 으로 클램프한다.
|
||||
_hard_cap = float(getattr(_cfg, "MAX_POSITION_SIZE", 0.04))
|
||||
position_cap = _hard_cap
|
||||
try:
|
||||
from app.services.adaptive_strategy import get_current_adaptive_params
|
||||
_adp = get_current_adaptive_params()
|
||||
if not _adp.fallback_used:
|
||||
position_cap = float(_adp.position_size)
|
||||
position_cap = min(float(_adp.position_size), _hard_cap)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
@@ -694,6 +699,10 @@ def _suggest_buy_amount(
|
||||
# 신뢰도 보정 (기준 0.7)
|
||||
if confidence > 0:
|
||||
budget *= min(1.2, max(0.8, confidence / 0.7)) # 하한 0.8 (과도 축소 방지)
|
||||
# 신뢰도 상향 보정(최대 ×1.2)이 절대 상한을 넘기지 못하게 재클램프.
|
||||
# 실행 경로도 패턴메모리 조정 뒤 같은 상한을 다시 적용하고(trade_executor.py:981-987),
|
||||
# manual_buy.py:120 의 자동 수량도 총자산×MAX_POSITION_SIZE 예산을 쓴다.
|
||||
budget = min(budget, total_assets * _hard_cap)
|
||||
# 현금 여력
|
||||
reserve = total_assets * (1 - float(getattr(_cfg, "MAX_TOTAL_CAPITAL_USAGE", 0.95)))
|
||||
budget = min(budget, max(0.0, available_cash - reserve))
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.orm.exc import StaleDataError
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, func
|
||||
from sqlalchemy import select, func, or_
|
||||
from typing import List, Optional
|
||||
from decimal import Decimal
|
||||
from pydantic import BaseModel, Field
|
||||
@@ -36,6 +36,75 @@ logger = logging.getLogger(__name__)
|
||||
# 한국 시간대
|
||||
KST = pytz.timezone('Asia/Seoul')
|
||||
|
||||
# 유입(외부) 잔고 격리 태그 — 시스템이 매수한 적 없는 보유분.
|
||||
# 스윙 자동 청산(get_swing_holdings: strategy_type=='swing')에서 자동 제외된다.
|
||||
EXTERNAL_STRATEGY_TYPE = "external"
|
||||
# sync 가 자동 시딩한 ActiveTrade 전략명 — '시스템 매수 근거'로 인정하지 않는다.
|
||||
_SEEDED_AT_STRATEGY_NAMES = ("synced",)
|
||||
|
||||
|
||||
def _external_stop_breaches(holdings: list) -> list:
|
||||
"""유입 격리분 중 기본 손절선(-STOP_LOSS_PERCENT)을 이탈한 종목 목록 (순수 함수).
|
||||
|
||||
격리분은 **집행하지 않는다** — 이 함수는 "조용한 실패"를 막기 위한 가시화 전용이다.
|
||||
ATR 손절(진입가 − 2.5×ATR)은 분봉·일봉이 필요해 여기서 계산하지 않는다.
|
||||
따라서 이 판정은 기본 SL 기준이며 실제 전략 손절선보다 느슨할 수 있다.
|
||||
|
||||
Returns:
|
||||
list[dict]: stock_code / stock_name / profit_loss_rate / breach_pct(초과 이탈폭)
|
||||
"""
|
||||
from app.core.config import settings as _cfg
|
||||
|
||||
_sl = -abs(float(getattr(_cfg, "STOP_LOSS_PERCENT", 7.0)))
|
||||
out = []
|
||||
for h in holdings:
|
||||
if (getattr(h, "strategy_type", None) or "swing") != EXTERNAL_STRATEGY_TYPE:
|
||||
continue
|
||||
if not h.quantity or h.quantity <= 0:
|
||||
continue
|
||||
_avg = float(h.average_price or 0)
|
||||
_cur = float(h.current_price or 0)
|
||||
if _avg <= 0 or _cur <= 0:
|
||||
continue
|
||||
_rate = (_cur / _avg - 1) * 100
|
||||
if _rate <= _sl:
|
||||
out.append({
|
||||
"stock_code": h.stock_code,
|
||||
"stock_name": h.stock_name or h.stock_code,
|
||||
"profit_loss_rate": round(_rate, 2),
|
||||
"breach_pct": round(_rate - _sl, 2), # 손절선 대비 추가 이탈폭(음수)
|
||||
"stop_loss_pct": _sl,
|
||||
})
|
||||
return out
|
||||
|
||||
|
||||
def _classify_holding_strategy_type(
|
||||
stock_code: str,
|
||||
quantity: int,
|
||||
dt_qty: dict,
|
||||
system_codes: set,
|
||||
) -> str:
|
||||
"""KIS 잔고 1행의 `Portfolio.strategy_type` 판정 (순수 함수).
|
||||
|
||||
Args:
|
||||
stock_code: 종목코드
|
||||
quantity: KIS 잔고 수량
|
||||
dt_qty: 데이트레이딩 인메모리 보유 수량 {code: qty}
|
||||
system_codes: 시스템 매수 근거가 있는 종목 집합
|
||||
(TradeHistory BUY ∪ 시딩 아닌 ActiveTrade ∪ DT 인메모리)
|
||||
|
||||
Returns:
|
||||
"daytrading" | "swing" | "external"
|
||||
- 전량이 DT 보유분이면 daytrading (부분 혼재면 스윙 청산 대상 유지)
|
||||
- 시스템 매수 근거가 없으면 external (유입 잔고 → 자동 청산 제외)
|
||||
"""
|
||||
_dt_held = int(dt_qty.get(stock_code, 0) or 0)
|
||||
if _dt_held > 0 and quantity <= _dt_held:
|
||||
return "daytrading"
|
||||
if stock_code in system_codes:
|
||||
return "swing"
|
||||
return EXTERNAL_STRATEGY_TYPE
|
||||
|
||||
# Request/Response Models
|
||||
class SellRequest(BaseModel):
|
||||
stock_code: str
|
||||
@@ -95,7 +164,21 @@ def _calculate_portfolio_values(holdings: list) -> dict:
|
||||
total_investment = domestic_investment
|
||||
total_profit_loss = domestic_profit_loss
|
||||
|
||||
# 유입 격리(strategy_type='external') 보유분 — 자동 청산 대상에서 빠진 잔고.
|
||||
# 총자산·평가액에서 빼지 않는다(계좌 전체 리스크는 이 잔고를 포함해야 한다).
|
||||
_external = [
|
||||
h for h in holdings
|
||||
if (getattr(h, "strategy_type", None) or "swing") == "external"
|
||||
]
|
||||
external_count = len(_external)
|
||||
# 국내 종목은 KRW, 해외 종목은 USD 원본 통화 — 환산은 하지 않는다(표시 전용).
|
||||
external_value = sum(float(h.current_price or 0) * h.quantity for h in _external)
|
||||
|
||||
return {
|
||||
"external_count": external_count,
|
||||
"external_value": external_value,
|
||||
# 손절선 이탈 격리분 — 집행은 하지 않되 프론트·로그에서 반드시 보이게 한다
|
||||
"external_stop_breaches": _external_stop_breaches(_external),
|
||||
"total_stocks": total_stocks,
|
||||
"domestic_value": domestic_value,
|
||||
"overseas_value_usd": overseas_value_usd,
|
||||
@@ -420,6 +503,11 @@ class PortfolioCache:
|
||||
"bfdy_total_assets": bfdy_total_assets,
|
||||
"today_profit_loss": today_profit_loss,
|
||||
"today_profit_loss_rate": today_profit_loss_rate,
|
||||
# 유입 격리(external) 노출 — 자동 청산 대상에서 빠진 보유분을 프론트가
|
||||
# 구분할 수 있게 한다(리스크 지표 total_assets/total_value 에는 포함).
|
||||
"external_count": portfolio_values["external_count"],
|
||||
"external_value": portfolio_values["external_value"],
|
||||
"external_stop_breaches": portfolio_values["external_stop_breaches"],
|
||||
# 해외 관련 추가 필드
|
||||
"overseas_value_usd": overseas_value_usd,
|
||||
"overseas_value_krw": overseas_value_krw if exchange_rate > 0 else overseas_total_krw,
|
||||
@@ -614,6 +702,43 @@ async def sync_portfolio(db: AsyncSession = Depends(get_db), force: bool = True)
|
||||
if ghost_candidates:
|
||||
logger.info(f"🚫 Ghost Stock Candidates (Last action today is SELL): {ghost_candidates}")
|
||||
|
||||
# ── 유입(external) 잔고 격리용 '시스템 매수 근거' 수집 ──────────────
|
||||
# [2026-09-28] 모의계좌 교체 등으로 계좌에 편입된 "시스템이 매수하지 않은"
|
||||
# 잔고를 strategy_type='external' 로 태깅해 스윙 자동 청산 대상에서 뺀다.
|
||||
# 태깅이 없으면 sync 가 전부 'swing' 으로 편입하고 get_swing_holdings()가
|
||||
# 곧바로 손절 판정을 내려 유입 잔고를 전략 손절로 처분했다
|
||||
# (2026-04~09 고아 청산 9건 / -153,661원).
|
||||
# 근거는 "기간 무제한" 이어야 한다 — ghost 필터처럼 당일만 보면 자정을
|
||||
# 넘긴 유입 잔고가 다시 정상 보유로 재편입된다.
|
||||
_buy_hist_rows = await db.execute(
|
||||
select(TradeHistory.stock_code)
|
||||
.where(
|
||||
TradeHistory.order_type == "BUY",
|
||||
TradeHistory.status.in_(["EXECUTED", "COMPLETED", "PARTIAL"]),
|
||||
)
|
||||
.distinct()
|
||||
)
|
||||
_system_bought = {c for c in _buy_hist_rows.scalars().all() if c}
|
||||
# ActiveTrade 행도 근거로 인정한다(체결 이력 유실 대비). 단 sync 가 유입
|
||||
# 포지션에 대해 자동 시딩한 행(strategy_name='synced')은 근거가 아니다 —
|
||||
# 근거로 쓰면 external → swing 자동 승격이라는 자기순환이 생긴다.
|
||||
_at_rows_prov = await db.execute(
|
||||
select(ActiveTrade.stock_code)
|
||||
.where(
|
||||
or_(
|
||||
ActiveTrade.strategy_name.is_(None),
|
||||
ActiveTrade.strategy_name.notin_(_SEEDED_AT_STRATEGY_NAMES),
|
||||
)
|
||||
)
|
||||
.distinct()
|
||||
)
|
||||
_system_codes = _system_bought | {c for c in _at_rows_prov.scalars().all() if c}
|
||||
_system_codes |= _dt_codes # 당일 DT 인메모리 포지션도 시스템 매수분
|
||||
|
||||
def _classify_strategy_type(_code: str, _qty: int) -> str:
|
||||
"""Portfolio 행의 strategy_type 판정 (모듈 순수 함수 위임)."""
|
||||
return _classify_holding_strategy_type(_code, _qty, _dt_qty, _system_codes)
|
||||
|
||||
# 기존 레코드 로드 (삭제 대신 업데이트)
|
||||
result = await db.execute(select(Portfolio))
|
||||
existing_all = {p.stock_code: p for p in result.scalars().all()}
|
||||
@@ -689,6 +814,7 @@ async def sync_portfolio(db: AsyncSession = Depends(get_db), force: bool = True)
|
||||
)
|
||||
|
||||
processed_codes.add(stock_code)
|
||||
_classified = _classify_strategy_type(stock_code, quantity)
|
||||
if stock_code in existing_all:
|
||||
h = existing_all[stock_code]
|
||||
h.stock_name = stock_name
|
||||
@@ -701,8 +827,25 @@ 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") == EXTERNAL_STRATEGY_TYPE and _classified == "swing":
|
||||
# 승격만 허용 — 시스템 매수가 실제로 발생한 뒤에만 운용 대상으로.
|
||||
# 반대 방향(운용 중인 swing/daytrading/manual → external) 강등은
|
||||
# 하지 않는다: 체결 이력이 유실된 정상 포지션이 손절·익절 관리를
|
||||
# 통째로 잃는 쪽이 더 위험하다.
|
||||
h.strategy_type = "swing"
|
||||
logger.warning(
|
||||
f"⬆️ 유입 격리 해제(승격): {stock_name}({stock_code}) "
|
||||
f"external → swing (시스템 매수 근거 확인)"
|
||||
)
|
||||
new_holdings.append(h)
|
||||
else:
|
||||
if _classified == EXTERNAL_STRATEGY_TYPE:
|
||||
logger.warning(
|
||||
f"🚧 유입 잔고 격리: {stock_name}({stock_code}) {quantity}주 "
|
||||
f"평단={average_price} 평가액={float(current_price) * quantity:,.0f}원 "
|
||||
f"— 시스템 매수 이력 없음 → strategy_type='external' "
|
||||
f"(자동 손절·익절 대상 제외, 수동 판단 필요)"
|
||||
)
|
||||
new_holding = Portfolio(
|
||||
stock_code=stock_code,
|
||||
stock_name=stock_name,
|
||||
@@ -712,11 +855,7 @@ async def sync_portfolio(db: AsyncSession = Depends(get_db), force: bool = True)
|
||||
profit_loss=profit_loss,
|
||||
profit_loss_rate=profit_loss_rate,
|
||||
market="KRX",
|
||||
strategy_type=(
|
||||
"daytrading"
|
||||
if quantity <= _dt_qty.get(stock_code, 0)
|
||||
else "swing"
|
||||
),
|
||||
strategy_type=_classified,
|
||||
)
|
||||
db.add(new_holding)
|
||||
new_holdings.append(new_holding)
|
||||
@@ -771,6 +910,7 @@ async def sync_portfolio(db: AsyncSession = Depends(get_db), force: bool = True)
|
||||
)
|
||||
|
||||
processed_codes.add(stock_code)
|
||||
_classified = _classify_strategy_type(stock_code, quantity)
|
||||
if stock_code in existing_all:
|
||||
h = existing_all[stock_code]
|
||||
h.stock_name = stock_name
|
||||
@@ -784,8 +924,19 @@ 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") == EXTERNAL_STRATEGY_TYPE and _classified == "swing":
|
||||
# 승격만 허용 (국내 경로와 동일 규칙)
|
||||
h.strategy_type = "swing"
|
||||
logger.warning(
|
||||
f"⬆️ [해외] 유입 격리 해제(승격): {stock_name}({stock_code}) external → swing"
|
||||
)
|
||||
new_holdings.append(h)
|
||||
else:
|
||||
if _classified == EXTERNAL_STRATEGY_TYPE:
|
||||
logger.warning(
|
||||
f"🚧 [해외] 유입 잔고 격리: {stock_name}({stock_code}) {quantity}주 "
|
||||
f"평단={average_price} — 시스템 매수 이력 없음 → strategy_type='external'"
|
||||
)
|
||||
new_holding = Portfolio(
|
||||
stock_code=stock_code,
|
||||
stock_name=stock_name,
|
||||
@@ -795,11 +946,7 @@ async def sync_portfolio(db: AsyncSession = Depends(get_db), force: bool = True)
|
||||
profit_loss=profit_loss,
|
||||
profit_loss_rate=profit_loss_rate,
|
||||
market=exchange_code,
|
||||
strategy_type=(
|
||||
"daytrading"
|
||||
if quantity <= _dt_qty.get(stock_code, 0)
|
||||
else "swing"
|
||||
),
|
||||
strategy_type=_classified,
|
||||
)
|
||||
db.add(new_holding)
|
||||
new_holdings.append(new_holding)
|
||||
@@ -869,6 +1016,13 @@ async def sync_portfolio(db: AsyncSession = Depends(get_db), force: bool = True)
|
||||
continue
|
||||
if (_h.strategy_type or "swing") == "daytrading":
|
||||
continue
|
||||
# [2026-09-28] 유입 격리분(external)은 ActiveTrade 를 만들지 않는다.
|
||||
# ① 사용자 결정: 유입 잔고는 자동 손절·익절 대상에서 제외한다.
|
||||
# ② 시딩하면 그 행이 다음 sync 의 '시스템 매수 근거'로 오인될 위험이
|
||||
# 있어(strategy_name 이 'synced' 가 아닌 값으로 굳는 경로) 격리가
|
||||
# 스스로 풀릴 수 있다.
|
||||
if (_h.strategy_type or "swing") == EXTERNAL_STRATEGY_TYPE:
|
||||
continue
|
||||
_avg = float(_h.average_price or 0)
|
||||
if _avg <= 0:
|
||||
continue
|
||||
@@ -926,6 +1080,34 @@ async def sync_portfolio(db: AsyncSession = Depends(get_db), force: bool = True)
|
||||
except Exception as _seed_err:
|
||||
logger.warning(f"ActiveTrade 시딩 큐잉 실패(무시): {_seed_err}")
|
||||
|
||||
# 격리된 유입 잔고는 "조용히 방치"되면 안 된다 — 매 동기화마다 한 줄로 노출.
|
||||
# (자동 손절·익절이 걸리지 않으므로 운영자의 수동 판단이 유일한 관리 수단)
|
||||
_external_rows = [
|
||||
(h.stock_name or h.stock_code, h.stock_code, int(h.quantity),
|
||||
float(h.current_price or 0) * int(h.quantity))
|
||||
for h in new_holdings
|
||||
if h.quantity > 0 and (h.strategy_type or "swing") == EXTERNAL_STRATEGY_TYPE
|
||||
]
|
||||
if _external_rows:
|
||||
logger.warning(
|
||||
"🚧 유입 격리 보유 %d종목 (자동 청산 제외): %s",
|
||||
len(_external_rows),
|
||||
", ".join(
|
||||
f"{_n}({_c}) {_q}주/{_v:,.0f}" for _n, _c, _q, _v in _external_rows
|
||||
),
|
||||
)
|
||||
# 손절선 이탈은 절대 침묵시키지 않는다 — 이번 사건의 본질이 '조용한 실패'였다.
|
||||
# 집행은 하지 않으며 처분 판단은 운영자 수동 청산(portfolio_v2)에 맡긴다.
|
||||
for _br in _external_stop_breaches(
|
||||
[h for h in new_holdings if h.quantity > 0]
|
||||
):
|
||||
logger.warning(
|
||||
"🔻 유입 격리분 손절선 이탈(자동 청산 안 함): %s(%s) "
|
||||
"손익률 %.2f%% / 기준 %.1f%% / 초과 이탈 %.2f%%p — 수동 판단 필요",
|
||||
_br["stock_name"], _br["stock_code"], _br["profit_loss_rate"],
|
||||
_br["stop_loss_pct"], _br["breach_pct"],
|
||||
)
|
||||
|
||||
portfolio_cache.holdings = [h for h in new_holdings if h.quantity > 0]
|
||||
await portfolio_cache.cache_summary(balance_data, overseas_balance_data) # 국내·해외 잔고 전달
|
||||
portfolio_cache.last_synced = datetime.now()
|
||||
@@ -1151,6 +1333,12 @@ async def check_and_execute_exit_trades(db: AsyncSession):
|
||||
|
||||
for holding in holdings:
|
||||
try:
|
||||
# [2026-09-28] 유입 격리분은 자동 청산 대상이 아니다.
|
||||
# 이 함수는 deprecated 이고 현재 스케줄러에 배선돼 있지 않지만
|
||||
# (auto_trade_scheduler 는 create_task 되지 않는다), 되살아날 때
|
||||
# strategy_type 필터가 없는 전수 조회라 격리가 통째로 뚫린다.
|
||||
if (holding.strategy_type or "swing") == EXTERNAL_STRATEGY_TYPE:
|
||||
continue
|
||||
stock_code = holding.stock_code
|
||||
stock_name = holding.stock_name
|
||||
quantity = holding.quantity
|
||||
@@ -1553,6 +1741,7 @@ async def sell_stock(
|
||||
stock_name = holding.stock_name
|
||||
original_holding_qty = holding.quantity
|
||||
original_avg_price = float(holding.average_price or 0)
|
||||
original_strategy_type = holding.strategy_type or "swing"
|
||||
|
||||
# 매도 주문 실행
|
||||
# [2026-09-23] safe_place_order 경유 — 종목별 세마포어 + Redis 분산락으로
|
||||
@@ -1633,6 +1822,21 @@ async def sell_stock(
|
||||
except Exception as _at_err:
|
||||
logger.warning(f"[SELL] ActiveTrade 정리 실패 {stock_code}: {_at_err}")
|
||||
|
||||
# LossTracker 연패 기록 — 전량 청산 시에만(분할 매도 이중 카운트 방지).
|
||||
# [2026-09-28] 수동 청산이 빠져 있어 실질 3연패 종목이 2로 집계됐다
|
||||
# (카카오뱅크: 9-22 수동청산 1건 누락 → CONSECUTIVE_LOSS_BAN 미발동).
|
||||
# 조건·순서는 swing/exit_service.py 와 동일하게 record → save_to_db.
|
||||
# 유입 격리분(external)은 전략 성과가 아니므로 연패에 넣지 않는다.
|
||||
if quantity >= original_holding_qty and original_strategy_type != EXTERNAL_STRATEGY_TYPE:
|
||||
try:
|
||||
await loss_tracker.record_trade_result_safe(
|
||||
stock_code=stock_code,
|
||||
is_loss=profit_loss < 0,
|
||||
)
|
||||
await loss_tracker.save_to_db(stock_code=stock_code)
|
||||
except Exception as _lt_err:
|
||||
logger.warning(f"[SELL] LossTracker 기록 실패 {stock_code}: {_lt_err}")
|
||||
|
||||
# 데이트레이딩 엔진이 같은 종목을 보유 중이면 인메모리 잔량도 줄인다.
|
||||
# (누락 시 엔진이 매도된 수량을 그대로 들고 있다고 믿고 다음 사이클에 재매도)
|
||||
try:
|
||||
|
||||
@@ -450,6 +450,7 @@ async def sell_stock_v2(
|
||||
stock_name = holding.stock_name
|
||||
original_holding_qty = holding.quantity
|
||||
average_price = holding.average_price
|
||||
original_strategy_type = holding.strategy_type or "swing"
|
||||
|
||||
order_result = await safe_place_order_v2(
|
||||
ctx, stock_code=stock_code, order_type="SELL", quantity=quantity, price=price, is_domestic=True,
|
||||
@@ -516,6 +517,23 @@ async def sell_stock_v2(
|
||||
await _enq_at(_at_payload)
|
||||
await db.commit()
|
||||
|
||||
# LossTracker 연패 기록 — 전량 청산 시에만(분할 매도 이중 카운트 방지).
|
||||
# [2026-09-28] 수동 청산이 빠져 연패가 과소 집계되던 구멍을 막는다.
|
||||
# 조건·순서는 swing/exit_service.py 와 동일하게 record → save.
|
||||
# 유입 격리분(external)은 전략 성과가 아니므로 제외.
|
||||
if quantity >= original_holding_qty and original_strategy_type != "external":
|
||||
try:
|
||||
await ctx.loss_tracker.record_trade_result_safe(
|
||||
stock_code=stock_code,
|
||||
is_loss=profit_loss < 0,
|
||||
)
|
||||
if ctx.is_legacy:
|
||||
await ctx.loss_tracker.save_to_db(stock_code=stock_code)
|
||||
else:
|
||||
await ctx.loss_tracker.save_to_db_v2(ctx.user_id, stock_code=stock_code)
|
||||
except Exception as _lt_err:
|
||||
logger.warning(f"[SELL v2] LossTracker 기록 실패 {stock_code}: {_lt_err}")
|
||||
|
||||
logger.info(f"✅ [v2] user_id={ctx.user_id} 수동 매도 완료: {stock_name} {quantity}주 @ {executed_price:,.0f}원")
|
||||
return {
|
||||
"success": True,
|
||||
|
||||
@@ -34,7 +34,10 @@ router = APIRouter()
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 🔥 리스크 관리 설정 — config.py 참조
|
||||
MAX_POSITION_SIZE_RATIO = settings.MAX_POSITION_SIZE # config.py: 0.06 (6%)
|
||||
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연속 손실 시 중단
|
||||
COMMISSION_RATE = 0.00015 # 수수료 0.015%
|
||||
@@ -273,14 +276,18 @@ async def get_today_session(db: AsyncSession = Depends(get_db)):
|
||||
async def check_risk_limits(
|
||||
order_value: float = Query(..., description="주문 금액"),
|
||||
account_balance: float = Query(..., description="계좌 잔고"),
|
||||
strategy: str = Query("swing", description="전략 구분: swing | daytrading (종목당 한도 분기)"),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
⚠️ 주문 전 리스크 제한 확인
|
||||
|
||||
- 포지션 사이즈 제한
|
||||
|
||||
- 포지션 사이즈 제한 (전략별: 스윙 MAX_POSITION_SIZE / DT DAY_TRADE_MAX_CAPITAL_RATIO)
|
||||
- 일일 손실 한도
|
||||
- 연속 손실 제한
|
||||
|
||||
[2026-09-28] `strategy` 는 기본값이 있는 추가 파라미터다(기존 호출부 동작 불변).
|
||||
이전에는 DT 주문(설계상 6%)을 스윙 상한 4%로 검사해 정상 주문을 위반으로 냈다.
|
||||
"""
|
||||
try:
|
||||
# 1. 오늘 세션 확인
|
||||
@@ -342,15 +349,22 @@ async def check_risk_limits(
|
||||
"consecutive_losses": session.consecutive_losses
|
||||
}
|
||||
|
||||
# 5. 포지션 사이즈 제한 확인
|
||||
# 5. 포지션 사이즈 제한 확인 (전략별 한도)
|
||||
_strategy = (strategy or "swing").strip().lower()
|
||||
_position_cap = (
|
||||
DT_POSITION_SIZE_RATIO
|
||||
if _strategy in ("daytrading", "day_trade", "dt")
|
||||
else MAX_POSITION_SIZE_RATIO
|
||||
)
|
||||
position_ratio = order_value / account_balance if account_balance > 0 else 0
|
||||
if position_ratio > MAX_POSITION_SIZE_RATIO:
|
||||
if position_ratio > _position_cap:
|
||||
return {
|
||||
"allowed": False,
|
||||
"reason": f"포지션 사이즈 초과 ({position_ratio*100:.1f}% > {MAX_POSITION_SIZE_RATIO*100}%)",
|
||||
"reason": f"포지션 사이즈 초과 ({position_ratio*100:.1f}% > {_position_cap*100}%)",
|
||||
"risk_level": "HIGH",
|
||||
"strategy": _strategy,
|
||||
"position_ratio": round(position_ratio * 100, 2),
|
||||
"max_allowed": round(MAX_POSITION_SIZE_RATIO * 100, 2)
|
||||
"max_allowed": round(_position_cap * 100, 2)
|
||||
}
|
||||
|
||||
# 모든 제한 통과
|
||||
|
||||
Reference in New Issue
Block a user