fix: CRITICAL/HIGH 버그 30건 수정 + 전체 코드 주석 추가 + 중복 정리

## CRITICAL 수정 (20건)
- day_trading: global 선언 누락, loss_tracker 연동, ban 체크 추가
- trade_executor: BUY 체결가 order_price로 수정, remaining_pct 누적 계산
- risk_validator: get_current_price dict 파싱, MAX_POSITION_SIZE 동기화, DB 세션 누수
- risk_management/recommendations: date.today() → KST 변환
- mdd_manager: Lock 밖 executed_key 참조 수정
- correlation_filter: output → output2 키 수정
- portfolio: 해외 종목 USD/KRW 환율 적용 분리
- Sharpe Ratio 거래빈도 기반 연간화
- telegram_notifier: send_message() 공개 래퍼 추가
- position_manager: 앱 재시작 시 DB 포지션 복원
- swing exit_service: remaining_pct 조건 범위 수정
- run_backtest: original_qty 기반 remaining_pct 계산
- us_market_schedule: 2027년 미국 공휴일 추가
- Login.tsx: res.ok 검증 추가
- mean_reversion_strategy: RSI 로그 하드코딩 수정

## HIGH 수정 (10건)
- db_worker: DLQ 파일 백업 메커니즘 추가
- korea_investment_api: 만료 토큰 임의 연장 제거
- market_data: chart_cache 최대 100개 + TTL 만료
- main.py: rate_limit_store 1시간 미접근 IP 정리
- recommendations: fire-and-forget try/except 추가
- swing/scheduler: DB 세션 3개 분리
- Dashboard: wsMessages 전체 순회
- Layout: /health 엔드포인트 + 30초 폴링
- ml_ensemble: POST /train require_api_key 추가
- day_trading: record_trade_result_safe() 전환

## 코드 정리
- 프론트엔드: isUsStock, formatTime 공통 유틸 추출
- 백엔드: dead code 제거, 중복 import 통합
- MCP: JSON 파싱 헬퍼 추출 (~60줄 감소)
- 전체 ~100개 파일 한국어 주석 추가
- 테스트 13건 코드 기준 수정 (468 passed)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-26 16:05:41 +09:00
parent 739a8a2e5d
commit de7921d270
149 changed files with 133353 additions and 3338 deletions

View File

