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:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user