fix: 매매 파라미터 3중 소스 정합 + 리스크 가드 복원 + 죽은 코드 2,695줄 제거

## 배경
"전략이 혼란스럽고 현재와 맞지 않는다"를 조사한 결과, 전략 설계가 아니라
전략을 검증했다는 도구와 파라미터 소스가 어긋나 있었다.

## 파라미터 소스 단일화
- 우선순위가 adaptive > strategy_cfg(DB) > config.py 인데 문서는 맨 마지막인
  config.py 를 기술하고 있었다. 11곳이 불일치(BULL RSI 46 vs DB 30, SL -7% vs -6%).
- DEFAULT_PARAMS 를 config.py 에 정합 + alembic 0012 로 기존 DB 행 갱신
  (운영자가 UI 로 바꾼 값은 보존, 옛 기본값과 같은 행만 갱신, 테이블 가드·멱등)

## 백테스트를 실전 로직에 정합
- calculate_adx_simple 이 정확히 period 배(14.0×) 발산 → Aggressive 게이트
  `ADX>25` 가 전 레짐 100% 통과. 전환 빈도 5.6%→2.0%(-65%), 성과 상방 편향
- 백테스트 RSI 는 SMA, 실전은 Wilder → 진입 판정 10~11% 불일치
- us_backtest_engine RSI 1봉 지연 제거 (룩어헤드 없음을 절단 테스트로 증명)
- backend/backtest_results/ 25건은 폐기 대상 (§8.4)

## 리스크 가드 복원
- 4% 절대 상한이 try 블록 안에 있어 예외 시 적응형 6~7% 가 통과 → try 밖으로
- 청산을 매수보다 먼저 평가 (보유 종목이 추천에 들면 손절 대신 물타기가 나갔다)
- 리스크 가드 4개를 매수 전용화 → MDD -10%·보유한도 도달 시에도 손절 작동
- MDD 갱신 5회 연속 실패 시 신규 진입 차단 fail-safe (청산은 계속 허용)
- 스윙 청산이 DT 보유분까지 매도하던 문제, sell_pct 기준 혼재 정정

## 죽은 코드 제거 (2,695줄)
- short_term_strategy, kosdaq_scanner, app/strategy/ 패키지
- app/trading/ + korea_investment_api_adapter (real_market_api 정본은
  korea_investment_api.py:1482, adapter 는 import 0건)

## 지표·사이징 단일화 (수치 무변경 증명)
- indicators_core.py: 3,274건 비교 최대 오차 0.000e+00
- sizing_core.py: 42,105건 비교 불일치 0
- 미국 전략 상수 13개 config 편입 (hex 까지 동일, 3,168 케이스 전수 비교)

## 테스트 안전 (실 KIS 호출 차단)
- 유닛 테스트가 실제 KIS 에 매도 주문을 2회 전송하고 있었다. 리팩터링이
  close_stale_positions 의 대상을 _execute_exit → _execute_exit_stale 로
  바꿨는데 목이 전자에 걸려 빗나갔다. test_api_endpoints 도 캡처된 싱글톤
  참조 누수로 실제 잔고 조회를 타 141초를 소비했다.
- backend/tests/conftest.py 신설: 주문·토큰·시세·잔고를 호출 즉시 예외로 차단.
  예외는 BaseException 파생(프로덕션 except Exception 폴백에 삼켜지지 않게),
  클래스 메서드에 적용(캡처된 참조까지 덮음), DATABASE_URL `_test` 가드 강제
- 전체 유닛 스위트: 365초/6 failed → 11.8초/0 failed (소켓 차단 하 559 passed)

## 신규 (둘 다 플래그 OFF, 백테스트 미검증)
- range_trading_strategy.py — SIDEWAYS 담당 전략이 없던 빈 칸. 미배선
- check_bear_buy_signal_v2 — BEAR 4중 AND(발화율 2.3%) → 점수제(12.4%)

## 문서
- CLAUDE.md 를 실제 코드 기준으로 재작성. §0 파라미터 우선순위, §4.1 테스트 규약,
  §6.4 문서화되지 않았던 진입 차단 게이트 9종, §8.4 백테스트 폐기 근거,
  §8.5 미해결 19항목
- 루트 md 6개 → 2개(docs/ 이동), 중복 배치 문서 병합, 스테일 worktree 제거

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-28 08:58:18 +09:00
parent b4608e5abf
commit 1b9257c4d2
71 changed files with 5510 additions and 3685 deletions

View File

