fix: 리스크 결함 일괄 수정 — 누적 4% 상한·스윙 소유권·일일한도 분리·DT 순손익/체결가·시간손절 밴 제외·체결조회 경로
trade_executor - 종목당 4% 상한을 기존 보유분 포함 누적으로 (sizing_core.remaining_position_room) - 추천 경로는 스윙 소유 행만 매수·매도 (external/manual/daytrading/us_swing skip, 대칭), 고아 DT 행 warning - 일일 매수한도는 스윙 소유 매수만 집계, 집계 실패 시 fail-closed - 부분매도 반복 차단에서만 DT 매도 제외 (재매수 쿨다운은 유지) - 체결 확인: 부분체결 잔량 자동 재주문 제거(가드 전부 우회·취소/거부 시 발동), PARTIAL→COMPLETED+체결수량, 초과체결 캡 korea_investment_api - get_order_execution 이 존재하지 않는 /trading/inquire-ccnl 을 호출하던 결함 → inquire-daily-ccld (TTTC0081R 우선, 구 TR 폴백, 페이지네이션, ODNO 매칭, EXCG_ID_DVSN_CD=KRX), 실패 로그 warning daytrading - 시간손절은 loss_tracker 중립 (42일 밴이 스윙까지 막던 문제) - 실현손익을 수수료·거래세 차감 순손익으로 (trade_cost_core), 체결가 백그라운드 보정 (fill_reconciler) - legacy _record_trade strategy_type=daytrading 명시 db_worker - update_trade_history_by_order_id 를 당일(KST)+최신 1행으로 한정 (일별 재사용 ODNO 로 과거 행 덮어쓰기 방지) alembic 0013: trade_journals strategy_name='unknown' 을 trade_history.strategy_type 으로 보정 (백업 테이블 포함) CLAUDE.md: 변경 동작 반영, MT 예약 리비전 0013→0014 998 passed Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -32,6 +32,7 @@ from app.services.technical_indicators import (
|
||||
# §8.5-2 포지션 사이징 단일 출처 (순수 계산 모듈 — settings/DB/네트워크 없음)
|
||||
from app.services.sizing_core import (
|
||||
amount_to_quantity, clamp_by_ratios, effective_min_buy_amount, half_kelly_fraction,
|
||||
remaining_position_room,
|
||||
)
|
||||
from app.services.signal_types import BUY_KEYWORDS, SELL_KEYWORDS, STRONG_BUY_KEYWORDS, BLOCK_KEYWORDS
|
||||
from app.services.correlation_filter import validate_correlation_filter
|
||||
@@ -531,16 +532,33 @@ async def _verify_and_sync_execution(
|
||||
db_trade_id: int,
|
||||
delay: int = 3,
|
||||
) -> None:
|
||||
"""주문 후 KIS 체결 내역 조회 → DB 업데이트 + 슬리피지 기록 + 부분 체결 재주문.
|
||||
"""주문 후 KIS 체결 내역 조회 → DB 업데이트 + 슬리피지 기록 (부분 체결 자동 재주문 없음).
|
||||
|
||||
[동작 흐름]
|
||||
1. delay초 대기 (체결 확인 전 KIS 서버 처리 시간 확보)
|
||||
2. KIS 체결 조회 API 호출 → filled_qty, filled_price 추출
|
||||
3. 슬리피지 계산: (체결가 - 주문가) / 주문가 × 100
|
||||
4. db_worker 큐를 통해 TradeHistory UPDATE (CLAUDE.md 3.2 준수)
|
||||
- 완전 체결(filled_qty == ordered_quantity) → status = "COMPLETED"
|
||||
- 부분 체결(filled_qty < ordered_quantity) → status = "PARTIAL" + 잔여 재주문
|
||||
- 완전 체결(filled_qty >= ordered_quantity) → status = "COMPLETED"
|
||||
- 부분 체결 + KIS 잔량 소멸(rmn_qty == 0: 취소·거부·만료) → 체결분만 기록(COMPLETED)
|
||||
+ warning("수동 확인"). **자동 재주문하지 않는다** (아래 ③)
|
||||
- 부분 체결 + 잔량이 KIS 에 살아 있음/모름 → DB 미갱신 + warning (재주문 금지)
|
||||
5. 포트폴리오 동기화 트리거
|
||||
|
||||
[2026-10-01] `get_order_execution` 이 존재하지 않는 경로를 호출해 이 함수의 체결 반영
|
||||
분기는 **한 번도 실행된 적이 없다**. 경로를 고치면서 처음 살아나는 분기의 결함을 함께 고쳤다:
|
||||
① 첫 부분체결 관측에서 곧바로 break → 잔량이 아직 KIS 에 지정가 주문으로 살아 있는데
|
||||
같은 수량을 재주문 = **중복 주문(과다 매수 / 과다 매도 시도)**. 이제 전량 체결 또는
|
||||
잔량 소멸까지 폴링을 계속한다.
|
||||
② status "PARTIAL" 은 매수 재진입 가드(당일 중복매수·일일횟수·쿨다운: EXECUTED/COMPLETED
|
||||
(/PENDING) 만 셈)와 손익 집계(COMPLETED/FILLED 만 셈)에서 빠진다 → 부분체결 BUY 가
|
||||
당일 재매수를 허용하고 부분체결 SELL 이 성과에서 사라진다. 체결분만 수량에 반영하고
|
||||
status 는 COMPLETED 를 유지한다.
|
||||
③ 잔량 소멸 시 재주문도 제거했다. 잔량이 사라지는 경우는 사실상 사용자 HTS 취소·KIS 거부·
|
||||
만료뿐이고(취소는 별도 행(orgn_odno)으로 기록돼 원주문 cncl_yn 이 N 일 수 있다),
|
||||
`_reorder_unfilled` 는 MDD fail-safe·밴·종목당 4%·현금·주문 세마포어/Redis 락·
|
||||
TradeHistory·체결 검증을 전부 우회한다. 취소 의도를 덮어쓰거나 가드 없는 주문을 내느니
|
||||
warning 으로 수동 확인을 요청한다. `_reorder_unfilled` 는 시그니처 보존용으로만 남긴다.
|
||||
"""
|
||||
await asyncio.sleep(delay) # KIS 서버 체결 처리 대기
|
||||
|
||||
@@ -550,25 +568,29 @@ async def _verify_and_sync_execution(
|
||||
return
|
||||
|
||||
try:
|
||||
# KIS 체결 내역 조회 (TR: TTTC8001R / VTTC8001R)
|
||||
# KIS 체결 내역 조회 (inquire-daily-ccld, TR: TTTC0081R→폴백 TTTC8001R / 모의 V*)
|
||||
# [실전 안정화] 최대 5회 폴링 (3초 간격, 최대 15초) — 체결 지연 대응
|
||||
filled_qty = 0
|
||||
filled_price = 0.0
|
||||
remaining_qty = None
|
||||
exec_result = {}
|
||||
max_polls = 5
|
||||
poll_interval = 3
|
||||
|
||||
for poll in range(max_polls):
|
||||
exec_result = await korea_investment_api.get_order_execution(order_id, is_domestic)
|
||||
filled_qty = int(exec_result.get("filled_qty", 0))
|
||||
filled_qty = int(exec_result.get("filled_qty", 0) or 0)
|
||||
filled_price = float(exec_result.get("filled_price", order_price) or order_price)
|
||||
remaining_qty = exec_result.get("remaining_qty")
|
||||
|
||||
if filled_qty > 0:
|
||||
break # 체결 확인됨
|
||||
if filled_qty >= ordered_quantity:
|
||||
break # 전량 체결
|
||||
if filled_qty > 0 and remaining_qty == 0:
|
||||
break # 부분 체결 후 잔량 소멸(취소·거부) — 더 기다려도 변하지 않음
|
||||
|
||||
if poll < max_polls - 1:
|
||||
logger.debug(
|
||||
f"[VERIFY_EXEC] {stock_code} {order_type} 체결 미확인 "
|
||||
f"[VERIFY_EXEC] {stock_code} {order_type} 체결 {filled_qty}/{ordered_quantity} "
|
||||
f"({poll + 1}/{max_polls}), {poll_interval}초 후 재시도"
|
||||
)
|
||||
await asyncio.sleep(poll_interval)
|
||||
@@ -576,12 +598,21 @@ async def _verify_and_sync_execution(
|
||||
if filled_qty == 0:
|
||||
logger.warning(
|
||||
f"[VERIFY_EXEC] {stock_code} {order_type} "
|
||||
f"{max_polls}회 폴링 후에도 체결 미확인 — order_id={order_id}"
|
||||
f"{max_polls}회 폴링 후에도 체결 미확인 — order_id={order_id} "
|
||||
f"(조회성공={exec_result.get('ok')}, 주문행발견={exec_result.get('found')})"
|
||||
)
|
||||
|
||||
if filled_qty > ordered_quantity:
|
||||
# 다른 주문 행과 혼동된 경우 등 — 주문 수량을 넘겨 기록하지 않는다
|
||||
logger.warning(
|
||||
f"[VERIFY_EXEC] {stock_code} {order_type} 체결수량 {filled_qty} > 주문수량 "
|
||||
f"{ordered_quantity} — 주문수량으로 클램프 (order_id={order_id})"
|
||||
)
|
||||
filled_qty = ordered_quantity
|
||||
|
||||
# ── 슬리피지 계산: (체결가 - 주문가) / 주문가 × 100 ─────────────────
|
||||
slippage_pct = 0.0
|
||||
if order_price > 0 and filled_price > 0:
|
||||
if filled_qty > 0 and order_price > 0 and filled_price > 0:
|
||||
slippage_pct = ((filled_price - order_price) / order_price) * 100
|
||||
logger.info(
|
||||
f"[SLIPPAGE] {stock_code} {order_type} "
|
||||
@@ -589,13 +620,24 @@ async def _verify_and_sync_execution(
|
||||
f"슬리피지={slippage_pct:+.3f}%"
|
||||
)
|
||||
|
||||
partial = 0 < filled_qty < ordered_quantity
|
||||
remainder_dead = partial and remaining_qty == 0
|
||||
if partial and not remainder_dead:
|
||||
# 잔량이 KIS 에 미체결 지정가로 살아 있다(또는 모른다) → 재주문하면 중복 주문.
|
||||
# 최종 체결 수량이 아직 정해지지 않았으므로 원장도 건드리지 않는다(포트폴리오 sync 가 진실).
|
||||
logger.warning(
|
||||
f"[PARTIAL_FILL] {stock_code} {order_type} {filled_qty}/{ordered_quantity}주 체결, "
|
||||
f"잔량 {remaining_qty if remaining_qty is not None else '미상'}주 KIS 미체결 주문 유지 "
|
||||
f"— 재주문·원장 갱신 안 함 (order_id={order_id})"
|
||||
)
|
||||
|
||||
# ── DB 업데이트 (CLAUDE.md 3.2: db_worker 큐 경유, 직접 세션 금지) ───
|
||||
if filled_qty > 0:
|
||||
partial = filled_qty < ordered_quantity # 부분 체결 여부
|
||||
if filled_qty > 0 and (not partial or remainder_dead):
|
||||
update_fields = {
|
||||
"executed_quantity": filled_qty,
|
||||
"executed_price": Decimal(str(filled_price)),
|
||||
"status": "PARTIAL" if partial else "COMPLETED",
|
||||
# PARTIAL 금지 — 위 docstring ② (가드·집계 누락)
|
||||
"status": "COMPLETED",
|
||||
"executed_at": _get_kst_now(),
|
||||
"slippage": Decimal(str(round(slippage_pct, 4))),
|
||||
}
|
||||
@@ -608,24 +650,14 @@ async def _verify_and_sync_execution(
|
||||
from app.services.db_worker import enqueue_update_trade_history_by_order_id
|
||||
await enqueue_update_trade_history_by_order_id(order_id, update_fields)
|
||||
|
||||
# ── 부분 체결 → 미체결 수량 재주문 ──────────────────────
|
||||
if partial:
|
||||
# ── 부분 체결 + 잔량 소멸 → 자동 재주문 금지, 수동 확인 요청 (docstring ③) ──
|
||||
if remainder_dead:
|
||||
remain = ordered_quantity - filled_qty
|
||||
logger.warning(
|
||||
f"[PARTIAL_FILL] {stock_code} {order_type} "
|
||||
f"{filled_qty}/{ordered_quantity}주 체결 → 잔여 {remain}주 재주문"
|
||||
)
|
||||
# 재주문은 백그라운드 태스크로 분리 (현재 흐름 블록 방지)
|
||||
asyncio.create_task(
|
||||
_reorder_unfilled(
|
||||
stock_code=stock_code,
|
||||
stock_name=stock_name,
|
||||
order_type=order_type,
|
||||
quantity=remain,
|
||||
price=order_price,
|
||||
is_domestic=is_domestic,
|
||||
exchange_code=exchange_code,
|
||||
)
|
||||
f"[PARTIAL_FILL] {stock_code}/{stock_name} {order_type} order_id={order_id} "
|
||||
f"체결 {filled_qty}/{ordered_quantity}주, 잔량 {remaining_qty}주(미체결 {remain}주 소멸) "
|
||||
f"취소확인={exec_result.get('cancel_confirmed_qty')} 거부={exec_result.get('rejected_qty')} "
|
||||
f"cncl_yn={'Y' if exec_result.get('cancelled') else 'N'} — 자동 재주문 안 함, 수동 확인 필요"
|
||||
)
|
||||
|
||||
await _delayed_sync_portfolio(0) # KIS 잔고 기준으로 포트폴리오 재동기화
|
||||
@@ -667,6 +699,83 @@ async def _reorder_unfilled(
|
||||
logger.error(f"[REORDER_ERROR] {stock_code} {order_type}: {type(e).__name__}: {e}")
|
||||
|
||||
|
||||
def _existing_position_qty_v2(holding, stock_code: str) -> int:
|
||||
"""해당 종목의 기존 보유 수량 (종목당 누적 상한 산정용).
|
||||
|
||||
Portfolio 행은 stock_code UNIQUE 라 스윙+DT 수량이 합산돼 있다(CLAUDE.md §3.5).
|
||||
§5.1 의 종목당 상한은 '계좌 내 그 종목 비중'이므로 **전략 구분 없이 전체**를 센다
|
||||
(보수적). 단 DT 가 sync 이전에 산 수량은 행에 아직 없을 수 있으므로
|
||||
`max(행 수량, DT 인메모리 수량)` 으로 잡는다 — 더하면 sync 이후 이중 계상된다.
|
||||
"""
|
||||
_row_qty = int(getattr(holding, "quantity", 0) or 0) if holding is not None else 0
|
||||
try:
|
||||
from app.services.daytrading.position_manager import day_trading_state as _dts
|
||||
_dt_qty = int((_dts.positions.get(stock_code) or {}).get("quantity", 0) or 0)
|
||||
except Exception as _e:
|
||||
logger.warning(
|
||||
f"[POSITION_CAP] {stock_code}: DT 인메모리 수량 조회 실패 — Portfolio 행 수량만 반영: {_e}"
|
||||
)
|
||||
_dt_qty = 0
|
||||
return max(_row_qty, _dt_qty)
|
||||
|
||||
|
||||
def _apply_position_cap_room_v2(
|
||||
invest_amount: float,
|
||||
holding,
|
||||
stock_code: str,
|
||||
stock_name: str,
|
||||
current_price: float,
|
||||
total_assets: float,
|
||||
position_cap_ratio: float,
|
||||
) -> tuple[float, str | None]:
|
||||
"""종목당 누적 상한 적용. 반환 = (클램프된 금액, SKIP 사유 또는 None).
|
||||
|
||||
매수 예산 = min(기존 예산, 총자산 × position_cap_ratio − 기존 보유 평가액).
|
||||
잔여 한도가 실효 최소주문금액(effective_min_buy_amount) 미만이면
|
||||
'position_cap_reached' 로 건너뛴다. 보유가 있는데 총자산을 모르면(잔고 조회 실패)
|
||||
한도를 검증할 수 없으므로 'position_cap_unverifiable' 로 추가매수를 막는다.
|
||||
|
||||
리스크 가드이므로 이 함수 자체에 try 로 감싼 판정이 없다(§4, §8.1-10).
|
||||
신규 진입(보유 0)은 잔여 한도 = 총자산 × cap 이라 기존 1건 클램프와 동일하게 동작한다.
|
||||
"""
|
||||
_existing_qty = _existing_position_qty_v2(holding, stock_code)
|
||||
if _existing_qty <= 0:
|
||||
return invest_amount, None
|
||||
|
||||
_existing_value = _existing_qty * float(current_price)
|
||||
if total_assets <= 0:
|
||||
logger.warning(
|
||||
f"[POSITION_CAP] {stock_code}/{stock_name}: 기존 보유 {_existing_qty}주 "
|
||||
f"(평가 {_existing_value:,.0f}원)인데 총자산 미확인 — 종목당 상한 "
|
||||
f"{position_cap_ratio:.1%} 검증 불가로 추가매수 차단"
|
||||
)
|
||||
return 0.0, "position_cap_unverifiable"
|
||||
|
||||
_room = remaining_position_room(total_assets, position_cap_ratio, _existing_value)
|
||||
_min_buy = effective_min_buy_amount(
|
||||
config_min_amount=settings.MIN_BUY_AMOUNT_KRW,
|
||||
total_assets=total_assets,
|
||||
position_cap_ratio=position_cap_ratio,
|
||||
factor=0.9,
|
||||
)
|
||||
if _room < _min_buy:
|
||||
logger.info(
|
||||
f"[POSITION_CAP] {stock_code}/{stock_name}: 종목당 상한 도달 — 기존 "
|
||||
f"{_existing_qty}주 평가 {_existing_value:,.0f}원 = 총자산의 "
|
||||
f"{_existing_value / total_assets:.2%} (상한 {position_cap_ratio:.1%}), "
|
||||
f"잔여 {_room:,.0f}원 < 최소주문 {_min_buy:,.0f}원 — 추가매수 건너뜀"
|
||||
)
|
||||
return 0.0, "position_cap_reached"
|
||||
|
||||
if invest_amount > _room:
|
||||
logger.info(
|
||||
f"[POSITION_CAP] {stock_code}/{stock_name}: 기존 보유 {_existing_value:,.0f}원 반영 — "
|
||||
f"매수 예산 {invest_amount:,.0f} → {_room:,.0f}원 (종목당 상한 {position_cap_ratio:.1%})"
|
||||
)
|
||||
return _room, None
|
||||
return invest_amount, None
|
||||
|
||||
|
||||
async def _execute_buy(
|
||||
stock: dict,
|
||||
db: AsyncSession,
|
||||
@@ -985,6 +1094,23 @@ async def _execute_buy(
|
||||
else:
|
||||
invest_amount = min(invest_amount, available_cash)
|
||||
|
||||
# ── 7.6. 종목당 **누적** 상한 (CLAUDE.md §5.1) ─────────────────────────
|
||||
# [2026-10-01] 위 클램프는 '주문 1건' 기준이라, 같은 종목이 매일 추천되면
|
||||
# 추가매수가 쌓여 종목 비중이 MAX_POSITION_SIZE 를 넘었다. 기존 보유 평가액을
|
||||
# 차감한 잔여 한도로 다시 묶는다. 리스크 가드이므로 try 밖에 둔다(§4, §8.1-10).
|
||||
_pos_cap_result = _apply_position_cap_room_v2(
|
||||
invest_amount=invest_amount,
|
||||
holding=holding,
|
||||
stock_code=stock_code,
|
||||
stock_name=stock_name,
|
||||
current_price=current_price,
|
||||
total_assets=total_assets,
|
||||
position_cap_ratio=_adaptive_position_cap,
|
||||
)
|
||||
if _pos_cap_result[1] is not None:
|
||||
return {"action": "SKIP", "reason": _pos_cap_result[1], "stock": stock_name}
|
||||
invest_amount = _pos_cap_result[0]
|
||||
|
||||
# [BUG FIX] 국면별 배율(_position_multiplier)과 뉴스 배율(_news_position_scale)을
|
||||
# invest_amount에 반영 — 기존에는 stock dict에 주입만 되고 실제로 사용되지 않았음.
|
||||
try:
|
||||
@@ -1241,6 +1367,64 @@ async def _execute_buy(
|
||||
await _release_order_lock(stock_code, "BUY")
|
||||
|
||||
|
||||
def _dt_inmemory_qty_v2(stock_code: str) -> int | None:
|
||||
"""DT 인메모리 포지션 수량. 조회 실패 시 None(호출부가 '확인 불가'로 취급)."""
|
||||
try:
|
||||
from app.services.daytrading.position_manager import day_trading_state as _dts
|
||||
return int((_dts.positions.get(stock_code) or {}).get("quantity", 0) or 0)
|
||||
except Exception as e:
|
||||
logger.warning(f"[SELL] {stock_code}: DT 인메모리 수량 조회 실패: {type(e).__name__}: {e}")
|
||||
return None
|
||||
|
||||
|
||||
# 자동 매도 대상이 아닌 Portfolio.strategy_type — 사용자 수동 판단 원칙.
|
||||
# external : sync 가 시스템 매수 근거 없이 발견한 유입 잔고(portfolio.EXTERNAL_STRATEGY_TYPE)
|
||||
# manual : manual_buy / portfolio_v2 수동 매수
|
||||
_MANUAL_JUDGMENT_STRATEGY_TYPES = ("external", "manual")
|
||||
|
||||
# 스윙 엔진이 '자기 것'으로 간주하는 strategy_type (NULL 레거시 행은 별도로 포함).
|
||||
# 매도 소유권(_sell_ownership_block_reason_v2)과 일일 매수 한도 집계
|
||||
# (_count_swing_buys_today_v2)가 같은 기준을 쓴다 — 한쪽만 바꾸지 말 것.
|
||||
# swing/position_manager.STRATEGY_TYPE("swing") 과 동일.
|
||||
_SWING_OWNER_STRATEGY_TYPES = ("swing",)
|
||||
|
||||
|
||||
def _swing_owned_clause_v2(column):
|
||||
"""스윙 소유 행 SQL 필터 = `column IS NULL OR column IN (_SWING_OWNER_STRATEGY_TYPES)`.
|
||||
|
||||
NULL 을 명시 포함한다(SQL 3값 논리 — `IN` 만 쓰면 legacy NULL 행이 빠진다).
|
||||
"""
|
||||
return or_(column.is_(None), column.in_(_SWING_OWNER_STRATEGY_TYPES))
|
||||
|
||||
|
||||
def _sell_ownership_block_reason_v2(holding, stock: dict) -> str | None:
|
||||
"""추천 경로 매도(_execute_sell)가 이 보유 행을 팔아도 되는지 판정.
|
||||
|
||||
swing/exit_service 가 `get_swing_holdings()`(strategy_type=='swing') 만 청산하는 것과
|
||||
같은 기준이다. 반환이 None 이면 허용, 문자열이면 SKIP 사유.
|
||||
|
||||
- external / manual → 'manual_judgment_holding' (warning)
|
||||
- DT 계열(DT_STRATEGY_NAMES) → 'owned_by_daytrading' — DT 엔진 소유
|
||||
(단 호출자 태그도 DT 계열이면 자기 보유분이므로 허용)
|
||||
- 그 외 비-swing(us_swing 등) → 'owned_by_other_strategy' — 호출자 태그와 같을 때만 허용
|
||||
|
||||
strategy_type 이 비어 있으면 모델 기본값과 같이 'swing' 으로 본다.
|
||||
MDD 강제축소(mdd_manager)·DT 청산·swing/exit_service 는 이 함수를 거치지 않는다
|
||||
(각자 주문 경로를 가진다) — 리스크 청산은 영향받지 않는다(§8.1-11).
|
||||
"""
|
||||
from app.services.db_worker import DT_STRATEGY_NAMES
|
||||
|
||||
_holding_type = (getattr(holding, "strategy_type", None) or "swing")
|
||||
_caller_tag = stock.get("strategy_type") or "swing"
|
||||
if _holding_type in _SWING_OWNER_STRATEGY_TYPES:
|
||||
return None
|
||||
if _holding_type in _MANUAL_JUDGMENT_STRATEGY_TYPES:
|
||||
return "manual_judgment_holding"
|
||||
if _holding_type in DT_STRATEGY_NAMES:
|
||||
return None if _caller_tag in DT_STRATEGY_NAMES else "owned_by_daytrading"
|
||||
return None if _caller_tag == _holding_type else "owned_by_other_strategy"
|
||||
|
||||
|
||||
async def _execute_sell(
|
||||
stock: dict,
|
||||
db: AsyncSession,
|
||||
@@ -1269,6 +1453,39 @@ async def _execute_sell(
|
||||
- 기본 손절 -6% (CLAUDE.md 5.2), 익절 2R (ATR 기반)
|
||||
"""
|
||||
|
||||
# ── 보유 행 소유권 가드 (2026-10-01) ─────────────────────────────────────
|
||||
# 추천 경로는 strategy_type 검사 없이 보유 행을 스윙 이름으로 팔았다
|
||||
# (external·manual 유입 잔고, strategy_type='daytrading' 행 포함).
|
||||
# 이 함수의 호출자는 execute_auto_trade 하나뿐이며 MDD 강제축소·DT 청산·
|
||||
# swing/exit_service 는 별도 주문 경로라 영향이 없다. try 밖 가드(§4).
|
||||
_ownership_block = _sell_ownership_block_reason_v2(holding, stock)
|
||||
if _ownership_block is not None:
|
||||
_h_type = getattr(holding, "strategy_type", None)
|
||||
_msg = (
|
||||
f"[SELL] {stock_code}/{stock_name}: 보유 {getattr(holding, 'quantity', '?')}주 "
|
||||
f"strategy_type='{_h_type}' — 추천 경로 자동매도 스킵({_ownership_block}, "
|
||||
f"요청 sell_pct={sell_pct*100:.0f}%, 호출 태그='{stock.get('strategy_type', 'swing')}')"
|
||||
)
|
||||
if _ownership_block == "owned_by_daytrading":
|
||||
# [2026-10-01] DT 인메모리에 포지션이 없는데 daytrading 행이 남아 있으면
|
||||
# 고아 행일 수 있다(재시작·stale 청산 실패 등) — 아무 엔진도 팔지 않으므로
|
||||
# info 에 묻히지 않게 warning 으로 올린다.
|
||||
_dt_mem_qty = _dt_inmemory_qty_v2(stock_code)
|
||||
if _dt_mem_qty is None or _dt_mem_qty <= 0:
|
||||
logger.warning(
|
||||
_msg + f" — DT 인메모리 포지션 "
|
||||
f"{'조회 실패' if _dt_mem_qty is None else '0주'}인데 daytrading 행 "
|
||||
f"{getattr(holding, 'quantity', '?')}주가 남아 있음: 고아 행 가능성, "
|
||||
f"수동 확인 필요 ({stock_name}/{stock_code})"
|
||||
)
|
||||
else:
|
||||
logger.info(_msg + f" — DT 엔진 소유(인메모리 {_dt_mem_qty}주)")
|
||||
elif _ownership_block == "manual_judgment_holding":
|
||||
logger.warning(_msg + " — 유입/수동 보유분은 수동 판단 대상")
|
||||
else:
|
||||
logger.warning(_msg + " — 다른 전략 엔진 소유")
|
||||
return {"action": "SKIP", "reason": _ownership_block, "stock": stock_name}
|
||||
|
||||
# [CLAUDE.md 5.2] 분할매도: sell_pct에 따라 매도 수량 결정
|
||||
# [2026-09-23] Portfolio 행은 stock_code UNIQUE 라 같은 종목의 스윙+데이트레이딩
|
||||
# 수량이 합산돼 있다. 행 전체를 대상으로 잡으면 다른 전략 보유분까지 매도된다
|
||||
@@ -1632,6 +1849,29 @@ async def _execute_sell(
|
||||
_auto_trade_bg_lock = asyncio.Lock()
|
||||
|
||||
|
||||
async def _count_swing_buys_today_v2(db: AsyncSession) -> int | None:
|
||||
"""당일 스윙 소유 BUY 종목 수 (distinct stock_code). 조회 실패 시 None.
|
||||
|
||||
None 은 '모름'이다 — 호출부가 fail-closed(신규 매수 차단)로 처리한다.
|
||||
여기서 0 으로 폴백하면 한도가 조용히 풀린다(이전 동작).
|
||||
"""
|
||||
try:
|
||||
_res = await db.execute(
|
||||
select(TradeHistory.stock_code)
|
||||
.where(
|
||||
TradeHistory.order_type == "BUY",
|
||||
TradeHistory.status.in_(["COMPLETED", "PENDING"]),
|
||||
TradeHistory.created_at >= get_daily_cutoff_utc(),
|
||||
_swing_owned_clause_v2(TradeHistory.strategy_type),
|
||||
)
|
||||
.distinct()
|
||||
)
|
||||
return len(_res.scalars().all())
|
||||
except Exception as e:
|
||||
logger.error(f"[AUTO_TRADE_BG] 당일 스윙 매수 건수 조회 예외: {type(e).__name__}: {e}")
|
||||
return None
|
||||
|
||||
|
||||
async def execute_auto_trade_background(recommendations_response: dict, db: AsyncSession):
|
||||
"""recommendations_response dict 에서 추천 종목을 꺼내 자동매매 실행.
|
||||
|
||||
@@ -1670,24 +1910,23 @@ async def _execute_auto_trade_background_locked(recommendations_response: dict,
|
||||
|
||||
# 일일 한도는 "오늘 이미 나간 BUY 종목 수"를 차감한 전역 기준으로 적용
|
||||
# (호출 단위 슬라이스만으로는 여러 사이클에 걸쳐 한도 초과됨)
|
||||
try:
|
||||
_today_buys = await db.execute(
|
||||
select(TradeHistory.stock_code)
|
||||
.where(
|
||||
TradeHistory.order_type == "BUY",
|
||||
TradeHistory.status.in_(["COMPLETED", "PENDING"]),
|
||||
TradeHistory.created_at >= get_daily_cutoff_utc(),
|
||||
)
|
||||
.distinct()
|
||||
# [2026-10-01] MAX_DAILY_TRADES 는 스윙 한도다 → 스윙 소유 매수(swing + NULL
|
||||
# 레거시)만 센다. DT·manual·external·us_swing 매수는 제외(매도 소유권과 같은 기준).
|
||||
# [2026-10-01] 조회 실패 시 fail-closed — 한도를 검증할 수 없으면 신규 매수를
|
||||
# 막는다. 판정은 try 밖(§4). 스윙 청산은 swing/scheduler step 2
|
||||
# (check_and_execute_exit_v2)가 이 함수와 독립적으로 수행하므로 영향이 없다.
|
||||
_bought_today = await _count_swing_buys_today_v2(db)
|
||||
if _bought_today is None:
|
||||
logger.error(
|
||||
f"[AUTO_TRADE_BG] 🚨 당일 스윙 매수 건수 조회 실패 — 일일 한도 "
|
||||
f"{min(settings.MAX_DAILY_TRADES, regime_max_trades)}종목 검증 불가로 "
|
||||
f"이번 사이클 신규 진입 차단(fail-closed). 스윙 청산 경로는 영향 없음"
|
||||
)
|
||||
_bought_today = len(_today_buys.scalars().all())
|
||||
except Exception as _e:
|
||||
logger.warning(f"[AUTO_TRADE_BG] 당일 매수 건수 조회 실패 (한도 미차감): {_e}")
|
||||
_bought_today = 0
|
||||
return []
|
||||
max_trades = max(0, max_trades - _bought_today)
|
||||
if max_trades == 0:
|
||||
logger.info(
|
||||
f"[AUTO_TRADE_BG] 일일 매수 한도 도달 (오늘 {_bought_today}종목 / 한도 "
|
||||
f"[AUTO_TRADE_BG] 일일 매수 한도 도달 (오늘 스윙 {_bought_today}종목 / 한도 "
|
||||
f"{min(settings.MAX_DAILY_TRADES, regime_max_trades)}) — 신규 진입 스킵"
|
||||
)
|
||||
return []
|
||||
@@ -1831,6 +2070,47 @@ def _ml_gate_score_v2(rule_score: float, ml_score: float) -> float:
|
||||
return (rule_score / rule_max) * 100.0
|
||||
|
||||
|
||||
def _today_sell_conditions_v2(stock_code: str, cutoff_time) -> list:
|
||||
"""당일 SELL 판정 조건 (COMPLETED/PARTIAL 은 executed_at, PENDING 은 created_at 기준)."""
|
||||
return [
|
||||
TradeHistory.stock_code == stock_code,
|
||||
TradeHistory.order_type == "SELL",
|
||||
or_(
|
||||
and_(
|
||||
TradeHistory.status.in_(["COMPLETED", "PARTIAL"]),
|
||||
TradeHistory.executed_at >= cutoff_time,
|
||||
),
|
||||
and_(
|
||||
TradeHistory.status == "PENDING",
|
||||
TradeHistory.created_at >= cutoff_time,
|
||||
),
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
async def _non_dt_sold_today_v2(db: AsyncSession, stock_code: str, cutoff_time) -> bool:
|
||||
"""당일 비-DT SELL 존재 여부 — 스윙 보유분 '부분매도 반복 차단' 판정 전용.
|
||||
|
||||
DT 판정은 db_worker.not_dt_strategy_clause 정본 표현(CLAUDE.md §3.3).
|
||||
조회 실패 시 True(보수적: 부분매도만 막고 전량 청산은 허용 — 기존 동작과 동일).
|
||||
"""
|
||||
from app.services.db_worker import not_dt_strategy_clause
|
||||
try:
|
||||
_res = await db.execute(
|
||||
select(TradeHistory.id).where(
|
||||
*_today_sell_conditions_v2(stock_code, cutoff_time),
|
||||
not_dt_strategy_clause(TradeHistory.strategy_type),
|
||||
).limit(1)
|
||||
)
|
||||
return _res.first() is not None
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f"[TRADE] {stock_code}: 당일 비-DT 매도 조회 실패 — 부분매도 차단으로 간주 "
|
||||
f"(전량 청산은 허용): {type(e).__name__}: {e}"
|
||||
)
|
||||
return True
|
||||
|
||||
|
||||
async def execute_auto_trade(stock: dict, db: AsyncSession):
|
||||
"""종목 단건 매매 판단 및 실행.
|
||||
|
||||
@@ -1944,6 +2224,22 @@ async def execute_auto_trade(stock: dict, db: AsyncSession):
|
||||
_r = _guard("external_inflow_holding")
|
||||
if _r is not None:
|
||||
return _r
|
||||
elif holding is not None:
|
||||
# [2026-10-01] "팔 수 없는 행에는 사지 않는다" — 매도 소유권과 대칭.
|
||||
# manual(수동매수)·daytrading(EOD 미청산 등)·us_swing 행에 스윙이 추가매수하면
|
||||
# 그 수량은 swing/exit_service(=='swing' 만)도 _execute_sell 도 팔지 않아
|
||||
# SL/TP 없는 보유가 된다. `_guard` 경유라 청산 판정은 그대로 통과(§8.1-11).
|
||||
_own_block = _sell_ownership_block_reason_v2(holding, stock)
|
||||
if _own_block is not None:
|
||||
logger.warning(
|
||||
f"🚧 비-스윙 보유 행 추가매수 차단: {stock_name}({stock_code}) "
|
||||
f"보유 {holding.quantity}주 strategy_type='{holding.strategy_type}' "
|
||||
f"({_own_block}) — 스윙 청산 경로가 이 행을 팔지 않으므로 "
|
||||
f"추가매수분이 SL/TP 없이 남는다"
|
||||
)
|
||||
_r = _guard(f"non_swing_holding:{_own_block}")
|
||||
if _r is not None:
|
||||
return _r
|
||||
|
||||
held_result = await db.execute(select(Portfolio).where(Portfolio.quantity > 0))
|
||||
# 적응형 전략: max_holdings 오버라이드 (실패 시 settings 기본값 fallback)
|
||||
@@ -1974,26 +2270,16 @@ async def execute_auto_trade(stock: dict, db: AsyncSession):
|
||||
# 쿨다운을 영구히 걸지 않는다.
|
||||
cutoff_time = _get_kst_now().replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
recent_sell = await db.execute(
|
||||
select(TradeHistory).where(
|
||||
TradeHistory.stock_code == stock_code,
|
||||
TradeHistory.order_type == "SELL",
|
||||
or_(
|
||||
and_(
|
||||
TradeHistory.status.in_(["COMPLETED", "PARTIAL"]),
|
||||
TradeHistory.executed_at >= cutoff_time,
|
||||
),
|
||||
and_(
|
||||
TradeHistory.status == "PENDING",
|
||||
TradeHistory.created_at >= cutoff_time,
|
||||
),
|
||||
),
|
||||
)
|
||||
select(TradeHistory).where(*_today_sell_conditions_v2(stock_code, cutoff_time))
|
||||
)
|
||||
if recent_sell.first():
|
||||
_sold_today = True
|
||||
# 당일 재매수 차단은 DT 매도까지 포함한다(같은 종목 왕복 방지 — 기존 동작).
|
||||
_r = _guard("cooldown_sell_today")
|
||||
if _r is not None:
|
||||
return _r
|
||||
# [2026-10-01] 단 '부분매도 반복 차단'(_sold_today) 은 스윙 자신의 매도만 본다.
|
||||
# DT 가 같은 종목을 팔았다고 스윙 보유분의 부분익절이 당일 막히면 안 된다.
|
||||
_sold_today = await _non_dt_sold_today_v2(db, stock_code, cutoff_time)
|
||||
|
||||
# [BUG FIX] ActiveTrade.remaining_pct 조회 — 미주입 시 1차 익절 후에도
|
||||
# remaining_pct가 항상 1.0으로 간주되어 SELL_PARTIAL이 반복 발생하는 문제 방지.
|
||||
|
||||
Reference in New Issue
Block a user