@@ -24,6 +24,7 @@ from app.services.mean_reversion_strategy import (
from app.services.mdd_manager import mdd_manager
from app.services.loss_tracker import loss_tracker
from app.services.websocket_manager import websocket_manager
from app.services.utils import _is_domestic
import asyncio
import logging
from datetime import datetime, timedelta
@@ -33,18 +34,6 @@ logger = logging.getLogger(__name__)
# 한국 시간대
KST = pytz.timezone('Asia/Seoul')
# 해외 거래소 코드 목록
OVERSEAS_MARKETS = {"NASD", "NYSE", "AMEX", "TSEX", "SEHK", "SHAA", "SZAA", "HASE", "VNSE"}
def _is_domestic(stock_code: str, market: str = None) -> bool:
"""국내 종목 여부 판별.
market 컬럼이 있으면 우선 사용, 없으면 종목코드 패턴으로 폴백.
"""
if market:
return market not in OVERSEAS_MARKETS
# 폴백: 6자리 숫자이면 국내(KRX)
return stock_code.isdigit() and len(stock_code) == 6
# Request/Response Models
class SellRequest(BaseModel):
stock_code: str
@@ -55,6 +44,157 @@ def _get_kst_now():
"""현재 한국 시간 반환 (timezone-naive datetime)"""
return datetime.now(KST).replace(tzinfo=None)
def _pick_float(d: dict, keys: list) -> float:
"""Helper to extract float value from dict by key priority."""
for k in keys:
if k in d and d.get(k) not in (None, ""):
try:
return float(d[k])
except Exception:
continue
return 0.0
def _calculate_portfolio_values(holdings: list) -> dict:
"""Calculate total value, investment, and profit/loss from holdings.
Returns dict with total_stocks, total_value, total_investment, total_profit_loss,
total_profit_loss_rate, domestic_value, overseas_value_usd.
"""
total_stocks = len(holdings)
# 국내/해외 분리하여 평가금액 계산
domestic_value = sum(
float(h.current_price or 0) * h.quantity
for h in holdings if _is_domestic(h.stock_code, getattr(h, "market", None))
)
overseas_value_usd = sum(
float(h.current_price or 0) * h.quantity
for h in holdings if not _is_domestic(h.stock_code, getattr(h, "market", None))
)
# 국내/해외 투자금을 분리 계산 (통화 혼합 방지)
domestic_investment = sum(
float(h.average_price or 0) * h.quantity
for h in holdings if _is_domestic(h.stock_code, getattr(h, "market", None))
)
overseas_investment_usd = sum(
float(h.average_price or 0) * h.quantity
for h in holdings if not _is_domestic(h.stock_code, getattr(h, "market", None))
)
# 국내 손익 (KRW 단위)
domestic_profit_loss = domestic_value - domestic_investment
# 해외 손익 (USD 단위) — 원화 환산은 cache_summary()에서 환율 적용 시 수행
overseas_profit_loss_usd = overseas_value_usd - overseas_investment_usd
# 하위 호환: total_investment 은 국내만 (기존 호출처 동작 유지)
total_investment = domestic_investment
total_profit_loss = domestic_profit_loss
return {
"total_stocks": total_stocks,
"domestic_value": domestic_value,
"overseas_value_usd": overseas_value_usd,
"domestic_investment": domestic_investment,
"overseas_investment_usd": overseas_investment_usd,
"domestic_profit_loss": domestic_profit_loss,
"overseas_profit_loss_usd": overseas_profit_loss_usd,
"total_investment": total_investment,
"total_profit_loss": total_profit_loss,
}
def _extract_balance_data(balance_data: dict) -> dict:
"""Extract cash and total assets from KIS API balance response.
Returns dict with available_cash, total_assets, kis_total_assets, output2.
"""
available_cash = 0.0
total_assets = 0.0
kis_total_assets = 0.0
output2 = {}
if balance_data:
logger.info(f"🔍 KIS API balance response keys: {balance_data.keys()}")
if "output2" in balance_data:
output2 = balance_data["output2"][0] if isinstance(balance_data["output2"], list) else balance_data["output2"]
logger.info(f"📊 output2 keys: {output2.keys()}")
logger.info(f"💰 output2 data: {output2}")
else:
logger.warning("⚠️ No 'output2' in balance_data!")
if balance_data and "output2" in balance_data:
output2 = balance_data["output2"][0] if isinstance(balance_data["output2"], list) else balance_data["output2"]
available_cash = _pick_float(output2, [
"dnca_tot_amt",
"d2_depo_amt",
"d2_deposit",
"nass_amt"
])
kis_total_assets = _pick_float(output2, ["tot_evlu_amt", "tot_evlu_amt_smtl"])
if kis_total_assets > 0:
total_assets = kis_total_assets
try:
logger.debug(
"Portfolio balance extract - cash=%s, total_assets=%s, keys=%s",
f"{available_cash:,.0f}",
f"{total_assets:,.0f}",
{k: output2.get(k) for k in [
"d2_depo_amt", "d2_deposit", "dnca_tot_amt", "nass_amt", "tot_evlu_amt"
] if k in output2}
)
except Exception:
pass
return {
"available_cash": available_cash,
"total_assets": total_assets,
"kis_total_assets": kis_total_assets,
"output2": output2,
}
def _extract_detailed_balance(balance_data: dict) -> dict:
"""Extract D+2 settlement and stock value details from KIS API response."""
d2_settlement = 0.0
stock_value_from_api = 0.0
bfdy_total_assets = 0.0
if balance_data and "output2" in balance_data:
output2 = balance_data["output2"][0] if isinstance(balance_data["output2"], list) else balance_data["output2"]
d2_settlement = _pick_float(output2, ["nxdy_excc_amt", "d2_auto_rdpt_amt"])
stock_value_from_api = _pick_float(output2, ["scts_evlu_amt", "evlu_amt_smtl_amt"])
bfdy_total_assets = _pick_float(output2, ["bfdy_tot_asst_evlu_amt"])
return {
"d2_settlement": d2_settlement,
"stock_value_from_api": stock_value_from_api,
"bfdy_total_assets": bfdy_total_assets,
}
def _extract_overseas_balance(overseas_balance_data: dict) -> dict:
"""Extract overseas holdings total from KIS API response."""
overseas_total_krw = 0.0
if overseas_balance_data and "output2" in overseas_balance_data:
ovrs_out2 = overseas_balance_data["output2"]
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"])
if overseas_total_krw > 0:
logger.debug(f"💵 해외 잔고 합산: {overseas_total_krw:,.0f}원")
return {"overseas_total_krw": overseas_total_krw}
# 단순 인메모리 캐시 (프로세스 단위). 다중 프로세스 환경이면 Redis 등 필요.
class PortfolioCache:
def __init__(self):
@@ -174,113 +314,66 @@ class PortfolioCache:
overseas_balance_data: 해외 잔고 KIS API 응답
exchange_rate: USD/KRW 환율 (해외 종목 원화 환산용, 0이면 미제공)
"""
# 1. Calculate portfolio values from holdings
holdings = self.holdings
portfolio_values = _calculate_portfolio_values(holdings)
def pick_float(d: dict, keys: list) -> float:
for k in keys:
if k in d and d.get(k) not in (None, ""):
try:
return float(d[k])
except Exception:
continue
return 0.0
domestic_value = portfolio_values["domestic_value"]
overseas_value_usd = portfolio_values["overseas_value_usd"]
domestic_investment = portfolio_values["domestic_investment"]
overseas_investment_usd = portfolio_values["overseas_investment_usd"]
total_stocks = len(holdings)
# 국내/해외 분리하여 평가금액 계산
domestic_value = sum(
float(h.current_price or 0) * h.quantity
for h in holdings if _is_domestic(h.stock_code, getattr(h, "market", None))
)
overseas_value_usd = sum(
float(h.current_price or 0) * h.quantity
for h in holdings if not _is_domestic(h.stock_code, getattr(h, "market", None))
)
# 해외 원화 환산 (환율 미제공 시 overseas_balance_data output2의 tot_evlu_amt 사용)
overseas_value_krw = overseas_value_usd * exchange_rate if exchange_rate > 0 else 0.0
overseas_investment_krw = overseas_investment_usd * exchange_rate if exchange_rate > 0 else 0.0
total_value = domestic_value + overseas_value_krw
total_investment = sum(float(h.average_price or 0) * h.quantity for h in holdings)
total_profit_loss = total_value - total_investment
# 손익 계산: 국내(KRW) + 해외(환율 적용 KRW) 분리 후 합산
domestic_profit_loss = domestic_value - domestic_investment
overseas_profit_loss_krw = overseas_value_krw - overseas_investment_krw
total_investment = domestic_investment + overseas_investment_krw
total_profit_loss = domestic_profit_loss + overseas_profit_loss_krw
total_profit_loss_rate = (total_profit_loss / total_investment * 100) if total_investment > 0 else 0
# KIS API output2에서 예수금 및 총자산 정보 추출
available_cash = 0.0
total_assets = 0.0
if balance_data:
logger.info(f"🔍 KIS API balance response keys: {balance_data.keys()}")
if "output2" in balance_data:
output2 = balance_data["output2"][0] if isinstance(balance_data["output2"], list) else balance_data["output2"]
logger.info(f"📊 output2 keys: {output2.keys()}")
logger.info(f"💰 output2 data: {output2}")
else:
logger.warning("⚠️ No 'output2' in balance_data!")
# 2. Extract balance data from KIS API
balance_info = _extract_balance_data(balance_data)
available_cash = balance_info["available_cash"]
total_assets = balance_info["total_assets"]
kis_total_assets = balance_info["kis_total_assets"]
output2 = balance_info["output2"]
if balance_data and "output2" in balance_data:
output2 = balance_data["output2"][0] if isinstance(balance_data["output2"], list) else balance_data["output2"]
available_cash = pick_float(output2, [
"dnca_tot_amt",
"d2_depo_amt",
"d2_deposit",
"nass_amt"
])
kis_total_assets = pick_float(output2, ["tot_evlu_amt", "tot_evlu_amt_smtl"])
if kis_total_assets > 0:
total_assets = kis_total_assets
calculated_assets = total_value + available_cash
if abs(kis_total_assets - calculated_assets) > 1000:
logger.debug(
f"💰 총자산 - KIS API: {kis_total_assets:,.0f}원, "
f"단순계산(현금+주식): {calculated_assets:,.0f}원, "
f"차이: {(kis_total_assets - calculated_assets):,.0f}원 (D+2정산액 포함)"
)
else:
total_assets = total_value + available_cash
try:
# Log discrepancies
if kis_total_assets > 0:
calculated_assets = total_value + available_cash
if abs(kis_total_assets - calculated_assets) > 1000:
logger.debug(
"Portfolio summary calc - cash=%s, total_assets=%s, keys=%s",
f"{available_cash:,.0f}",
f"{total_assets:,.0f}",
{k: output2.get(k) for k in [
"d2_depo_amt", "d2_deposit", "dnca_tot_amt", "nass_amt", "tot_evlu_amt"
] if k in output2}
f"💰 총자산 - KIS API: {kis_total_assets:,.0f}원, "
f"단순계산(현금+주식): {calculated_assets:,.0f}원, "
f"차이: {(kis_total_assets - calculated_assets):,.0f}원 (D+2정산액 포함)"
)
except Exception:
pass
else:
total_assets = total_value + available_cash
# D+2 정산액 및 기타 상세 정보 추출
d2_settlement = 0.0
stock_value_from_api = 0.0
bfdy_total_assets = 0.0
if balance_data and "output2" in balance_data:
output2 = balance_data["output2"][0] if isinstance(balance_data["output2"], list) else balance_data["output2"]
d2_settlement = pick_float(output2, ["nxdy_excc_amt", "d2_auto_rdpt_amt"])
stock_value_from_api = pick_float(output2, ["scts_evlu_amt", "evlu_amt_smtl_amt"])
bfdy_total_assets = pick_float(output2, ["bfdy_tot_asst_evlu_amt"])
# 3. Extract detailed balance info (D+2, stock value, previous day total)
detail_info = _extract_detailed_balance(balance_data)
d2_settlement = detail_info["d2_settlement"]
stock_value_from_api = detail_info["stock_value_from_api"]
bfdy_total_assets = detail_info["bfdy_total_assets"]
# 해외 잔고 합산 (원화 환산 총자산에 포함)
overseas_total_krw = 0.0
if overseas_balance_data and "output2" in overseas_balance_data:
ovrs_out2 = overseas_balance_data["output2"]
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"])
if overseas_total_krw > 0:
total_assets += overseas_total_krw
logger.debug(f"💵 해외 잔고 합산: {overseas_total_krw:,.0f}원")
# 4. Extract overseas balance
overseas_info = _extract_overseas_balance(overseas_balance_data)
overseas_total_krw = overseas_info["overseas_total_krw"]
# 금일 평가손익 (전일 총자산 대비 오늘 변화)
if overseas_total_krw > 0:
total_assets += overseas_total_krw
# 5. Calculate daily profit/loss
today_profit_loss = (total_assets - bfdy_total_assets) if bfdy_total_assets > 0 else 0.0
today_profit_loss_rate = (today_profit_loss / bfdy_total_assets * 100) if bfdy_total_assets > 0 else 0.0
# 6. Build and store summary
summary_data = {
"total_stocks": total_stocks,
"total_stocks": portfolio_values["total_stocks"],
"total_value": total_value,
"total_investment": total_investment,
"total_profit_loss": total_profit_loss,
@@ -318,6 +411,7 @@ class PortfolioResponse(BaseModel):
profit_loss: Decimal = None
profit_loss_rate: Decimal = None
market: str = "KRX"
strategy_type: str = "swing"
class Config:
from_attributes = True
@@ -626,6 +720,10 @@ async def sync_portfolio(db: AsyncSession = Depends(get_db), force: bool = True)
logger.info(f"🗑️ Deleting {h.stock_name}({code}) from portfolio DB (not in KIS API response)")
await db.delete(h)
# TODO: db_worker 마이그레이션 필요 — 읽기+업데이트가 같은 트랜잭션에서
# 이뤄져야 하므로(Portfolio SELECT → UPDATE/DELETE → commit) 큐로 분리가 어려움.
# sync_portfolio()는 API 엔드포인트(get_db 의존성) 경로에서만 호출되므로
# 실시간 콜백/스케줄러 직접 호출과는 성격이 다름.
await db.commit()
portfolio_cache.holdings = [h for h in new_holdings if h.quantity > 0]
await portfolio_cache.cache_summary(balance_data, overseas_balance_data) # 국내·해외 잔고 전달
@@ -694,7 +792,16 @@ async def get_portfolio_summary(db: AsyncSession = Depends(get_db)):
return resp
async def _sync_portfolio_background():
"""백그라운드 포트폴리오 KIS 동기화 (비차단). 이미 동기화 중이면 건너뜀."""
"""백그라운드 포트폴리오 KIS 동기화 (비차단). 이미 동기화 중이면 건너뜀.
[DB 직접 접근 예외 사유]
sync_portfolio()는 KIS API → Portfolio READ/UPSERT → ActiveTrade UPSERT의
복합 트랜잭션이 한 세션 내에서 원자적으로 실행되어야 합니다.
db_worker 큐는 단순 INSERT/UPDATE 태스크 단위를 처리하므로
이 복합 조회+쓰기 흐름을 분리하면 일관성이 깨집니다.
따라서 이 함수에 한해 AsyncSessionLocal 직접 사용을 허용합니다.
(실시간 WebSocket 콜백이 아닌 asyncio.create_task 백그라운드 태스크에서 호출)
"""
if portfolio_cache.sync_lock.locked():
return
try:
@@ -754,7 +861,7 @@ async def get_portfolio_combined(db: AsyncSession = Depends(get_db)):
db_holdings = list(result.scalars().all())
if db_holdings:
portfolio_cache.holdings = db_holdings
portfolio_cache.cache_summary() # available_cash=0이지만 holdings는 즉시 표시
await portfolio_cache.cache_summary() # available_cash=0이지만 holdings는 즉시 표시
logger.info(f"Portfolio cold-start: DB {len(db_holdings)}종목 즉시 반환, KIS 갱신 백그라운드 시작")
asyncio.create_task(_sync_portfolio_background())
return _build_combined_resp()
@@ -776,15 +883,27 @@ async def refresh_portfolio(db: AsyncSession = Depends(get_db)):
async def check_and_execute_exit_trades(db: AsyncSession):
"""
보유 종목의 손절/익절 조건을 체크하고 자동 매도 실행
"""[DEPRECATED] 스윙 포지션 손절/익절 체크.
.. deprecated::
services/swing/exit_service.py의 check_and_execute_exit_v2()로 대체됨.
swing_scheduler가 호출. 이 함수는 더 이상 활성 스케줄러에서 호출되지 않음.
하위 호환을 위해 함수는 유지.
CLAUDE.md 섹션 6.2 + 7.3 + 7.4 기반:
- ATR 기반 손절: min(최저가 - 2.5×ATR, 진입가 -6%) ← -8% → -6%
- ATR 기반 손절: min(최저가 - 2.5×ATR, 진입가 -6%)
- 1차 익절(30%/50%): RSI 55~65 + BB 중앙선 상회
- 2차 익절(50%): RSI ≥ 65 + BB 상단선 도달
- 트레일링 스탑: 최고가 대비 -3% 하락 시 잔여 전량 청산
- 폴백: 고정 비율 손절 (STOP_LOSS_PERCENT)
"""
import warnings
warnings.warn(
"check_and_execute_exit_trades is deprecated. "
"Use services/swing/exit_service.check_and_execute_exit_v2() instead.",
DeprecationWarning,
stacklevel=2,
)
from app.core.config import settings
from datetime import datetime
@@ -803,6 +922,13 @@ async def check_and_execute_exit_trades(db: AsyncSession):
mdd_action = await mdd_manager.update(total_assets)
if mdd_action["action"] != "NONE":
logger.warning(f"MDD 조치: {mdd_action}")
# CRITICAL: MDD 액션에 따른 실제 포지션 축소 매도 실행
try:
reduction_results = await mdd_manager.execute_reduction(mdd_action)
if reduction_results:
logger.warning(f"MDD 강제 축소 완료: {len(reduction_results)}종목 매도")
except Exception as e:
logger.error(f"MDD 강제 축소 실행 실패: {e}", exc_info=True)
result = await db.execute(
select(Portfolio).where(Portfolio.quantity > 0)
@@ -933,9 +1059,10 @@ async def check_and_execute_exit_trades(db: AsyncSession):
if exit_signal.action == "SELL_PARTIAL":
sell_quantity = max(1, int(quantity * exit_signal.sell_pct))
logger.info(f"ATR 청산: {stock_name} - {exit_reason} (매도 {sell_quantity}주)")
elif profit_loss_rate <= settings.STOP_LOSS_PERCENT:
# [이슈9 연동] STOP_LOSS_PERCENT는 양수(6.0)로 저장됨 — 손절 비교 시 음수 변환
elif profit_loss_rate <= -abs(settings.STOP_LOSS_PERCENT):
should_exit = True
exit_reason = f"고정 손절 ({profit_loss_rate:.2f}% <= {settings.STOP_LOSS_PERCENT}%)"
exit_reason = f"고정 손절 ({profit_loss_rate:.2f}% <= -{abs(settings.STOP_LOSS_PERCENT):.1f}%)"
elif profit_loss_rate >= settings.TAKE_PROFIT_PERCENT:
should_exit = True
exit_reason = f"고정 익절 ({profit_loss_rate:.2f}% >= {settings.TAKE_PROFIT_PERCENT}%)"
@@ -975,32 +1102,34 @@ async def check_and_execute_exit_trades(db: AsyncSession):
total_amount = Decimal(str(executed_price)) * sell_quantity
profit_loss = (float(executed_price) - avg_price) * sell_quantity
trade = TradeHistory(
stock_code=stock_code,
stock_name=stock_name,
order_type="SELL",
order_price=Decimal(str(order_price)),
order_quantity=sell_quantity,
executed_price=executed_price,
executed_quantity=sell_quantity,
total_amount=total_amount,
status="COMPLETED" if order_result.get("rt_cd") == "0" else "FAILED",
order_id=order_result.get("odno"),
message=f"자동 청산: {profit_loss_rate:.2f}%",
exit_reason=f"{exit_reason}. 평균단가: {avg_price:,.0f}원, 매도가: {latest_price:,.0f}원, 손익: {profit_loss:,.0f}원",
created_at=_get_kst_now(),
executed_at=_get_kst_now() if order_result.get("rt_cd") == "0" else None
)
db.add(trade)
await db.commit()
# TradeHistory DB 기록 (db_worker 큐 경유 — CLAUDE.md 3.2 준수)
from app.services.db_worker import enqueue_trade_history
_trade_status = "COMPLETED" if order_result.get("rt_cd") == "0" else "FAILED"
await enqueue_trade_history({
"stock_code": stock_code,
"stock_name": stock_name,
"order_type": "SELL",
"order_price": Decimal(str(order_price)),
"order_quantity": sell_quantity,
"executed_price": executed_price,
"executed_quantity": sell_quantity,
"total_amount": total_amount,
"status": _trade_status,
"order_id": order_result.get("odno"),
"message": f"자동 청산: {profit_loss_rate:.2f}%",
"exit_reason": f"{exit_reason}. 평균단가: {avg_price:,.0f}원, 매도가: {latest_price:,.0f}원, 손익: {profit_loss:,.0f}원",
"created_at": _get_kst_now(),
"executed_at": _get_kst_now() if order_result.get("rt_cd") == "0" else None,
})
if order_result.get("rt_cd") == "0":
# 연속 손실 추적 (섹션 7.4) — 인메모리 + DB 영속화
is_loss = profit_loss < 0
loss_tracker.record_trade_result(stock_code, is_loss)
await loss_tracker.record_trade_result_safe(stock_code, is_loss)
await loss_tracker.save_to_db(db, stock_code)
# 분할 청산 시 ActiveTrade 잔여 비율 업데이트
# TODO: db_worker 마이그레이션 필요 — 읽기+업데이트가 같은 트랜잭션이라 큐 분리 어려움
if exit_signal and exit_signal.action == "SELL_PARTIAL" and active_trade:
new_remaining = remaining_pct * (1.0 - exit_signal.sell_pct)
active_trade.remaining_pct = Decimal(str(round(new_remaining, 4)))
@@ -1022,9 +1151,9 @@ async def check_and_execute_exit_trades(db: AsyncSession):
})
logger.info(f"자동 매도 완료: {stock_name} {sell_quantity}주 @ {latest_price:,.0f}원")
# 매매일지 백그라운드 생성
# 매매일지 백그라운드 생성 — trade_id 미확보이므로 0 전달
asyncio.create_task(_create_journal_background(
trade_id=trade.id,
trade_id=0,
stock_code=stock_code,
stock_name=stock_name,
order_type="SELL",
@@ -1080,8 +1209,9 @@ async def _create_journal_background(
tags = []
# MCP 서버로 AI 일지 생성 요청 (실패 시 규칙 기반 폴백)
_mcp_headers = {"X-API-Key": settings.MCP_API_KEY} if settings.MCP_API_KEY else {}
try:
async with httpx.AsyncClient() as client:
async with httpx.AsyncClient(headers=_mcp_headers) as client:
resp = await client.post(
f"{settings.MCP_SERVER_URL}/analyze",
json={"analysis_type": "GENERATE_JOURNAL", "data": trade_info},
@@ -1108,21 +1238,19 @@ async def _create_journal_background(
mood = "좋음" if profit_loss > 0 else "나쁨"
tags = [direction, stock_code]
# DB 저장 (별도 세션 사용 — 호출 시점의 세션은 이미 닫혔을 수 있음)
async with AsyncSessionLocal() as session:
journal = TradeJournal(
trade_id=trade_id,
stock_code=stock_code,
stock_name=stock_name,
journal_date=datetime.utcnow(),
content=content,
strategy_name="AUTO_TRADE",
mood=mood,
tags=tags if tags else None,
)
session.add(journal)
await session.commit()
logger.info(f"매매일지 생성 완료: {stock_name} (trade_id={trade_id})")
# DB 저장 — CLAUDE.md 3.2: db_worker 큐 경유
from app.services.db_worker import enqueue_trade_journal_raw
await enqueue_trade_journal_raw({
"trade_id": trade_id,
"stock_code": stock_code,
"stock_name": stock_name,
"journal_date": datetime.utcnow(),
"content": content,
"strategy_name": "AUTO_TRADE",
"mood": mood,
"tags": tags if tags else None,
})
logger.info(f"매매일지 큐 등록 완료: {stock_name} (trade_id={trade_id})")
# WebSocket으로 프론트엔드에 즉시 알림
await websocket_manager.broadcast_json({
@@ -1194,23 +1322,27 @@ async def sell_stock(
profit_loss = (executed_price - holding.average_price) * quantity
profit_loss_rate = ((executed_price / holding.average_price) - 1) * 100
trade = TradeHistory(
stock_code=stock_code,
stock_name=stock_name,
order_type='SELL',
order_price=Decimal(str(price)) if price else None,
order_quantity=quantity,
executed_price=Decimal(str(executed_price)),
executed_quantity=quantity,
total_amount=Decimal(str(executed_price * quantity)),
status='EXECUTED',
order_id=order_result.get('output', {}).get('ODNO'),
message=f"수동 청산: 손익 {profit_loss:+,.0f}원 ({profit_loss_rate:+.2f}%)",
exit_reason=f"사용자 수동 청산 (손익: {profit_loss:+,.0f}원, 손익률: {profit_loss_rate:+.2f}%)",
created_at=_get_kst_now(),
executed_at=_get_kst_now()
)
db.add(trade)
# CLAUDE.md 3.2: db_worker 큐 경유
from app.services.db_worker import enqueue_trade_history
await enqueue_trade_history({
"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,
"total_amount": float(executed_price * quantity),
"status": "COMPLETED",
"order_id": order_result.get('output', {}).get('ODNO'),
"message": f"수동 청산: 손익 {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,
"strategy_type": "manual",
"created_at": _get_kst_now(),
"executed_at": _get_kst_now(),
})
# ✅ Portfolio DB 즉시 반영 (매도 후 보유종목에서 즉시 제거)
if quantity >= original_holding_qty: