fix: 프로그램 검증 루프 7회차 — 리스크·주문·청산·인프라 결함 일괄 수정 + 거래 확대 결정 반영
하네스 규칙 검사 전역 위반 117→0, 회차별 검증(code-verifier opus 4도메인)으로 Critical/High 해소. 주문·리스크 - MDD 강제축소 반복 매도(종목별 완료 집합·재시도 상한·날짜 롤오버·잔고 오류 보류·epoch·장외 미주문) - KIS 거부 FAILED 기록, 결과 불명(전송 후) 재전송 금지·PENDING, 미전송 실패만 재시도(msg_cd 분류) - 국내 전량·손실 매도 시장가, 부분 익절 직전 현재가, 지정가만 미체결 취소(cancel_order_v2: TR 0013U·조직번호) + 0체결 롤백 - 누적 4%·현금·레버리지(신규분 포함)·사이클 내 매수 누적, 수동매수 공용 가드(buy_guards), 수동매도 DT 차감·락 - 가드 우회 엔드포인트 차단(/recommendations/auto-trade 위임, US /execute 매수 410·매도 가드) - 매수 슬롯=성공 매수, 평가 상한·회전, 업무거부/계좌 쿨다운, 프로세스 내 in-flight 주문 중복 차단 데이트레이딩 - 진입·청산 shield, 청산 진행 중 표식(소유 토큰), 2단계 청산 체크, 결과 불명 → 보유 감소 판정·비DT 차감 상한 - 전일 포지션 재시도(일시 실패 미산정), 분할매도 영속, ORB 구간 손절, /cycle 락, 사이클 내 현금 차감, 체결 수량 보정 스윙 - 손절 공백(판정 예외 시 하드 바닥), 단일 손절 소스·범위 검증, 매도 직전 KIS 잔고 상한(min(주문가능,보유)−DT) - 결과 불명 체결 대조·장부 반영, 대체가(나이·진입 이후·ATR 캐시), 분할 단계 기록, 국면 실패 fail-closed - 듀얼 플레이북·서킷브레이커 게이트, 평균회귀 우선·듀얼 독립, 시간손절 거래일 - 거래 확대(사용자 결정, 백테스트 미검증): RSI 46/35 정본 경로, 모멘텀 v2 ON, SIDEWAYS ADX AND, 트레일링 1차 익절 후, 20일 시간손절 ON 인프라·보안 - 계좌·손익 GET 인증, 세션 is_active·관리자 경로, 서명키 파생, realip, WS 토큰 마스킹 - KIS 토큰 즉시 실패(sleep 제거)·무효 시 캐시 삭제, 주문 5xx+JSON 본문 반환 - db_worker 종료 drain(멱등 재실행·커밋 불명 격리·예산·밴 커밋), stop_grace 40s, Redis 타임아웃·volatile-lru, /health DLQ·캐시 - KRX 휴장일, py3.11 비호환 f-string 수정 + Jenkins 이미지 내 compileall·헬스 실패 시 빌드 실패 하네스: rules_check 에 PEP 701 f-string 검사, 마커(allow-*), deploy 스모크 401 기준 CLAUDE.md: 실제 동작·플래그·백로그(§8.5-27~38) 갱신 backend 1451 passed / hooks 112 passed / tsc ok / py3.9 compile 0 errors Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -25,7 +25,7 @@ async def get_adaptive_status():
|
||||
return _get_status()
|
||||
except Exception as e:
|
||||
logger.error("[Adaptive API] 상태 조회 실패: %s", e)
|
||||
return {"error": str(e), "trading_mode": "normal", "fallback_used": True}
|
||||
return {"error": "상태 조회 실패 — 서버 로그 확인", "trading_mode": "normal", "fallback_used": True}
|
||||
|
||||
|
||||
@router.post("/refresh", dependencies=[Depends(require_api_key)])
|
||||
@@ -43,7 +43,7 @@ async def refresh_adaptive_params():
|
||||
return {"result": "refreshed", **_get_status()}
|
||||
except Exception as e:
|
||||
logger.error("[Adaptive API] 수동 갱신 실패: %s", e)
|
||||
return {"error": str(e)}
|
||||
return {"error": "갱신 실패 — 서버 로그 확인"}
|
||||
|
||||
|
||||
@router.get("/history")
|
||||
@@ -59,4 +59,4 @@ async def get_mode_history(days: int = 30):
|
||||
return {"history": history, "count": len(history)}
|
||||
except Exception as e:
|
||||
logger.error("[Adaptive API] 이력 조회 실패: %s", e)
|
||||
return {"history": [], "count": 0, "error": str(e)}
|
||||
return {"history": [], "count": 0, "error": "이력 조회 실패 — 서버 로그 확인"}
|
||||
|
||||
@@ -50,7 +50,7 @@ async def get_performance(
|
||||
return await analyze_performance(db, days=days)
|
||||
except Exception as e:
|
||||
logger.error(f"performance 분석 실패: {e}", exc_info=True)
|
||||
return {"error": str(e), "summary": {}, "total_trades": 0}
|
||||
return {"error": "성과 분석 실패 — 서버 로그 확인", "summary": {}, "total_trades": 0}
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
@@ -76,7 +76,7 @@ async def get_insights(
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"insights 생성 실패: {e}", exc_info=True)
|
||||
return {"insights": [], "error": str(e)}
|
||||
return {"insights": [], "error": "인사이트 생성 실패 — 서버 로그 확인"}
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
@@ -107,7 +107,7 @@ async def _generate_journals_task(days: int = 7) -> Dict[str, Any]:
|
||||
except Exception as e:
|
||||
await db.rollback()
|
||||
logger.error(f"일지 일괄 생성 실패: {e}", exc_info=True)
|
||||
return {"created": 0, "error": str(e)}
|
||||
return {"created": 0, "error": "일지 생성 실패 — 서버 로그 확인"}
|
||||
|
||||
|
||||
@router.post("/generate-journals", dependencies=[Depends(require_api_key)])
|
||||
@@ -131,7 +131,7 @@ async def generate_journals(
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"generate-journals 실패: {e}", exc_info=True)
|
||||
return {"status": "error", "message": str(e)}
|
||||
return {"status": "error", "message": "거래 일지 생성 시작 실패 — 서버 로그 확인"}
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -37,7 +37,9 @@ from app.core.config import settings
|
||||
from app.core.database import get_db
|
||||
from app.core.security import (
|
||||
_get_api_key,
|
||||
client_ip_v2,
|
||||
create_session_token,
|
||||
invalidate_session_user_cache,
|
||||
require_api_key,
|
||||
verify_session_token,
|
||||
)
|
||||
@@ -135,10 +137,9 @@ async def _check_password_async(password: str, password_hash: str) -> bool:
|
||||
|
||||
|
||||
def _client_ip(request: Request) -> str:
|
||||
fwd = request.headers.get("x-forwarded-for")
|
||||
if fwd:
|
||||
return fwd.split(",")[0].strip()
|
||||
return request.client.host if request.client else "unknown"
|
||||
# [M-2 2026-10-02] X-Forwarded-For 첫 값을 신뢰하면 클라이언트가 매 요청마다 값을 바꿔
|
||||
# 로그인 잠금(IP 단위)을 우회할 수 있었다. 신뢰 프록시(nginx)가 덮어쓴 X-Real-IP 만 쓴다.
|
||||
return client_ip_v2(request)
|
||||
|
||||
|
||||
def _lockout_remaining(ip: str) -> int:
|
||||
@@ -283,6 +284,7 @@ async def approve_user(user_id: int, admin: str = Depends(require_admin), db: As
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="계정을 찾을 수 없습니다.")
|
||||
user.is_active = True
|
||||
await db.commit()
|
||||
invalidate_session_user_cache(user.username)
|
||||
logger.info(f"[Auth] 계정 승인 user={user.username} by={admin}")
|
||||
return UserOut.model_validate(user, from_attributes=True)
|
||||
|
||||
@@ -296,6 +298,8 @@ async def revoke_user(user_id: int, admin: str = Depends(require_admin), db: Asy
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="자기 자신은 비활성화할 수 없습니다.")
|
||||
user.is_active = False
|
||||
await db.commit()
|
||||
# [H-2] 캐시 TTL 을 기다리지 않고 즉시 세션 차단
|
||||
invalidate_session_user_cache(user.username)
|
||||
logger.info(f"[Auth] 계정 비활성화 user={user.username} by={admin}")
|
||||
return UserOut.model_validate(user, from_attributes=True)
|
||||
|
||||
|
||||
@@ -995,12 +995,15 @@ async def get_positions():
|
||||
"stock_name": pos.get("stock_name", code),
|
||||
"strategy": pos.get("strategy", ""),
|
||||
"entry_price": entry_price,
|
||||
"current_price": current_price,
|
||||
"stop_loss": pos.get("stop_loss"),
|
||||
"target_price": pos.get("target_price"),
|
||||
"current_price": _finite_or_none(current_price),
|
||||
# [M2 2026-10-02] 분할매도 후 target_price=inf(트레일링 전용 표식)가 그대로
|
||||
# 직렬화돼 JSON 인코딩 오류로 이 엔드포인트가 500 이었다 → inf/NaN 은 None.
|
||||
"stop_loss": _finite_or_none(pos.get("stop_loss")),
|
||||
"target_price": _finite_or_none(pos.get("target_price")),
|
||||
"partial_sold": bool(pos.get("partial_sold", False)),
|
||||
"quantity": quantity,
|
||||
"pnl": round(pnl, 0),
|
||||
"pnl_pct": round(pnl_pct, 2),
|
||||
"pnl": _finite_or_none(round(pnl, 0)),
|
||||
"pnl_pct": _finite_or_none(round(pnl_pct, 2)),
|
||||
"entry_time": str(pos.get("entry_time", "")),
|
||||
})
|
||||
return {
|
||||
@@ -1035,16 +1038,67 @@ async def close_all_positions():
|
||||
}
|
||||
|
||||
|
||||
def _finite_or_none(value):
|
||||
"""[M2] JSON 직렬화 가능한 유한 숫자만 통과 — inf/NaN/변환 불가는 None."""
|
||||
import math
|
||||
if value is None:
|
||||
return None
|
||||
try:
|
||||
f = float(value)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
return f if math.isfinite(f) else None
|
||||
|
||||
|
||||
# [M5] 수동 사이클 단계별 상한(초) — 스케줄러 STEP_TIMEOUT 과 같은 값을 쓴다.
|
||||
def _manual_cycle_step_timeout() -> int:
|
||||
from app.services.daytrading.scheduler import STEP_TIMEOUT
|
||||
return STEP_TIMEOUT
|
||||
|
||||
|
||||
@router.post("/cycle", dependencies=[Depends(require_api_key)])
|
||||
async def manual_cycle():
|
||||
"""1 사이클 수동 실행 — 실제 엔진의 청산·진입 로직 호출 (테스트/디버그용)."""
|
||||
"""1 사이클 수동 실행 — 실제 엔진의 청산·진입 로직 호출 (테스트/디버그용).
|
||||
|
||||
[M5 2026-10-02] 스케줄러와 **같은 전역 락(dt_cycle_lock)** 을 잡는다. 예전에는
|
||||
스케줄러 사이클과 동시에 돌아 같은 포지션을 두 번 매도하거나, 두 스캔이 슬롯·예산
|
||||
검사를 동시에 통과해 한도를 넘겨 매수할 수 있었다. 락이 잡혀 있으면 기다리지 않고
|
||||
409 를 돌려준다. 게이트: DAY_TRADE_ENABLED · 장 운영 시간 · (진입은) 진입 시간창.
|
||||
"""
|
||||
from app.services.daytrading.exit_service import check_and_exit_positions
|
||||
from app.services.daytrading.entry_service import scan_and_enter
|
||||
from app.services.daytrading.position_manager import dt_cycle_lock
|
||||
|
||||
if not settings.DAY_TRADE_ENABLED:
|
||||
raise HTTPException(409, "DAY_TRADE_ENABLED=False — 데이트레이딩 비활성")
|
||||
if not is_market_open():
|
||||
raise HTTPException(409, "장 운영 시간이 아님")
|
||||
if dt_cycle_lock.locked():
|
||||
raise HTTPException(409, "데이트레이딩 사이클 실행 중 — 잠시 후 재시도")
|
||||
|
||||
_es, _st = _engine()
|
||||
_st.reset_if_new_day()
|
||||
exits = await check_and_exit_positions()
|
||||
entries = await scan_and_enter()
|
||||
return {"exits": exits, "entries": entries, "positions": _st.position_count()}
|
||||
_timeout = _manual_cycle_step_timeout()
|
||||
async with dt_cycle_lock:
|
||||
_st.reset_if_new_day()
|
||||
try:
|
||||
exits = await asyncio.wait_for(check_and_exit_positions(), timeout=_timeout)
|
||||
except asyncio.TimeoutError:
|
||||
logger.error(f"[DayTrade] 수동 사이클 청산 체크 {_timeout}초 초과 — 취소")
|
||||
exits = []
|
||||
entries: list = []
|
||||
entry_skipped = None
|
||||
if is_day_trade_entry_window():
|
||||
try:
|
||||
entries = await asyncio.wait_for(scan_and_enter(), timeout=_timeout)
|
||||
except asyncio.TimeoutError:
|
||||
logger.error(f"[DayTrade] 수동 사이클 진입 스캔 {_timeout}초 초과 — 취소")
|
||||
entries = []
|
||||
else:
|
||||
entry_skipped = "진입 시간창 밖 — 청산 체크만 수행"
|
||||
return {
|
||||
"exits": exits, "entries": entries, "positions": _st.position_count(),
|
||||
"entry_skipped": entry_skipped,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/status")
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
"""추천 종목 수동 매수 엔드포인트 (프론트 "매수" 버튼 — 청산 버튼의 매수 대응).
|
||||
|
||||
POST /api/v1/portfolio/buy
|
||||
- quantity 생략 시: 총자산 × MAX_POSITION_SIZE 예산으로 시장가 수량 자동 산출
|
||||
- quantity 생략 시: 공용 매수 가드(services/buy_guards) 예산으로 수량 자동 산출
|
||||
- quantity 지정 시: 같은 예산 상한으로 클램프(응답 clamped/clamp_reasons)
|
||||
- 리스크 가드(§5): 가격 필터·연속손실 ban·MDD 신규진입 차단·최대 보유수·TRADING_HALTED
|
||||
+ buy_guards: 종목당 4% 누적·현금 유보·레버리지 20%·최소주문 실효값·Crisis·섹터/상관
|
||||
- 주문은 trade_executor.safe_place_order (종목별 세마포어 + Redis 분산락) 경유
|
||||
- DB 쓰기는 db_worker 큐 경유(insert_trade_history / upsert_portfolio), 이후 KIS 잔고 재동기화
|
||||
"""
|
||||
@@ -26,6 +28,7 @@ from app.models.models import Portfolio
|
||||
from app.services.korea_investment_api import korea_investment_api
|
||||
from app.services.loss_tracker import loss_tracker
|
||||
from app.services.mdd_manager import mdd_manager
|
||||
from app.services.buy_guards import BuyGuardRejected, evaluate_manual_buy_v2
|
||||
from app.services.utils import _is_domestic
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -105,25 +108,29 @@ async def _buy_stock_impl(request: BuyRequest, stock_code: str, db: AsyncSession
|
||||
if cnt >= settings.MAX_HOLDINGS_COUNT:
|
||||
raise HTTPException(status_code=400, detail=f"최대 보유 종목 수({settings.MAX_HOLDINGS_COUNT}) 초과")
|
||||
|
||||
# ── 수량 산출 (예산 = 총자산 × MAX_POSITION_SIZE, 예수금 한도) ────────────
|
||||
quantity = request.quantity
|
||||
# ── 사이징·자본 가드 (CLAUDE.md §5.1, 공용 buy_guards) ─────────────────
|
||||
# [H-4 2026-10-02] 종목당 4% 누적·현금 유보·레버리지 20%·최소주문 실효값·
|
||||
# 적응형 Crisis·섹터/상관 필터. 지정 수량은 상한으로 클램프. 판정은 try 밖이며
|
||||
# 조회 실패는 매수 거부(fail-closed).
|
||||
order_price = float(request.price) if request.price else current_price
|
||||
if quantity is None:
|
||||
try:
|
||||
balance = await korea_investment_api.get_balance()
|
||||
output2 = balance.get("output2") or [{}]
|
||||
b = output2[0] if isinstance(output2, list) else output2
|
||||
available_cash = float(b.get("dnca_tot_amt") or 0)
|
||||
total_assets = float(b.get("tot_evlu_amt") or 0)
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=502, detail=f"잔고 조회 실패: {e}")
|
||||
budget = min(total_assets * settings.MAX_POSITION_SIZE, available_cash)
|
||||
quantity = int(budget // order_price)
|
||||
if quantity < 1:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"예산 부족: 예산 {budget:,.0f}원 < 1주 {order_price:,.0f}원 (예수금 {available_cash:,.0f}원)",
|
||||
)
|
||||
current_holdings = (await db.execute(
|
||||
select(Portfolio).where(Portfolio.quantity > 0)
|
||||
)).scalars().all()
|
||||
try:
|
||||
guard = await evaluate_manual_buy_v2(
|
||||
kis=korea_investment_api,
|
||||
stock_code=stock_code,
|
||||
stock_name=stock_name,
|
||||
current_price=current_price,
|
||||
order_price=order_price,
|
||||
requested_quantity=request.quantity,
|
||||
holding=holding,
|
||||
current_holdings=list(current_holdings),
|
||||
)
|
||||
except BuyGuardRejected as rej:
|
||||
logger.warning(f"[MANUAL_BUY] {stock_code} 거부({rej.reason}): {rej.detail}")
|
||||
raise HTTPException(status_code=rej.status_code, detail=rej.detail)
|
||||
quantity = guard.quantity
|
||||
|
||||
logger.info(f"🟢 수동 매수 요청: {stock_name}({stock_code}) {quantity}주 @ {request.price or '시장가'}")
|
||||
|
||||
@@ -186,4 +193,5 @@ async def _buy_stock_impl(request: BuyRequest, stock_code: str, db: AsyncSession
|
||||
"order_id": order_id,
|
||||
"quantity": quantity,
|
||||
"price": order_price,
|
||||
**guard.to_response(),
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@ from sqlalchemy import select
|
||||
|
||||
# 내부 모듈 임포트
|
||||
from app.core.config import settings
|
||||
from app.core.log_throttle import warn_throttled
|
||||
from app.core.database import get_db
|
||||
from app.services.korea_investment_api import korea_investment_api
|
||||
|
||||
@@ -609,8 +610,14 @@ def _suggest_targets(current_price: int, atr: float | None) -> dict:
|
||||
if not _adp.fallback_used:
|
||||
stop_pct = abs(float(_adp.stop_loss_pct))
|
||||
atr_mult = float(_adp.atr_stop_multiplier)
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as _adp_err:
|
||||
# [§4 2026-10-02] 폴백(config 값) 유지, 단 조용히 넘기지 않는다.
|
||||
warn_throttled(
|
||||
logger, "market_data:target_stop_overlay",
|
||||
"[AI분석] 적응형 손절/ATR 배수 조회 실패 — config 값 사용: %s", _adp_err,
|
||||
)
|
||||
stop_pct = abs(float(getattr(_cfg, "STOP_LOSS_PERCENT", 7.0)))
|
||||
atr_mult = float(getattr(_cfg, "ATR_STOP_MULTIPLIER", 2.5))
|
||||
|
||||
pct_stop = current_price * (1 - stop_pct / 100)
|
||||
if atr and atr > 0:
|
||||
@@ -691,9 +698,15 @@ def _suggest_buy_amount(
|
||||
from app.services.adaptive_strategy import get_current_adaptive_params
|
||||
_adp = get_current_adaptive_params()
|
||||
if not _adp.fallback_used:
|
||||
position_cap = min(float(_adp.position_size), _hard_cap)
|
||||
except Exception:
|
||||
pass
|
||||
position_cap = float(_adp.position_size)
|
||||
except Exception as _adp_err:
|
||||
warn_throttled(
|
||||
logger, "market_data:position_cap_overlay",
|
||||
"[AI분석] 적응형 position_size 조회 실패 — MAX_POSITION_SIZE 사용: %s", _adp_err,
|
||||
)
|
||||
position_cap = _hard_cap
|
||||
# [§4 2026-10-02] 절대 상한 클램프는 try 밖에서 무조건 적용.
|
||||
position_cap = max(0.0, min(position_cap, _hard_cap))
|
||||
|
||||
budget = total_assets * position_cap
|
||||
# 신뢰도 보정 (기준 0.7)
|
||||
@@ -805,7 +818,7 @@ async def get_ai_analysis(stock_code: str, db: AsyncSession = Depends(get_db)):
|
||||
}
|
||||
logger.info(f"📊 {stock_code} 지표 실시간 계산 (DB 없음 fallback)")
|
||||
except Exception as fe:
|
||||
logger.debug(f"지표 fallback 계산 실패 ({stock_code}): {fe}")
|
||||
logger.warning(f"지표 fallback 계산 실패 ({stock_code}): {fe}")
|
||||
|
||||
# 3. 규칙 기반 분석 로직
|
||||
recommendation = "HOLD" # 기본 추천: 보유
|
||||
@@ -937,7 +950,7 @@ async def get_ai_analysis(stock_code: str, db: AsyncSession = Depends(get_db)):
|
||||
[c["close"] for c in _db_chart],
|
||||
)
|
||||
except Exception as _atr_err:
|
||||
logger.debug(f"[AI분석] ATR 계산 실패 ({stock_code}): {_atr_err}")
|
||||
logger.warning(f"[AI분석] ATR 계산 실패 ({stock_code}): {_atr_err}")
|
||||
|
||||
# 추세 최종 결정 (이동평균선 없으면 가격 변동으로)
|
||||
if trend == "SIDEWAYS" and high_price and low_price and current_price:
|
||||
|
||||
@@ -103,4 +103,4 @@ async def get_recent_news(
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[NEWS_API] 최근 뉴스 조회 오류: {e}")
|
||||
return {"news": [], "count": 0, "error": str(e)}
|
||||
return {"news": [], "count": 0, "error": "뉴스 조회 실패 — 서버 로그 확인"}
|
||||
|
||||
@@ -31,6 +31,8 @@ import logging
|
||||
from datetime import datetime, timedelta
|
||||
from app.core.timeutil import now_kst
|
||||
|
||||
from app.core.log_throttle import warn_throttled # noqa: E402
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 한국 시간대
|
||||
@@ -857,8 +859,8 @@ def _extract_balance_data(balance_data: dict) -> dict:
|
||||
"d2_depo_amt", "d2_deposit", "dnca_tot_amt", "nass_amt", "tot_evlu_amt"
|
||||
] if k in output2}
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as _swallowed:
|
||||
warn_throttled(logger, "portfolio:860", "[portfolio] 잔고 추출 디버그 로그 생성 실패: %s", _swallowed)
|
||||
|
||||
return {
|
||||
"available_cash": available_cash,
|
||||
@@ -1225,7 +1227,7 @@ async def sync_portfolio(db: AsyncSession = Depends(get_db), force: bool = True)
|
||||
_o2 = balance_data["output2"]
|
||||
(_o2[0] if isinstance(_o2, list) else _o2)["_ord_psbl_cash"] = _buyable
|
||||
except Exception as _bc_err:
|
||||
logger.debug(f"주문가능현금 조회 실패 (예수금총액 폴백): {_bc_err}")
|
||||
warn_throttled(logger, "portfolio:1228", f"주문가능현금 조회 실패 (예수금총액 폴백): {_bc_err}")
|
||||
|
||||
# 디버깅: KIS API 전체 응답 로깅
|
||||
logger.info(f"🔍 KIS API balance response keys: {balance_data.keys() if balance_data else 'None'}")
|
||||
@@ -1919,7 +1921,7 @@ async def _sync_portfolio_background():
|
||||
async with AsyncSessionLocal() as db:
|
||||
await sync_portfolio(db, force=False)
|
||||
except Exception as e:
|
||||
logger.debug(f"Background portfolio sync: {e}")
|
||||
warn_throttled(logger, "portfolio:1922", f"Background portfolio sync: {e}")
|
||||
|
||||
|
||||
def _build_combined_resp() -> dict:
|
||||
@@ -2128,7 +2130,7 @@ async def check_and_execute_exit_trades(db: AsyncSession):
|
||||
trailing_high=trailing_high,
|
||||
)
|
||||
except Exception as chart_err:
|
||||
logger.debug(f"ATR 청산 계산 실패 ({stock_code}): {chart_err}")
|
||||
warn_throttled(logger, "portfolio:2131", f"ATR 청산 계산 실패 ({stock_code}): {chart_err}")
|
||||
else:
|
||||
# 해외: 해외 차트로 ATR 계산 시도
|
||||
try:
|
||||
@@ -2163,7 +2165,7 @@ async def check_and_execute_exit_trades(db: AsyncSession):
|
||||
trailing_high=trailing_high,
|
||||
)
|
||||
except Exception as chart_err:
|
||||
logger.debug(f"[해외] ATR 청산 계산 실패 ({stock_code}): {chart_err}")
|
||||
warn_throttled(logger, "portfolio:2166", f"[해외] ATR 청산 계산 실패 ({stock_code}): {chart_err}")
|
||||
|
||||
should_exit = False
|
||||
exit_reason = ""
|
||||
@@ -2482,6 +2484,19 @@ async def sell_stock(
|
||||
original_avg_price = float(holding.average_price or 0)
|
||||
original_strategy_type = holding.strategy_type or "swing"
|
||||
|
||||
# [Low 3회차 2026-10-02] /swing/sell 과 같은 정책 — 수량 검증 + DT 보유분을 주문 **전**에
|
||||
# 확정. DT 수량을 모르면 DT/스윙 배분(ActiveTrade·DT 인메모리 차감)을 할 수 없어 매도 후
|
||||
# DT 엔진이 판 수량을 재매도할 수 있다 → 503(주문 전 거부).
|
||||
if quantity <= 0:
|
||||
raise HTTPException(status_code=400, detail="매도 수량은 1 이상이어야 합니다")
|
||||
from app.services.manual_sell_service import dt_inmemory_qty_v2 as _dt_qty_fn
|
||||
_dt_before = _dt_qty_fn(stock_code)
|
||||
if _dt_before is None:
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail="데이트레이딩 보유분 확인 실패 — 매도 배분을 확정할 수 없습니다",
|
||||
)
|
||||
|
||||
# 매도 주문 실행
|
||||
# [2026-09-23] safe_place_order 경유 — 종목별 세마포어 + Redis 분산락으로
|
||||
# 스윙/데이트레이딩 자동 청산과 동시에 매도되는 것을 막는다.
|
||||
@@ -2529,35 +2544,34 @@ async def sell_stock(
|
||||
"executed_at": _get_kst_now(),
|
||||
})
|
||||
|
||||
# ✅ Portfolio DB 즉시 반영 (매도 후 보유종목에서 즉시 제거)
|
||||
if quantity >= original_holding_qty:
|
||||
# 전량 매도 → 수량 0 (행 삭제는 동시 실행 중인 sync 와 StaleDataError 충돌을
|
||||
# 일으킨다. 수량 0 행은 조회에서 제외되고 다음 sync 가 KIS 기준으로 정리한다)
|
||||
holding.quantity = 0
|
||||
holding.current_price = Decimal(str(executed_price))
|
||||
else:
|
||||
# 분할 매도 → 수량 차감
|
||||
holding.quantity -= quantity
|
||||
holding.current_price = Decimal(str(executed_price))
|
||||
# [M-5 2026-10-02] DT 보유분을 매도 전에 확정 — 아래 배분(DT 우선)의 기준.
|
||||
from app.services.manual_sell_service import (
|
||||
allocate_sell_v2,
|
||||
reflect_dt_after_manual_sell_v2,
|
||||
sync_active_trade_after_manual_sell_v2,
|
||||
)
|
||||
_alloc = allocate_sell_v2(quantity, original_holding_qty, _dt_before, swing_only=False)
|
||||
|
||||
await db.commit()
|
||||
# ✅ Portfolio 즉시 반영 — db_worker 큐 경유(§3.2). 전량 매도는 수량 0
|
||||
# (행 삭제는 동시 실행 중인 sync 와 StaleDataError 충돌을 일으킨다. 수량 0 행은
|
||||
# 조회에서 제외되고 다음 sync 가 KIS 기준으로 정리한다)
|
||||
from app.services.db_worker import enqueue_portfolio_upsert as _enq_pf
|
||||
await _enq_pf({
|
||||
"stock_code": stock_code,
|
||||
"quantity": max(0, original_holding_qty - quantity),
|
||||
"current_price": float(executed_price),
|
||||
})
|
||||
|
||||
# ActiveTrade 정리 — 누락 시 유령 active 행이 남아, 같은 종목을 다시 매수했을 때
|
||||
# 옛 remaining_pct(예: 0.50)가 살아나 1차 익절이 건너뛰어진다.
|
||||
# (2026-09-23 확인: 삼아알미늄·LG 등 수동 청산 종목이 active 상태로 잔존)
|
||||
from app.services.db_worker import (
|
||||
enqueue_close_active_trade as _enq_close_at,
|
||||
enqueue_active_trade_upsert as _enq_at_upsert,
|
||||
)
|
||||
# [M-5 2026-10-02] 기존엔 전략명 없이(None) close/upsert 해 db_worker 가
|
||||
# '가장 최근 active 행'을 매칭 → 합산 행의 DT 행이 오폐기됐다(§5.3). DT 우선 배분 후
|
||||
# 비-DT/DT 행을 전략명 필터로 각각 갱신한다.
|
||||
try:
|
||||
if quantity >= original_holding_qty:
|
||||
await _enq_close_at(stock_code, strategy_name=None)
|
||||
else:
|
||||
await _enq_at_upsert({
|
||||
"stock_code": stock_code,
|
||||
"quantity": original_holding_qty - quantity,
|
||||
"current_price": float(executed_price),
|
||||
})
|
||||
await sync_active_trade_after_manual_sell_v2(
|
||||
stock_code, _alloc, price=float(executed_price),
|
||||
)
|
||||
except Exception as _at_err:
|
||||
logger.warning(f"[SELL] ActiveTrade 정리 실패 {stock_code}: {_at_err}")
|
||||
|
||||
@@ -2578,20 +2592,9 @@ async def sell_stock(
|
||||
|
||||
# 데이트레이딩 엔진이 같은 종목을 보유 중이면 인메모리 잔량도 줄인다.
|
||||
# (누락 시 엔진이 매도된 수량을 그대로 들고 있다고 믿고 다음 사이클에 재매도)
|
||||
try:
|
||||
from app.services.daytrading.position_manager import day_trading_state as _dts
|
||||
async with _dts.trade_lock:
|
||||
_dt_pos = _dts.positions.get(stock_code)
|
||||
if _dt_pos:
|
||||
_left = max(0, int(_dt_pos.get("quantity", 0) or 0) - quantity)
|
||||
if _left <= 0:
|
||||
_dts.remove_position(stock_code)
|
||||
logger.info(f"[SELL] DT 포지션 제거(수동 매도): {stock_code}")
|
||||
else:
|
||||
_dt_pos["quantity"] = _left
|
||||
logger.info(f"[SELL] DT 포지션 잔량 갱신(수동 매도): {stock_code} → {_left}주")
|
||||
except Exception as _dt_err:
|
||||
logger.warning(f"[SELL] DT 인메모리 반영 실패 {stock_code}: {_dt_err}")
|
||||
# DT 우선 배분분(_alloc.dt_sold)만 차감 — ActiveTrade 갱신과 같은 규칙. 전량 제거 시
|
||||
# closed_today 영속화까지 수행.
|
||||
await reflect_dt_after_manual_sell_v2(stock_code, _alloc.dt_sold)
|
||||
|
||||
# ✅ 인메모리 캐시 즉시 반영 (KIS 재동기화 전에도 화면에서 즉시 제거)
|
||||
if quantity >= original_holding_qty:
|
||||
|
||||
@@ -39,6 +39,13 @@ from app.services.db_worker import (
|
||||
enqueue_portfolio_upsert,
|
||||
enqueue_trade_history,
|
||||
)
|
||||
from app.services.buy_guards import BuyGuardRejected, evaluate_manual_buy_v2
|
||||
from app.services.manual_sell_service import (
|
||||
allocate_sell_v2,
|
||||
dt_inmemory_qty_v2,
|
||||
reflect_dt_after_manual_sell_v2,
|
||||
sync_active_trade_after_manual_sell_v2,
|
||||
)
|
||||
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
|
||||
@@ -249,8 +256,8 @@ async def sync_portfolio_v2(
|
||||
if ctx.portfolio_cache is not None:
|
||||
try:
|
||||
ctx.portfolio_cache.mark_changed()
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as _swallowed:
|
||||
logger.warning("[portfolio_v2] portfolio_cache.mark_changed 실패: %s", _swallowed)
|
||||
return {"message": "Portfolio synchronized", "count": len(processed_codes), "user_id": ctx.user_id}
|
||||
except HTTPException:
|
||||
raise
|
||||
@@ -336,24 +343,32 @@ async def _buy_stock_impl_v2(request: BuyRequest, stock_code: str, db: AsyncSess
|
||||
if cnt >= settings.MAX_HOLDINGS_COUNT:
|
||||
raise HTTPException(status_code=400, detail=f"최대 보유 종목 수({settings.MAX_HOLDINGS_COUNT}) 초과")
|
||||
|
||||
quantity = request.quantity
|
||||
# ── 사이징·자본 가드 (CLAUDE.md §5.1, 공용 buy_guards — manual_buy 와 동일) ──
|
||||
# [H-4 2026-10-02] 종목당 4% 누적·현금 유보·레버리지 20%·최소주문 실효값·
|
||||
# 적응형 Crisis·섹터/상관 필터. 지정 수량은 상한으로 클램프. 판정은 try 밖.
|
||||
order_price = float(request.price) if request.price else current_price
|
||||
if quantity is None:
|
||||
try:
|
||||
balance = await ctx.kis.get_balance()
|
||||
output2 = balance.get("output2") or [{}]
|
||||
b = output2[0] if isinstance(output2, list) else output2
|
||||
available_cash = float(b.get("dnca_tot_amt") or 0)
|
||||
total_assets = float(b.get("tot_evlu_amt") or 0)
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=502, detail=f"잔고 조회 실패: {e}")
|
||||
budget = min(total_assets * settings.MAX_POSITION_SIZE, available_cash)
|
||||
quantity = int(budget // order_price)
|
||||
if quantity < 1:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"예산 부족: 예산 {budget:,.0f}원 < 1주 {order_price:,.0f}원 (예수금 {available_cash:,.0f}원)",
|
||||
)
|
||||
current_holdings = (await db.execute(
|
||||
select(Portfolio).where(
|
||||
Portfolio.quantity > 0, _user_scope_filter(Portfolio.user_id, ctx.user_id)
|
||||
)
|
||||
)).scalars().all()
|
||||
try:
|
||||
guard = await evaluate_manual_buy_v2(
|
||||
kis=ctx.kis,
|
||||
stock_code=stock_code,
|
||||
stock_name=stock_name,
|
||||
current_price=current_price,
|
||||
order_price=order_price,
|
||||
requested_quantity=request.quantity,
|
||||
holding=holding,
|
||||
current_holdings=list(current_holdings),
|
||||
# DT 인메모리 상태는 레거시 계좌 소유 — 사용자 컨텍스트에는 섞지 않는다
|
||||
include_dt_inmemory=bool(ctx.is_legacy),
|
||||
)
|
||||
except BuyGuardRejected as rej:
|
||||
logger.warning(f"[MANUAL_BUY v2] user_id={ctx.user_id} {stock_code} 거부({rej.reason}): {rej.detail}")
|
||||
raise HTTPException(status_code=rej.status_code, detail=rej.detail)
|
||||
quantity = guard.quantity
|
||||
|
||||
logger.info(f"🟢 [v2] user_id={ctx.user_id} 수동 매수 요청: {stock_name}({stock_code}) {quantity}주")
|
||||
|
||||
@@ -419,6 +434,7 @@ async def _buy_stock_impl_v2(request: BuyRequest, stock_code: str, db: AsyncSess
|
||||
"order_id": order_id,
|
||||
"quantity": quantity,
|
||||
"price": order_price,
|
||||
**guard.to_response(),
|
||||
}
|
||||
|
||||
|
||||
@@ -456,12 +472,24 @@ async def sell_stock_v2(
|
||||
average_price = holding.average_price
|
||||
original_strategy_type = holding.strategy_type or "swing"
|
||||
|
||||
# [Low 3회차] DT 보유분은 주문 전에 확정(레거시 계좌만). 모르면 503 — /swing/sell·/sell 과 동일.
|
||||
_dt_qty = 0
|
||||
if ctx.is_legacy:
|
||||
_dt_qty = dt_inmemory_qty_v2(stock_code)
|
||||
if _dt_qty is None:
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail="데이트레이딩 보유분 확인 실패 — 매도 배분을 확정할 수 없습니다",
|
||||
)
|
||||
|
||||
order_result = await safe_place_order_v2(
|
||||
ctx, stock_code=stock_code, order_type="SELL", quantity=quantity, price=price, is_domestic=True,
|
||||
)
|
||||
if not order_result or order_result.get("rt_cd") != "0":
|
||||
error_msg = order_result.get("msg1", "알 수 없는 오류") if order_result else "API 호출 실패"
|
||||
raise HTTPException(status_code=500, detail=f"매도 주문 실패: {error_msg}")
|
||||
# order_lock_busy = 자동 청산 등 다른 경로가 같은 종목을 주문 중 → 재시도 가능(409)
|
||||
_status = 409 if error_msg == "order_lock_busy" else 500
|
||||
raise HTTPException(status_code=_status, detail=f"매도 주문 실패: {error_msg}")
|
||||
|
||||
executed_price = price if price else float(holding.current_price or 0)
|
||||
profit_loss = (executed_price - float(average_price or 0)) * quantity
|
||||
@@ -490,36 +518,29 @@ async def sell_stock_v2(
|
||||
sell_history_payload["user_id"] = ctx.user_id
|
||||
await enqueue_trade_history(sell_history_payload)
|
||||
|
||||
# Portfolio.strategy_type('daytrading'/'us_swing')과 ActiveTrade.strategy_name
|
||||
# ('day_trade'/'us_mean_reversion')은 값 체계가 달라 그대로 넘기면 매칭에 실패해
|
||||
# active 행이 영구 잔존한다(2026-09-23).
|
||||
_AT_STRATEGY = {"daytrading": "day_trade", "us_swing": "us_mean_reversion"}
|
||||
_at_name = _AT_STRATEGY.get(holding.strategy_type or "swing", holding.strategy_type or "swing")
|
||||
|
||||
if quantity >= original_holding_qty:
|
||||
await db.delete(holding)
|
||||
if ctx.is_legacy:
|
||||
# M3: legacy 스케줄러/기존 `/portfolio/sell`과 동일하게 user_id 없는 close 사용
|
||||
# (ActiveTrade.user_id == owner 필터로 NULL 행을 못 찾아 유령 포지션으로
|
||||
# 남는 결함 방지).
|
||||
await enqueue_close_active_trade(stock_code, strategy_name=_at_name)
|
||||
else:
|
||||
await enqueue_close_active_trade_v2(ctx.user_id, stock_code, strategy_name=_at_name)
|
||||
else:
|
||||
holding.quantity -= quantity
|
||||
holding.current_price = Decimal(str(executed_price))
|
||||
# 부분매도 시 ActiveTrade 잔량 갱신 — 누락 시 다음 청산이 매도 전 수량으로 판다
|
||||
from app.services.db_worker import enqueue_active_trade_upsert as _enq_at
|
||||
_at_payload = {
|
||||
"stock_code": stock_code,
|
||||
"strategy_name": _at_name,
|
||||
"quantity": original_holding_qty - quantity,
|
||||
"current_price": float(executed_price),
|
||||
}
|
||||
if not ctx.is_legacy:
|
||||
_at_payload["user_id"] = ctx.user_id
|
||||
await _enq_at(_at_payload)
|
||||
await db.commit()
|
||||
# [M-5 2026-10-02] ActiveTrade·Portfolio·DT 인메모리 반영.
|
||||
# 기존: (1) 보유 행의 strategy_type 한 가지로만 close/upsert → 합산 행(스윙+DT)에서
|
||||
# 다른 전략 행이 잔존/오폐기, (2) DT 인메모리 미차감 → DT 엔진이 판 수량을 재매도,
|
||||
# (3) `db.delete/commit` 직접 쓰기(§3.2 위반).
|
||||
# 수정: DT 우선 배분(레거시 /portfolio/sell·MDD 축소와 동일 규칙)으로 전략별 행을
|
||||
# 전략명 필터와 함께 갱신, DB 쓰기는 db_worker 큐 경유. DT 인메모리는 레거시 계좌 소유라
|
||||
# legacy 컨텍스트에서만 반영한다.
|
||||
alloc = allocate_sell_v2(quantity, original_holding_qty, _dt_qty, swing_only=False)
|
||||
await sync_active_trade_after_manual_sell_v2(
|
||||
stock_code, alloc, price=float(executed_price),
|
||||
user_id=None if ctx.is_legacy else ctx.user_id,
|
||||
)
|
||||
_pf_payload = {
|
||||
"stock_code": stock_code,
|
||||
# 0 → 목록에서 제외, 다음 sync 가 KIS 기준으로 정리(행 삭제는 sync 와 충돌)
|
||||
"quantity": max(0, original_holding_qty - quantity),
|
||||
"current_price": float(executed_price),
|
||||
}
|
||||
if not ctx.is_legacy:
|
||||
_pf_payload["user_id"] = ctx.user_id
|
||||
await enqueue_portfolio_upsert(_pf_payload)
|
||||
if ctx.is_legacy:
|
||||
await reflect_dt_after_manual_sell_v2(stock_code, alloc.dt_sold)
|
||||
|
||||
# LossTracker 연패 기록 — 전량 청산 시에만(분할 매도 이중 카운트 방지).
|
||||
# [2026-09-28] 수동 청산이 빠져 연패가 과소 집계되던 구멍을 막는다.
|
||||
|
||||
@@ -24,7 +24,7 @@ from app.services.market_analyzer import (
|
||||
analyze_stock_for_batch,
|
||||
RecommendationCriteria, StockRecommendation, KST
|
||||
)
|
||||
from app.services.trade_executor import execute_auto_trade_background, execute_auto_trade
|
||||
from app.services.trade_executor import execute_auto_trade_background
|
||||
from app.services.mean_reversion_strategy import MarketRegime
|
||||
import pytz
|
||||
|
||||
@@ -242,7 +242,58 @@ async def _load_active_rules_from_db(db: AsyncSession) -> list:
|
||||
)
|
||||
]
|
||||
|
||||
def _mean_reversion_rsi_thresholds_v2(conditions: dict) -> tuple:
|
||||
"""추천 스캐너 평균회귀 RSI 임계 (bull, bear, 출처).
|
||||
|
||||
[C1 2026-10-02] 플래그 `MEAN_REVERSION_CANONICAL_PARAMS_ENABLED`
|
||||
- False(기본, 현행 동작 불변): trading_rules conditions 의
|
||||
`rsi_bull_max`/`rsi_bear_max` — DB `trading_rules` → `trading_rules_config.json`
|
||||
→ `_load_active_rules_from_db` 하드코딩 폴백 순. 키가 없으면 40/30.
|
||||
⚠️ 즉 CLAUDE.md §6.2 의 46/35 와 다르다(§0 우선순위가 이 경로에는 적용되지 않음).
|
||||
- True: CLAUDE.md §0 우선순위 — adaptive(`rsi_entry`/`bear_rsi_entry`, fallback_used=False 일
|
||||
때) → strategy_cfg(`MEAN_REVERSION_RSI_THRESHOLD`/`_BEAR_RSI_THRESHOLD`) → settings.
|
||||
mean_reversion_strategy.check_buy_signal 과 같은 출처를 쓴다. 비교식(rsi > 임계 → 탈락,
|
||||
즉 `rsi <= 임계` 통과)·RSI 계산 방식·나머지 필터는 바꾸지 않는다.
|
||||
"""
|
||||
if not settings.MEAN_REVERSION_CANONICAL_PARAMS_ENABLED:
|
||||
return conditions.get("rsi_bull_max", 40), conditions.get("rsi_bear_max", 30), "trading_rules"
|
||||
|
||||
bull = float(settings.MEAN_REVERSION_RSI_THRESHOLD)
|
||||
bear = float(settings.MEAN_REVERSION_BEAR_RSI_THRESHOLD)
|
||||
source = "settings"
|
||||
try:
|
||||
from app.services.strategy_config_service import strategy_cfg
|
||||
_b = strategy_cfg.get("MEAN_REVERSION_RSI_THRESHOLD")
|
||||
_r = strategy_cfg.get("MEAN_REVERSION_BEAR_RSI_THRESHOLD")
|
||||
if _b is not None:
|
||||
bull, source = float(_b), "strategy_cfg"
|
||||
if _r is not None:
|
||||
bear, source = float(_r), "strategy_cfg"
|
||||
except Exception as e:
|
||||
logger.warning(f"[MR_SCAN] strategy_cfg RSI 임계 조회 실패 — settings 값 사용: {e}")
|
||||
try:
|
||||
from app.services.adaptive_strategy import get_current_adaptive_params
|
||||
_adp = get_current_adaptive_params()
|
||||
if not _adp.fallback_used:
|
||||
bull, bear, source = float(_adp.rsi_entry), float(_adp.bear_rsi_entry), "adaptive"
|
||||
except Exception as e:
|
||||
logger.warning(f"[MR_SCAN] 적응형 RSI 임계 조회 실패 — {source} 값 사용: {e}")
|
||||
return bull, bear, source
|
||||
|
||||
|
||||
def is_mean_reversion_candidate(stock, conditions: dict, market_regime_str: str) -> bool:
|
||||
"""추천 스캐너의 평균회귀 후보 판정 — **실제 스윙 평균회귀 진입의 1차 게이트**.
|
||||
|
||||
현재 실제 진입 기준 (2026-10-02 기준, 이 함수 기준):
|
||||
- 공통: RSI·종가·BB(상/중/하) 모두 존재, 종가 ≥ MIN_PRICE_KRW,
|
||||
own_trend 게이트(MA20 < MA60×0.97 이면 탈락, MA 없으면 미적용),
|
||||
ADX(14) > 30 이면 탈락(강추세 — 100 초과는 계산오류로 무시)
|
||||
- RSI 는 StockAnalysis.rsi(배치 산출값)를 그대로 쓴다. 거래량 하한·RSI 반등 조건은
|
||||
**이 함수에 없다**(후속 execute_auto_trade 경로의 필터에 의존).
|
||||
- BULL: RSI ≤ bull 임계, %B ≤ 0.35(RSI<25) / 0.30(RSI<30) / 0.20(그 외)
|
||||
- BEAR: RSI ≤ bear 임계, %B ≤ 0.30(RSI<20) / 0.25(RSI<25) / 0.20(그 외)
|
||||
- 임계 출처는 `_mean_reversion_rsi_thresholds_v2` 참고(기본: trading_rules 40/30).
|
||||
"""
|
||||
rsi_val = float(stock.rsi) if stock.rsi else None
|
||||
close = int(stock.current_price) if stock.current_price else 0
|
||||
bb_lower = float(stock.bollinger_lower) if stock.bollinger_lower else None
|
||||
@@ -274,8 +325,10 @@ def is_mean_reversion_candidate(stock, conditions: dict, market_regime_str: str)
|
||||
bb_width = bb_upper - bb_lower
|
||||
pct_b = (close - bb_lower) / bb_width if bb_width > 0 else 0.5
|
||||
|
||||
_bull_thr, _bear_thr, _thr_src = _mean_reversion_rsi_thresholds_v2(conditions)
|
||||
|
||||
if regime == MarketRegime.BULL:
|
||||
rsi_threshold = conditions.get("rsi_bull_max", 40)
|
||||
rsi_threshold = _bull_thr
|
||||
if adx_strong_trend:
|
||||
return False
|
||||
if rsi_val > rsi_threshold:
|
||||
@@ -289,7 +342,7 @@ def is_mean_reversion_candidate(stock, conditions: dict, market_regime_str: str)
|
||||
pct_b_max = 0.20 # RSI 30~40 → BB 하단 근처 요구
|
||||
return pct_b <= pct_b_max
|
||||
else:
|
||||
rsi_threshold = conditions.get("rsi_bear_max", 30)
|
||||
rsi_threshold = _bear_thr
|
||||
if adx_strong_trend:
|
||||
return False
|
||||
if rsi_val > rsi_threshold:
|
||||
@@ -634,57 +687,52 @@ async def get_rule_based_recommendations_realtime():
|
||||
|
||||
@router.post("/auto-trade")
|
||||
async def execute_auto_trading(db: AsyncSession = Depends(get_db)):
|
||||
try:
|
||||
recommendations_response = await get_rule_based_recommendations(realtime=True, auto_trade=False)
|
||||
if not recommendations_response.get("recommendations_by_rule"):
|
||||
return {"message": "추천 종목이 없습니다", "trades": []}
|
||||
|
||||
trade_results = []
|
||||
total_processed = 0
|
||||
max_trades = 5
|
||||
|
||||
# make sure we iterate a list of rule group dicts
|
||||
rec_groups = recommendations_response.get("recommendations_by_rule", [])
|
||||
if isinstance(rec_groups, dict):
|
||||
normalized = []
|
||||
for name, grp in rec_groups.items():
|
||||
if isinstance(grp, dict):
|
||||
normalized.append(grp)
|
||||
else:
|
||||
normalized.append({"rule_name": name, "stocks": grp})
|
||||
rec_groups = normalized
|
||||
"""추천 기반 자동매매 수동 트리거.
|
||||
|
||||
for rule_group in rec_groups:
|
||||
if total_processed >= max_trades:
|
||||
break
|
||||
|
||||
for stock in rule_group.get("stocks", [])[:settings.AUTO_TRADE_TOP_N_PER_RULE]:
|
||||
if total_processed >= max_trades:
|
||||
break
|
||||
|
||||
try:
|
||||
result = await execute_auto_trade(stock, db)
|
||||
total_processed += 1
|
||||
|
||||
if result["action"] in ["BUY", "SELL"]:
|
||||
trade_results.append(result)
|
||||
|
||||
await asyncio.sleep(0.5)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error executing auto-trade for {stock.get('stock_code')}: {e}")
|
||||
continue
|
||||
|
||||
return {
|
||||
"message": f"자동 매매 완료: {len(trade_results)}건 실행 / {total_processed}개 분석",
|
||||
"trades": trade_results,
|
||||
"total_analyzed": total_processed
|
||||
}
|
||||
|
||||
[H-3 2026-10-02] 기존에는 종목별 `execute_auto_trade()` 를 직접 호출해
|
||||
시장 국면(SIDEWAYS/CRASH → max_trades=0)·position_multiplier·일일 매수 한도·
|
||||
`_auto_trade_bg_lock`(스윙 스케줄러와의 동시 실행 방지)·`AUTO_TRADE_ENABLED` 를
|
||||
전부 우회했다. 이제 스케줄러와 **같은 진입점** `execute_auto_trade_background()` 로
|
||||
위임해 동일 락·게이트를 탄다. 게이트 판정은 try 밖(§4).
|
||||
"""
|
||||
if not settings.AUTO_TRADE_ENABLED:
|
||||
raise HTTPException(status_code=423, detail="AUTO_TRADE_ENABLED=False — 자동매매 비활성 상태")
|
||||
if settings.TRADING_HALTED:
|
||||
raise HTTPException(status_code=423, detail="TRADING_HALTED — 전체 매수 중단 상태")
|
||||
from app.services.trade_executor import _auto_trade_bg_lock
|
||||
if _auto_trade_bg_lock.locked():
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail="자동매매가 이미 실행 중입니다(스윙 스케줄러 등) — 중복 실행 방지",
|
||||
)
|
||||
|
||||
try:
|
||||
recommendations_response = await get_rule_based_recommendations(
|
||||
realtime=True, auto_trade=False, db=db
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"❌ 자동 매매 실행 오류: {e}")
|
||||
logger.error(f"❌ 자동 매매 추천 조회 오류: {e}", exc_info=True)
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
if not recommendations_response.get("recommendations_by_rule"):
|
||||
return {"message": "추천 종목이 없습니다", "trades": []}
|
||||
|
||||
try:
|
||||
results = await execute_auto_trade_background(recommendations_response, db)
|
||||
except Exception as e:
|
||||
logger.error(f"❌ 자동 매매 실행 오류: {e}", exc_info=True)
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
results = results if isinstance(results, list) else []
|
||||
# 반환 리스트에는 SELL·실패건도 섞인다 — 진입 건수는 BUY & success 로 센다(CLAUDE.md §6.4)
|
||||
trades = [r for r in results if isinstance(r, dict) and r.get("action") in ("BUY", "SELL")]
|
||||
buys = sum(1 for r in trades if r.get("action") == "BUY" and r.get("success"))
|
||||
return {
|
||||
"message": f"자동 매매 완료: 진입 {buys}건 / 처리 {len(results)}건",
|
||||
"trades": trades,
|
||||
"total_analyzed": len(results),
|
||||
}
|
||||
|
||||
@router.get("/ai/{stock_code}")
|
||||
async def get_ai_recommendation(stock_code: str):
|
||||
"""종목 기술적 분석 — AI 미사용, 전략 엔진(RSI/BB/MA/ATR/ADX) 직접 계산."""
|
||||
|
||||
@@ -12,7 +12,7 @@ import logging
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
@@ -21,6 +21,11 @@ from app.models.models import Portfolio, ActiveTrade
|
||||
from app.services.korea_investment_api import korea_investment_api
|
||||
from app.services.swing.position_manager import get_swing_holdings, count_swing_positions
|
||||
from app.core.json_helpers import _dumps
|
||||
from app.services.manual_sell_service import (
|
||||
allocate_sell_v2,
|
||||
dt_inmemory_qty_v2,
|
||||
sync_active_trade_after_manual_sell_v2,
|
||||
)
|
||||
|
||||
router = APIRouter()
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -28,7 +33,8 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
class SwingSellRequest(BaseModel):
|
||||
stock_code: str
|
||||
quantity: Optional[int] = None # None이면 전량
|
||||
# None이면 스윙 보유분 전량. 0/음수는 422 (M-5 2026-10-02)
|
||||
quantity: Optional[int] = Field(None, gt=0)
|
||||
|
||||
|
||||
@router.get("/portfolio")
|
||||
@@ -100,7 +106,19 @@ async def manual_swing_sell(
|
||||
req: SwingSellRequest,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""스윙 포지션 수동 청산."""
|
||||
"""스윙 포지션 수동 청산.
|
||||
|
||||
[M-5 2026-10-02] 기존 결함: `place_order` 직접 호출(종목 세마포어·Redis 락 없음 →
|
||||
자동 청산과 동시 매도), DT 보유분 미차감(합산 행 전량을 팔아 DT 분까지 매도, §3.5),
|
||||
ActiveTrade close·loss_tracker 누락, 음수 수량 미검증.
|
||||
수정: 매도 가능 = 행 수량 − DT 인메모리 보유분, 주문은 safe_place_order(락 경유),
|
||||
ActiveTrade 는 비-DT 필터로 갱신, 스윙분 전량 청산 시 loss_tracker record+save_to_db,
|
||||
DB 쓰기는 db_worker 큐 경유.
|
||||
"""
|
||||
if req.quantity is not None and req.quantity <= 0:
|
||||
raise HTTPException(status_code=400, detail="매도 수량은 1 이상이어야 합니다")
|
||||
|
||||
# DB-READ: 보유 행 SELECT 전용(commit 없음)
|
||||
result = await db.execute(
|
||||
select(Portfolio).where(
|
||||
Portfolio.stock_code == req.stock_code,
|
||||
@@ -112,8 +130,29 @@ async def manual_swing_sell(
|
||||
if not holding:
|
||||
raise HTTPException(status_code=404, detail="스윙 포지션을 찾을 수 없습니다")
|
||||
|
||||
quantity = req.quantity or holding.quantity
|
||||
quantity = min(quantity, holding.quantity)
|
||||
row_qty = int(holding.quantity or 0)
|
||||
dt_qty = dt_inmemory_qty_v2(req.stock_code)
|
||||
if dt_qty is None:
|
||||
# DT 보유분을 모르면 스윙 매도 가능 수량을 확정할 수 없다 → DT 분 오매도 방지
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail="데이트레이딩 보유분 확인 실패 — 스윙 매도 가능 수량을 확정할 수 없습니다",
|
||||
)
|
||||
swing_qty = max(0, row_qty - min(dt_qty, row_qty))
|
||||
if swing_qty <= 0:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"스윙 보유분이 없습니다 (행 {row_qty}주 전부 데이트레이딩 보유분 {dt_qty}주)",
|
||||
)
|
||||
quantity = req.quantity if req.quantity is not None else swing_qty
|
||||
if quantity > swing_qty:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=(
|
||||
f"매도 요청 {quantity}주 > 스윙 보유분 {swing_qty}주 "
|
||||
f"(행 {row_qty}주 − 데이트레이딩 {dt_qty}주)"
|
||||
),
|
||||
)
|
||||
|
||||
# 현재가 조회
|
||||
price_resp = await korea_investment_api.get_current_price(req.stock_code)
|
||||
@@ -124,32 +163,25 @@ async def manual_swing_sell(
|
||||
if price <= 0:
|
||||
raise HTTPException(status_code=503, detail="현재가 조회 실패")
|
||||
|
||||
order_resp = await korea_investment_api.place_order(
|
||||
from app.services.trade_executor import safe_place_order
|
||||
order_resp = await safe_place_order(
|
||||
stock_code=req.stock_code,
|
||||
order_type="SELL",
|
||||
quantity=quantity,
|
||||
price=None, # 시장가 (order_dvsn="01"은 price=None일 때 자동 적용)
|
||||
price=None, # 시장가
|
||||
is_domestic=True,
|
||||
)
|
||||
if not order_resp or order_resp.get("rt_cd") != "0":
|
||||
err = order_resp.get("msg1", "unknown") if order_resp else "no response"
|
||||
raise HTTPException(status_code=503, detail=f"매도 주문 실패: {err}")
|
||||
status = 409 if err == "order_lock_busy" else 503
|
||||
raise HTTPException(status_code=status, detail=f"매도 주문 실패: {err}")
|
||||
order_id = (order_resp.get("output") or {}).get("ODNO")
|
||||
|
||||
from app.services.db_worker import enqueue_trade_history, enqueue_portfolio_upsert
|
||||
from app.services.db_worker import enqueue_trade_history
|
||||
avg_price = float(holding.average_price or 0)
|
||||
pl = (price - avg_price) * quantity
|
||||
pl_rate = (price - avg_price) / avg_price * 100 if avg_price > 0 else 0.0
|
||||
|
||||
# 매도 후 Portfolio 수량 갱신 (db_worker 큐 경유)
|
||||
new_quantity = holding.quantity - quantity
|
||||
await enqueue_portfolio_upsert({
|
||||
"stock_code": req.stock_code,
|
||||
"stock_name": holding.stock_name,
|
||||
"quantity": new_quantity,
|
||||
"average_price": avg_price,
|
||||
"current_price": float(price),
|
||||
"strategy_type": "swing",
|
||||
})
|
||||
|
||||
await enqueue_trade_history({
|
||||
"stock_code": req.stock_code,
|
||||
"stock_name": holding.stock_name,
|
||||
@@ -160,17 +192,37 @@ async def manual_swing_sell(
|
||||
"executed_quantity": quantity,
|
||||
"total_amount": price * quantity,
|
||||
"status": "COMPLETED",
|
||||
"order_id": order_id,
|
||||
"exit_reason": "수동 청산",
|
||||
"net_profit_loss": pl,
|
||||
"profit_loss_rate": pl_rate,
|
||||
"strategy_type": "swing",
|
||||
})
|
||||
|
||||
logger.info(f"[SwingAPI] 수동 청산: {req.stock_code} 수량={quantity} 가격={price:,}")
|
||||
# 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:
|
||||
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.save_to_db(stock_code=req.stock_code)
|
||||
except Exception as e:
|
||||
logger.warning(f"[SwingAPI] LossTracker 기록 실패 {req.stock_code}: {e}")
|
||||
|
||||
logger.info(
|
||||
f"[SwingAPI] 수동 청산: {req.stock_code} 수량={quantity} 가격={price:,} "
|
||||
f"(스윙 잔량 {alloc.swing_left}주, DT {dt_qty}주 유지)"
|
||||
)
|
||||
return {
|
||||
"stock_code": req.stock_code,
|
||||
"quantity": quantity,
|
||||
"price": price,
|
||||
"order_id": order_id,
|
||||
"profit_loss": round(pl),
|
||||
"profit_loss_rate": round(pl_rate, 2),
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
|
||||
- GET /: 거래 이력 조회 (손익 포함)
|
||||
- GET /stats/summary: 거래 통계
|
||||
- DELETE /pending: PENDING/FAILED 거래 정리
|
||||
- DELETE /pending: PENDING/FAILED 거래 정리 (전일 이전 행만 — 당일 행은 한도·체결 근거)
|
||||
"""
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
@@ -192,10 +192,20 @@ async def get_trade_stats(
|
||||
|
||||
@router.delete("/pending")
|
||||
async def delete_pending_trades(db: AsyncSession = Depends(get_db)):
|
||||
"""장 마감 후 PENDING과 FAILED 상태의 매수 시도 내역 정리"""
|
||||
"""PENDING과 FAILED 상태의 매수 시도 내역 정리 — **전일 이전 행만**.
|
||||
|
||||
[L 2026-10-02] 기존에는 기간 무제한이라 장중 호출 시 당일 PENDING/FAILED 행까지
|
||||
지웠다. 당일 행은 `already_ordered_today`·일일 스윙 매수 한도(`_count_swing_buys_today_v2`
|
||||
가 PENDING 포함)·체결 확인(`_verify_and_sync_execution` 의 order_id 갱신)의 근거라,
|
||||
지우면 당일 한도가 풀리고 체결 기록이 유실된다. 기준은 trade_executor 와 같은
|
||||
`get_daily_cutoff_utc()`(KST 당일 00:00, created_at 과 같은 KST naive 규약).
|
||||
"""
|
||||
from app.services.trade_helpers import get_daily_cutoff_utc
|
||||
cutoff = get_daily_cutoff_utc()
|
||||
result = await db.execute(
|
||||
select(TradeHistory).where(
|
||||
TradeHistory.status.in_(['PENDING', 'FAILED'])
|
||||
TradeHistory.status.in_(['PENDING', 'FAILED']),
|
||||
TradeHistory.created_at < cutoff,
|
||||
)
|
||||
)
|
||||
pending_trades = result.scalars().all()
|
||||
@@ -205,6 +215,9 @@ async def delete_pending_trades(db: AsyncSession = Depends(get_db)):
|
||||
await db.delete(trade)
|
||||
await db.commit()
|
||||
|
||||
logger.info(f"✅ 정리 완료: {count}개의 PENDING/FAILED 매수 시도 삭제")
|
||||
|
||||
return {"deleted_count": count, "message": f"Cleaned up {count} pending/failed trades"}
|
||||
logger.info(f"✅ 정리 완료: {count}개의 PENDING/FAILED 매수 시도 삭제 (기준 {cutoff} 이전, 당일 행 보존)")
|
||||
|
||||
return {
|
||||
"deleted_count": count,
|
||||
"message": f"Cleaned up {count} pending/failed trades before {cutoff.isoformat()} (today's rows kept)",
|
||||
}
|
||||
|
||||
@@ -24,6 +24,8 @@ from app.api.endpoints.portfolio_v2 import _resolve_target_user_id, _user_scope_
|
||||
from app.api.endpoints.trades import TradeHistoryResponse
|
||||
from app.core.timeutil import now_kst
|
||||
|
||||
from app.core.log_throttle import warn_throttled # noqa: E402
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter()
|
||||
|
||||
@@ -78,7 +80,7 @@ async def get_trade_history_v2(
|
||||
try:
|
||||
response.profit_loss = Decimal(profit_match.group(1).replace(",", ""))
|
||||
except Exception as e:
|
||||
logger.debug(f"[trades_v2] 손익 금액 파싱 실패: {e}")
|
||||
warn_throttled(logger, "trades_v2:81", f"[trades_v2] 손익 금액 파싱 실패: {e}")
|
||||
if response.profit_loss_rate is None:
|
||||
message_rate_match = re.search(r"손익률\s*([+-]?[\d.]+)%", trade.message or "")
|
||||
rate_match = message_rate_match or re.search(r"([+-]?[\d.]+)%", text_to_parse)
|
||||
@@ -86,7 +88,7 @@ async def get_trade_history_v2(
|
||||
try:
|
||||
response.profit_loss_rate = float(rate_match.group(1))
|
||||
except Exception as e:
|
||||
logger.debug(f"[trades_v2] 손익률 파싱 실패: {e}")
|
||||
warn_throttled(logger, "trades_v2:89", f"[trades_v2] 손익률 파싱 실패: {e}")
|
||||
|
||||
trade_responses.append(response)
|
||||
|
||||
|
||||
@@ -3,13 +3,16 @@
|
||||
- GET /us-trading/watchlist: 미국 주식 관심 종목 목록
|
||||
- GET /us-trading/signals: 매매 신호 스캔 결과
|
||||
- GET /us-trading/status: 미국장 상태 (개장/폐장/프리마켓)
|
||||
- POST /us-trading/execute: 미국 주식 매수/매도 실행
|
||||
- POST /us-trading/execute: 매도(청산)만 허용 — 실보유 검증·주문락·부기. 매수는 410
|
||||
- POST /us-trading/cycle: 미국 매매 사이클 수동 실행 (US_TRADING_ENABLED=False 면 423)
|
||||
"""
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from pydantic import BaseModel, Field
|
||||
from typing import Literal, Optional, List
|
||||
import logging
|
||||
|
||||
from app.core.config import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(prefix="/us-trading", tags=["US Trading"])
|
||||
|
||||
@@ -51,65 +54,151 @@ async def scan_us_signals():
|
||||
try:
|
||||
results = await scan_us_stocks(korea_investment_api)
|
||||
return {"signals": results, "count": len(results)}
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"US signal scan failed: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
def _f(v) -> float:
|
||||
try:
|
||||
return float(str(v or 0).replace(",", ""))
|
||||
except (TypeError, ValueError):
|
||||
return 0.0
|
||||
|
||||
|
||||
@router.post("/execute")
|
||||
async def execute_us_trade(req: UsOrderRequest):
|
||||
"""미국 주식 매수/매도 실행"""
|
||||
"""미국 주식 수동 주문 — **매도(청산)만 허용**, 매수는 410.
|
||||
|
||||
[H-5 2026-10-02] 매수: `place_overseas_order` 직접 호출로 US_TRADING_ENABLED·MDD·
|
||||
종목당 상한·현금 유보·가격 필터·밴을 전부 우회했다 → 공통 미국 매수 가드가 생기기 전까지 410.
|
||||
[H-1 3회차 2026-10-02] 매도는 청산 경로이므로 막지 않는다(§8.1-11, 플래그 OFF 여도 허용).
|
||||
- 수량 ≤ KIS 해외 잔고 실보유(`ord_psbl_qty`) — 앱 장부가 아닌 계좌 기준
|
||||
- 주문은 `safe_place_order(is_domestic=False)`(종목 세마포어 + Redis 락)
|
||||
- TradeHistory·ActiveTrade(비-DT 필터)·Portfolio 는 db_worker 큐 경유
|
||||
- 전량 청산 시 loss_tracker record + save_to_db
|
||||
"""
|
||||
from app.services.exchange_detector import detect_exchange
|
||||
from app.services.korea_investment_api import korea_investment_api
|
||||
|
||||
exchange = detect_exchange(req.ticker)
|
||||
ticker = req.ticker.strip().upper()
|
||||
if req.order_type.lower() != "sell":
|
||||
logger.warning(
|
||||
f"[US] /us-trading/execute 매수 거부(410): {ticker} x{req.quantity} — 가드 없는 직접 매수 경로 폐지 "
|
||||
f"(US_TRADING_ENABLED={settings.US_TRADING_ENABLED})"
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=410,
|
||||
detail="미국 수동 매수는 폐지되었습니다(리스크 가드 우회). 자동매매는 /us-trading/cycle 을 사용하십시오.",
|
||||
)
|
||||
|
||||
# ── 실보유 확인 (KIS 해외 잔고) ─────────────────────────────────────────
|
||||
try:
|
||||
if req.order_type.lower() == "buy":
|
||||
if not req.price:
|
||||
# Get current price for limit order
|
||||
price_data = await korea_investment_api.get_overseas_price(req.ticker, exchange)
|
||||
req.price = float(price_data.get("output", {}).get("last", 0))
|
||||
if not req.price or req.price <= 0:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"유효하지 않은 주문 가격: {req.ticker} 현재가 조회 실패 또는 0달러"
|
||||
)
|
||||
|
||||
result = await korea_investment_api.place_overseas_order(
|
||||
req.ticker, req.order_type.upper(), req.quantity, req.price, exchange
|
||||
)
|
||||
elif req.order_type.lower() == "sell":
|
||||
if not req.price:
|
||||
price_data = await korea_investment_api.get_overseas_price(req.ticker, exchange)
|
||||
req.price = float(price_data.get("output", {}).get("last", 0))
|
||||
if not req.price or req.price <= 0:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"유효하지 않은 주문 가격: {req.ticker} 현재가 조회 실패 또는 0달러"
|
||||
)
|
||||
|
||||
result = await korea_investment_api.place_overseas_order(
|
||||
req.ticker, req.order_type.upper(), req.quantity, req.price, exchange
|
||||
)
|
||||
else:
|
||||
raise HTTPException(status_code=400, detail="order_type must be 'buy' or 'sell'")
|
||||
|
||||
return result
|
||||
bal = await korea_investment_api.get_overseas_balance()
|
||||
except Exception as e:
|
||||
logger.error(f"US trade execution failed: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
logger.warning(f"[US SELL] {ticker} 해외 잔고 조회 실패: {e}")
|
||||
raise HTTPException(status_code=502, detail=f"해외 잔고 조회 실패: {e}")
|
||||
if not isinstance(bal, dict) or bal.get("rt_cd") not in (None, "0") or not isinstance(bal.get("output1"), list):
|
||||
raise HTTPException(status_code=502, detail="해외 잔고 조회 실패(오류 응답) — 보유 수량을 확인할 수 없습니다")
|
||||
row = next((h for h in bal["output1"] if str(h.get("ovrs_pdno", "")).upper() == ticker), None)
|
||||
sellable = int(_f((row or {}).get("ord_psbl_qty")))
|
||||
held = max(sellable, int(_f((row or {}).get("ovrs_cblc_qty"))))
|
||||
if row is None or sellable <= 0:
|
||||
raise HTTPException(status_code=404, detail=f"매도 가능한 해외 보유가 없습니다: {ticker}")
|
||||
if req.quantity > sellable:
|
||||
raise HTTPException(
|
||||
status_code=400, detail=f"매도 요청 {req.quantity}주 > 매도가능 {sellable}주 ({ticker})",
|
||||
)
|
||||
|
||||
exchange = (row.get("ovrs_excg_cd") or "").strip() or detect_exchange(ticker)
|
||||
price = float(req.price or 0) or _f(row.get("now_pric2"))
|
||||
if price <= 0:
|
||||
try:
|
||||
price_data = await korea_investment_api.get_overseas_price(ticker, exchange)
|
||||
price = _f((price_data or {}).get("output", {}).get("last"))
|
||||
except Exception as e:
|
||||
logger.warning(f"[US SELL] {ticker} 현재가 조회 실패: {e}")
|
||||
if price <= 0:
|
||||
raise HTTPException(status_code=400, detail=f"유효하지 않은 주문 가격: {ticker} 현재가 조회 실패 또는 0달러")
|
||||
|
||||
from app.services.trade_executor import safe_place_order
|
||||
result = await safe_place_order(
|
||||
stock_code=ticker, order_type="SELL", quantity=req.quantity, price=price,
|
||||
is_domestic=False, exchange_code=exchange,
|
||||
)
|
||||
if not result or result.get("rt_cd") != "0":
|
||||
msg = (result or {}).get("msg1", "no_response")
|
||||
raise HTTPException(status_code=409 if msg == "order_lock_busy" else 502, detail=f"매도 주문 실패: {msg}")
|
||||
order_id = (result.get("output") or {}).get("ODNO")
|
||||
|
||||
avg = _f(row.get("pchs_avg_pric"))
|
||||
pnl_usd = (price - avg) * req.quantity if avg > 0 else 0.0
|
||||
pnl_rate = (price / avg - 1) * 100 if avg > 0 else 0.0
|
||||
|
||||
from app.services.db_worker import enqueue_trade_history
|
||||
from app.services.manual_sell_service import allocate_sell_v2, sync_active_trade_after_manual_sell_v2
|
||||
from app.services.portfolio_cache_bridge import reflect_sell
|
||||
|
||||
await enqueue_trade_history({
|
||||
"stock_code": ticker,
|
||||
"stock_name": row.get("ovrs_item_name") or ticker,
|
||||
"order_type": "SELL",
|
||||
"order_price": price,
|
||||
"order_quantity": req.quantity,
|
||||
"executed_price": price,
|
||||
"executed_quantity": req.quantity,
|
||||
"total_amount": price * req.quantity,
|
||||
"status": "COMPLETED",
|
||||
"order_id": order_id,
|
||||
"exit_reason": f"사용자 수동 청산 (손익 ${pnl_usd:+,.2f}, {pnl_rate:+.2f}%)",
|
||||
"net_profit_loss": pnl_usd,
|
||||
"profit_loss_rate": round(pnl_rate, 4),
|
||||
"strategy_type": "us_swing",
|
||||
"currency_code": "USD",
|
||||
"exchange_code": exchange,
|
||||
})
|
||||
alloc = allocate_sell_v2(req.quantity, held, 0, swing_only=True)
|
||||
await sync_active_trade_after_manual_sell_v2(ticker, alloc, price=price)
|
||||
await reflect_sell(ticker, req.quantity, price, remaining_qty=alloc.swing_left)
|
||||
|
||||
if alloc.swing_left <= 0:
|
||||
try:
|
||||
from app.services.loss_tracker import loss_tracker
|
||||
await loss_tracker.record_trade_result_safe(stock_code=ticker, is_loss=pnl_usd < 0)
|
||||
await loss_tracker.save_to_db(stock_code=ticker)
|
||||
except Exception as e:
|
||||
logger.warning(f"[US SELL] LossTracker 기록 실패 {ticker}: {e}")
|
||||
|
||||
logger.warning(f"[US SELL] 수동 청산 {ticker} {req.quantity}주 @ ${price:,.2f} (잔량 {alloc.swing_left}주)")
|
||||
return {
|
||||
"success": True, "ticker": ticker, "quantity": req.quantity, "price": price,
|
||||
"order_id": order_id, "remaining": alloc.swing_left,
|
||||
"profit_loss_usd": round(pnl_usd, 2), "profit_loss_rate": round(pnl_rate, 2),
|
||||
}
|
||||
|
||||
|
||||
@router.post("/cycle")
|
||||
async def trigger_us_trade_cycle():
|
||||
"""미국 매매 사이클 수동 즉시 실행 (테스트/긴급 용도)"""
|
||||
"""미국 매매 사이클 수동 즉시 실행 (테스트/긴급 용도)
|
||||
|
||||
[H-5 2026-10-02] `US_TRADING_ENABLED=False` 면 423 — 기존에는 플래그를 우회해
|
||||
정지된 미국 전략을 수동으로 돌릴 수 있었다. 판정은 try 밖(§4).
|
||||
"""
|
||||
if not settings.US_TRADING_ENABLED:
|
||||
raise HTTPException(
|
||||
status_code=423,
|
||||
detail="US_TRADING_ENABLED=False — 미국 자동매매 비활성 상태",
|
||||
)
|
||||
from app.services.us_market_scheduler import us_trade_cycle
|
||||
try:
|
||||
result = await us_trade_cycle()
|
||||
return result
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"US trade cycle failed: {e}")
|
||||
logger.error(f"US trade cycle failed: {e}", exc_info=True)
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user