@@ -822,6 +822,9 @@ async def sync_portfolio(db: AsyncSession = Depends(get_db), force: bool = True)
# 실시간 콜백/스케줄러 직접 호출과는 성격이 다름.
# ActiveTrade 잔량 정합 — 같은 종목에 active 행이 1개뿐일 때만 실제 보유량으로 교정.
# (분할매도·수동매도 누락으로 DB 잔량이 실제와 달라지면 다음 청산이 과다매도)
# ActiveTrade 시딩 payload 는 여기서 모으고, commit 성공 후에만 큐에 넣는다
# (롤백된 Portfolio 에 대해 ActiveTrade 가 생기는 것을 방지).
_seed_payloads: list[dict] = []
try:
from app.core.config import settings as _cfg # 모듈 상단 미임포트(함수 지역 사용)
_held = {h.stock_code: h.quantity for h in new_holdings if h.quantity > 0}
@@ -849,7 +852,18 @@ async def sync_portfolio(db: AsyncSession = Depends(get_db), force: bool = True)
# 손절·분할익절·트레일링 관리를 전혀 받지 못한다(2026-09-22 카카오뱅크 -13%).
# 보유 중인데 active 행이 없으면 현재 평균단가를 진입가로 보고 생성한다.
# 데이트레이딩 태깅 종목은 제외 — 엔진이 당일 청산 대상으로 복원해버린다.
#
# [§8.5 항목4③ 2026-09-27] 직접 `db.add(ActiveTrade(...))` → db_worker 큐 경유.
# CLAUDE.md §3 예외는 sync_portfolio 의 **Portfolio** 직접 쓰기까지이고,
# ActiveTrade 쓰기는 반드시 db_task_queue 를 거쳐야 한다.
# 이중 행 방지 3중 장치:
# 1) `_managed`(= 이 트랜잭션에서 읽은 status='active' 전 종목) 선제 제외
# 2) upsert 핸들러가 stock_code + status='active' + strategy_name 동등
# 조건으로 재조회 → 이미 있으면 UPDATE (동일 payload 재큐잉도 안전)
# 3) strategy_name 을 시딩 전용값("synced")으로 고정 → 다음 sync 주기가
# 같은 payload 를 다시 넣어도 1)·2) 에서 걸러진다
_managed = set(_by_code)
_seed_sl_pct = abs(_cfg.STOP_LOSS_PERCENT) / 100
for _h in new_holdings:
if _h.quantity <= 0 or _h.stock_code in _managed:
continue
@@ -859,30 +873,59 @@ async def sync_portfolio(db: AsyncSession = Depends(get_db), force: bool = True)
if _avg <= 0:
continue
_cur = float(_h.current_price or _avg)
db.add(ActiveTrade(
user_id=getattr(_h, "user_id", None),
stock_code=_h.stock_code,
stock_name=_h.stock_name,
quantity=_h.quantity,
entry_price=Decimal(str(_avg)),
current_price=Decimal(str(_cur)),
stop_loss=Decimal(str(round(_avg * (1 - abs(_cfg.STOP_LOSS_PERCENT) / 100), 2))),
status="active",
remaining_pct=Decimal("1.0"),
highest_since_entry=Decimal(str(max(_avg, _cur))),
strategy_name=_h.strategy_type or "swing",
entry_reason="외부 유입 포지션 — 동기화 시 자동 등록(진입가=평균단가)",
currency_code=_h.currency_code or "KRW",
exchange_code=_h.exchange_code,
))
# 전략명: 구체적 전략(manual/us_swing 등)은 보존해야 해당 엔진의
# close_active_trade(strategy_name=...) 와 매칭된다. 제네릭 'swing'
# 만 시딩 출처가 드러나는 'synced' 로 표기한다(mdd_manager.
# SWING_STRATEGY_NAMES 에 등재 — 비-DT 로 취급됨).
_seed_name = _h.strategy_type or "swing"
if _seed_name == "swing":
_seed_name = "synced"
# DLQ 직렬화(json) 대비 — Decimal 대신 순수 float/int/str 만 담는다.
_seed_payload = {
"stock_code": _h.stock_code,
"stock_name": _h.stock_name,
"quantity": int(_h.quantity),
"entry_price": _avg,
"current_price": _cur,
"stop_loss": round(_avg * (1 - _seed_sl_pct), 2),
"status": "active",
"remaining_pct": 1.0,
"highest_since_entry": max(_avg, _cur),
"strategy_name": _seed_name,
"entry_reason": "외부 유입 포지션 — 동기화 시 자동 등록(진입가=평균단가)",
"currency_code": _h.currency_code or "KRW",
"exchange_code": _h.exchange_code,
}
_seed_uid = getattr(_h, "user_id", None)
if _seed_uid is not None:
_seed_payload["user_id"] = int(_seed_uid)
_seed_payloads.append(_seed_payload)
logger.info(
f"🆕 ActiveTrade 자동 등록: {_h.stock_name}({_h.stock_code}) "
f"🆕 ActiveTrade 자동 등록(큐): {_h.stock_name}({_h.stock_code}) "
f"{_h.quantity}주 @ {_avg:,.0f} — 외부 유입 포지션"
)
except Exception as _rec_err:
logger.warning(f"ActiveTrade 정합 실패(무시): {_rec_err}")
await db.commit()
# [§8.5 항목4③] 외부 유입 포지션 ActiveTrade 시딩 — commit 성공 후 큐잉.
# db_worker 가 stock_code + status='active' + strategy_name 으로 재조회해
# 기존 행이 있으면 UPDATE 하므로 재큐잉/재시도로도 행이 늘지 않는다.
if _seed_payloads:
try:
from app.services.db_worker import (
enqueue_active_trade_upsert as _enq_seed_at,
)
for _sp in _seed_payloads:
await _enq_seed_at(_sp)
logger.info(
f"🆕 ActiveTrade 시딩 큐잉 완료: {len(_seed_payloads)}건 "
f"({[p['stock_code'] for p in _seed_payloads]})"
)
except Exception as _seed_err:
logger.warning(f"ActiveTrade 시딩 큐잉 실패(무시): {_seed_err}")
portfolio_cache.holdings = [h for h in new_holdings if h.quantity > 0]
await portfolio_cache.cache_summary(balance_data, overseas_balance_data) # 국내·해외 잔고 전달
portfolio_cache.last_synced = datetime.now()
@@ -1294,7 +1337,8 @@ async def check_and_execute_exit_trades(db: AsyncSession):
await loss_tracker.save_to_db(db, stock_code)
# 분할 청산 시 ActiveTrade 잔여 비율 업데이트
# TODO: db_worker 마이그레이션 필요 — 읽기+업데이트가 같은 트랜잭션이라 큐 분리 어려움
# (기존 행 UPDATE 는 같은 트랜잭션에서 SELECT 한 ORM 행 수정 —
# sync_portfolio 와 동일 패턴으로 유지)
if exit_signal and exit_signal.action == "SELL_PARTIAL" and sell_quantity < quantity:
new_remaining = remaining_pct * (1.0 - exit_signal.sell_pct)
# 잔여 수량 즉시 반영 (다음 사이클 원래 수량 기준 재매도 방지)
@@ -1302,23 +1346,42 @@ async def check_and_execute_exit_trades(db: AsyncSession):
holding.current_price = Decimal(str(latest_price))
if active_trade:
active_trade.remaining_pct = Decimal(str(round(new_remaining, 4)))
_at_seed_payload = None
else:
# [FIX] ActiveTrade 없는 포지션(동기화 유입)은 remaining_pct를
# 저장할 곳이 없어 부분매도가 매 사이클 반복됨 → 행 생성
db.add(ActiveTrade(
stock_code=stock_code,
stock_name=stock_name,
quantity=quantity - sell_quantity,
entry_price=Decimal(str(avg_price)),
current_price=Decimal(str(latest_price)),
status="active",
remaining_pct=Decimal(str(round(new_remaining, 4))),
highest_since_entry=Decimal(str(latest_price)),
strategy_name="swing",
entry_reason="동기화 유입 포지션 (부분매도 시 자동 생성)",
currency_code="KRW" if domestic else "USD",
))
# [§3.2 준수 2026-09-27] 직접 db.add(ActiveTrade(...)) →
# db_worker 큐 경유. 이 함수는 sync_portfolio/auth 시드
# 예외에 해당하지 않는다. commit 성공 후에만 큐잉해
# 롤백된 Portfolio 에 ActiveTrade 가 생기는 것을 방지한다.
_at_seed_payload = {
"stock_code": stock_code,
"strategy_name": "swing",
"stock_name": stock_name,
"quantity": quantity - sell_quantity,
"entry_price": Decimal(str(avg_price)),
"current_price": Decimal(str(latest_price)),
"status": "active",
"remaining_pct": Decimal(str(round(new_remaining, 4))),
"highest_since_entry": Decimal(str(latest_price)),
"entry_reason": "동기화 유입 포지션 (부분매도 시 자동 생성)",
"currency_code": "KRW" if domestic else "USD",
}
await db.commit()
if _at_seed_payload is not None:
try:
from app.services.db_worker import (
enqueue_active_trade_upsert as _enq_at_seed,
)
await _enq_at_seed(_at_seed_payload)
logger.info(
f"🆕 ActiveTrade 자동 등록(큐): {stock_name}({stock_code}) "
f"remaining_pct={round(new_remaining, 4)}"
)
except Exception as _at_q_err:
logger.warning(
f"ActiveTrade 큐잉 실패(무시) {stock_code}: {_at_q_err}"
)
else:
# 전량 매도
holding.quantity = max(0, quantity - sell_quantity)