diff --git a/CLAUDE.md b/CLAUDE.md index 4b6d5e9..d3a3774 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,127 +1,332 @@ # 자동 주식 매매 시스템 — Claude 가이드 > 모든 코드 수정·분석은 이 문서의 규칙을 엄격히 준수할 것. +> **이 문서는 코드가 실제로 하는 일을 기술한다.** 이상(理想)이 아니라 현재 동작이 기준이다. +> 코드와 이 문서가 어긋나면 **코드가 진실**이고, 문서를 고치는 것이 1차 대응이다. + +## 0. 파라미터 우선순위 — 가장 먼저 알아야 할 것 + +전략 파라미터의 진실 소스는 **세 곳**이고, 다음 순서로 앞선 것이 이깁니다. + +``` +adaptive_strategy(_MODE_PARAMS) > strategy_cfg(DB: strategy_configs) > settings(config.py) +``` + +- `adaptive_strategy` 는 2분 주기로 모드를 갱신하고, RSI 진입·포지션 크기·손절을 **덮어씁니다**. 모드가 `fallback_used=True` 로 떨어질 때만 아래 단계가 쓰입니다. +- `strategy_cfg.get(KEY)` 는 **DB 테이블 `strategy_configs`** 를 읽습니다. 이 테이블은 `strategy_config_service.DEFAULT_PARAMS` 로 시드되지만, `auto_init()` 이 `on_conflict_do_nothing` 이라 **기존 행은 갱신되지 않습니다.** `reset_to_default()` 도 DB의 `default_value` 컬럼을 쓰므로 코드만 고쳐서는 반영되지 않습니다. +- `config.py`(`settings`)는 **맨 마지막 폴백**입니다. §8.2 표가 문서화하는 값이 바로 이것입니다. + +**따라서 "파라미터가 N이다"라고 주장하기 전에 세 소스를 모두 확인할 것.** 2026-09-27 감사에서 이 세 소스가 11곳에서 어긋나 있었고(예: BULL 진입 RSI가 문서 46 / DB 30, 손절이 문서 -7% / DB -6%), 이것이 "문서가 현실과 맞지 않는다"의 근본 원인이었습니다. **2026-09-27 정합 완료**: `DEFAULT_PARAMS` 11건을 `config.py` 에 맞췄고(AST 전수 대조 후 실질 불일치 0건, 잔여 2건은 부호 규약 차이), alembic `0012_refresh_strategy_config_defaults` 가 기존 DB 행을 갱신합니다 — 운영자가 UI 로 직접 바꾼 값은 보존하고 옛 기본값과 같은 행만 갱신합니다. +**그래도 우선순위 체계 자체는 그대로**이므로, 값을 바꿀 때는 세 곳을 함께 보십시오. ## 1. 스택 & 환경 - **백엔드**: Python 3.11 / FastAPI / SQLAlchemy 2.0(Async) / PostgreSQL 15 / Redis 7 / RabbitMQ 3 - **프론트**: React 18 / TypeScript / Vite / Recharts / Lightweight Charts - **인프라·ML**: Docker Compose, Nginx, Alembic / PyTorch, LightGBM, scikit-learn - **연동**: 한국투자증권(KIS) REST·WebSocket, MCP 서버(stdio) / AI 계층: `AI_PROVIDER`=claude|gemini -- **주요 .env**: `KIS_MODE`(virtual|real), `TOKEN_SAVE_MODE`(JSON 압축+차트 30일 제한), `AUTO_TRADE_ENABLED`, `US_TRADING_ENABLED` -- **배포**: 코드 수정 후 `docker compose up -d --build`(옛 이미지 재사용 방지) — **스키마(모델) 변경 시 반드시 `alembic upgrade head`를 이미지 기동보다 먼저 실행**(`create_all`은 기존 테이블에 컬럼을 추가하지 않음, H1). +- **주요 .env**: `KIS_MODE`(virtual|real), `TOKEN_SAVE_MODE`, `AUTO_TRADE_ENABLED`, `DAY_TRADE_ENABLED`, `US_TRADING_ENABLED`(기본 False), `MULTI_TENANT_ENABLED`(기본 False) + +### 배포 순서 (엄수) +1. `docker compose build` +2. **`alembic upgrade head` 를 앱 기동보다 먼저 실행** (`docker compose run --rm backend alembic upgrade head`) + - 스키마 변경 시 필수: `create_all` 은 기존 테이블에 컬럼을 추가하지 않는다. + - **전략 파라미터 마이그레이션(0012 계열)도 마찬가지다.** 앱이 먼저 뜨면 `strategy_cfg` 캐시(30초)가 옛 값으로 채워져 최대 30초간 낡은 사이징·손절로 매매가 돈다. 장중 배포라면 특히 위험하다. +3. `docker compose up -d --build` — **`--build` 필수.** `up -d` 만 하면 옛 이미지로 컨테이너가 재생성된다. +4. **섹터 캐시 생성(최초 1회)**: `docker compose exec backend python collect_sector_map.py` + - 없으면 `sector_mapper` 가 44종목 큐레이션 표로만 폴백해 §5.1 섹터 필터가 사실상 무력화된다. + - 캐시는 `~/.cache/auto-stock/sector_map.json` 에 생성되고 `docker-compose.yml` 의 `./token_cache:/root/.cache/auto-stock` 마운트로 유지된다(추가 마운트 불필요). + - 상장·폐지 반영을 위해 주기적 재실행 권장(평일 08:30 cron). ## 2. 구조 (`backend/app/`) -- `api/endpoints/` — 20개 모듈 (portfolio, market_data, trades, day_trading, backtest 등) -- `core/` — config(설정), database, security(require_api_key), json_helpers(_dumps), market_schedule -- `models/models.py` — Portfolio, TradeHistory, ActiveTrade, StockAnalysis, StockBan 등 -- `realtime/` — websocket_client, event_handler / `strategy/` — base_strategy, strategy_manager -- `services/` 전략: mean_reversion_strategy(§6), short_term_strategy(§7.2), day_trading_strategy(§7.3), kosdaq_scanner(§7.1), us_stock_strategy +- `api/endpoints/` — portfolio, market_data, trades, day_trading, backtest 등 +- `core/` — config(설정), database, security(require_api_key), json_helpers(`_dumps`), market_schedule +- `models/models.py` — Portfolio, TradeHistory, ActiveTrade, StockAnalysis, StockBan, StrategyConfig 등 +- `realtime/` — websocket_client, event_handler +- `services/` 전략 (활성): `mean_reversion_strategy`(§6, 스윙 3전략 로직 전부 여기), `day_trading_strategy`(§7), `adaptive_strategy`(파라미터 오버레이), `us_stock_strategy`(`US_TRADING_ENABLED=False` 로 정지) - `services/daytrading/`·`services/swing/` — entry_service, exit_service, position_manager, scheduler -- `services/` 핵심: trade_executor(매매엔진: 매수/매도/체결확인/슬리피지), db_worker(비동기 DB워커), mdd_manager·loss_tracker·correlation_filter(리스크), korea_investment_api·kis_realtime_stream·naver_finance(데이터), feature_engineering·ml_ensemble·technical_indicators(분석) +- `services/` 순수 계산 모듈 (settings·DB·네트워크 참조 없음 — 이 원칙을 깨지 말 것): + - `indicators_core.py` — RSI/BB/ATR/ADX 단일 출처. `rsi_wilder`/`rsi_sma`/`atr_sma`/`bollinger_sma`/`adx_wilder`/`true_ranges` + - `sizing_core.py` — 포지션 사이징 원시 연산. `budget_by_ratios`/`clamp_by_ratios`/`apply_confidence_scale`/`amount_to_quantity`/`half_kelly_fraction`/`effective_min_buy_amount`. **한도 숫자는 이 모듈에 없다**(호출부의 settings 소유) +- `services/sector_mapper.py` — 섹터 조회 단일화. **조회 경로는 파일 캐시만 읽고 네트워크를 쓰지 않는다**(§3.1). 수집은 `backend/collect_sector_map.py` 배치 전용 +- `services/` 핵심: trade_executor(매매엔진), db_worker(비동기 DB워커), mdd_manager·loss_tracker·correlation_filter(리스크), korea_investment_api·kis_realtime_stream·naver_finance(데이터), feature_engineering·ml_ensemble·technical_indicators(분석), analysis_db_service(DB 기반 스캐너), market_analyzer(국면 게이트), market_playbook(이벤트 차단), ai_strategy_service(AI 필터 계층) - `mcp-server/` — stdio_server, auto_trade_claude, claude_client, gemini_client, trade_monitor, risk_validator, mcp_tools - 배치(`backend/`) — batch_daily_analysis, batch_intraday_analysis, train_ensemble, collect_krx_data +**2026-09-27 삭제됨** (import 0건 확인 후 제거, 총 2,695줄): +- `services/short_term_strategy.py`, `services/kosdaq_scanner.py`, `app/strategy/` 패키지 전체 — 기능은 각각 `day_trading_strategy`, `analysis_db_service.run_db_scanner()`, `services/` 쪽에 이미 있다. +- `app/trading/`(kis_client·order_manager·market_data) + `services/korea_investment_api_adapter.py` — 유일한 진입점인 adapter 가 import 0건이었다. **`real_market_api` 의 정본은 `services/korea_investment_api.py:1482`** 이고 `adaptive_strategy`·`market_analyzer` 가 거기서 import 한다. adapter 가 정의한 동명 객체는 아무도 쓰지 않았다. + +되살리지 말 것. 되살리려면 먼저 "누가 import 하는가"를 grep 으로 확인하라. +`config.py` 의 `SHORT_TERM_*` 키들은 삭제된 전략의 잔재로 **현재 아무 효과가 없습니다**(백테스트 폴백에서만 참조). + ## 3. 아키텍처 원칙 (필수) 1. **실시간 계층 DB I/O 절대 금지** — API 콜백·웹소켓·실시간 스케줄러에서 `AsyncSessionLocal()`/`commit()` 호출 금지. -2. **모든 DB 쓰기는 `db_worker.py`의 `db_task_queue` 경유** (실패 시 지수 백오프 1→2→4초, 최대 3회 재시도). 예외: `sync_portfolio()`만 단일 트랜잭션 필요로 엔드포인트 직접 DB 접근 허용. auth/credentials/bootstrap 시드(회원가입·로그인, `user_kis_credentials` 등록/삭제, 앱 기동 시 관리자·자격증명 시드)도 단일 트랜잭션 직접 접근 허용 — 실시간 계층(콜백·스케줄러) 아님. -3. 지원 task: insert_trade_history, update_trade_history(+_by_order_id), upsert_stock_analysis, bulk_upsert_stock_analysis, upsert_stock_ban, upsert_portfolio, upsert_active_trade, close_active_trade, insert_trade_journal, log_mcp_deny +2. **모든 DB 쓰기는 `db_worker.py` 의 `db_task_queue` 경유** (실패 시 지수 백오프 1→2→4초, 최대 3회 재시도). + - 허용 예외 ①: `sync_portfolio()` — 단일 트랜잭션 필요. + - 허용 예외 ②: auth/credentials/bootstrap 시드(회원가입·로그인, `user_kis_credentials` 등록/삭제, 기동 시 관리자·자격증명 시드) — 실시간 계층이 아님. + - 허용 예외 ③ (읽기 전용): `daytrading/exit_service.close_stale_positions`, `daytrading/entry_service.populate_daytrading_watchlist`, `daytrading/scheduler` 기동 시 `restore_from_db` — SELECT 전용이며 `commit()` 없음. 각 지점에 주석으로 명시되어 있다. **여기에 쓰기를 추가하지 말 것.** +3. 지원 task: insert_trade_history, update_trade_history(+`_by_order_id`), upsert_stock_analysis, bulk_upsert_stock_analysis, upsert_stock_ban, upsert_portfolio, upsert_active_trade, close_active_trade, insert_trade_journal, log_mcp_deny + - **전략명 필터 (2026-09-27 추가)**: `close_active_trade`/`upsert_active_trade` payload 가 세 가지 선택 키를 받는다. 우선순위는 `strategy_name`(동등) > `strategy_names`(IN) > `strategy_name_not`(NOT IN, **`strategy_name IS NULL` 행은 제외되지 않음**). 키를 넘기지 않으면 동작은 이전과 동일하다. + - `db_worker.not_dt_strategy_clause(column)` 가 "DT 아닌 행"의 정본 표현이다(`DT_STRATEGY_NAMES = ("day_trade","daytrading")`). `trade_executor`·`swing/position_manager`·`swing/exit_service`·`mdd_manager` 가 모두 이것을 쓴다 — **한 곳만 바꾸면 더 나쁜 불일치가 된다.** 4. **동시성**: trade_executor `_order_semaphores`(종목별 동시 주문 방지) / Redis 분산 락(워커 간 동시 매도 방지) / loss_tracker·mdd_manager `asyncio.Lock`. 동시 접근 경로에서는 `is_banned_safe()`, `record_trade_result_safe()` 사용. +5. **`Portfolio` 행은 `stock_code` UNIQUE** 라 같은 종목의 스윙+데이트레이딩 수량이 **합산**되어 있다. 한 전략이 청산할 때는 반드시 다른 전략 보유분을 차감할 것 (`trade_executor._execute_sell`, `swing/exit_service` 양쪽에 구현됨). 차감을 빼먹으면 과다매도·주문거부가 난다. ## 4. 코딩 규칙 -- **기존 함수/클래스 시그니처 변경 금지** → 새 전략은 `_v2`/`_v3`, 동기→비동기 확장은 `_safe` 접미사의 새 메서드. +- **기존 함수/클래스 시그니처 변경 금지** → 새 전략은 `_v2`/`_v3`, 동기→비동기 확장은 `_safe`, 내부 구현 분기는 `_impl` 접미사의 새 메서드. - JSON은 `json.dumps` 금지, `core.json_helpers._dumps()` 사용. -- 쓰기/삭제 엔드포인트에 `Depends(require_api_key)` 필수. 외부 API 호출 `verify=True` 유지(KIS 포함). `SECRET_KEY` 64자+ 랜덤. +- 쓰기/삭제 엔드포인트에 `Depends(require_api_key)` 필수 (portfolio·trades·day_trading 등은 `app/api/__init__.py` 에서 라우터 단위로 적용됨). 외부 API 호출 `verify=True` 유지(KIS 포함). `SECRET_KEY` 64자+ 랜덤. - 프롬프트는 토큰 최소화, 컴팩트 포맷. +- **리스크 가드·한도 클램프를 `try` 블록 안에 두지 말 것.** 예외가 나면 가드가 조용히 통과된다 (2026-09-27에 4% 상한이 정확히 이 이유로 무력화되어 있었다). 예외 로그는 `logger.debug` 금지 — 최소 `warning`. +- **로그·description 문자열에 임계값을 하드코딩하지 말 것.** 실효값을 f-string 으로 넣어라. 2026-09-27 에 `mean_reversion_strategy` 의 BULL 진입 description 이 `RSI<40` 을 박아둔 채 실제로는 46 을 쓰고 있었다 — 로그·TradeJournal·ML 학습 데이터에 거짓 임계값이 남는다. + +### 4.1 테스트 규약 (안전 직결) +2026-09-27 에 **유닛 테스트가 실제 KIS 에 매도 주문을 2회 전송**하고 있었다. 리팩터링이 `close_stale_positions()` 의 호출 대상을 `_execute_exit` → `_execute_exit_stale` 로 바꿨는데 테스트 목이 전자에 걸려 있어 빗나갔고, 실주문 경로가 그대로 실행됐다. 별도로 `test_api_endpoints.py` 도 캡처된 싱글톤 참조 누수로 실제 잔고 조회를 타 141초를 소비했다. + +- **실행**: `cd backend && DATABASE_URL="...autostock_test" ../.venv/bin/python -m pytest tests/ --ignore=tests/integration` + - `backend/tests/conftest.py` 가 `DATABASE_URL` 의 DB 이름이 `_test` 로 끝나지 않으면 **세션을 중단**한다. + - 같은 conftest 가 주문·토큰·시세·잔고 계열을 **호출 즉시 예외**로 치환한다. 차단 예외는 `BaseException` 파생(`KISCallBlocked`) — 프로덕션의 `except Exception:` 폴백에 삼켜지면 차단이 무력화되기 때문이다. + - 차단은 **클래스 메서드**(`KoreaInvestmentAPI.`)에 적용한다. 인스턴스만 패치하면 `from ... import korea_investment_api` 로 참조를 캡처한 모듈에 새어나간다. + - 옵트아웃은 세션 단위 `--allow-network` 하나뿐이다. per-test 예외 마커를 만들지 말 것. +- **목킹 지점은 공통 종단에 걸어라.** 위임 래퍼(`_execute_exit`/`_execute_exit_stale`)가 아니라 `_execute_exit_impl` 처럼 양쪽이 수렴하는 곳에 걸어야 리팩터링에 빗나가지 않는다. +- **차단·목킹은 예외 발생형으로.** 그럴듯한 값을 반환하는 스텁은 목킹 누락을 숨겨 거짓 통과를 만든다. +- **테스트가 수십 초 걸리면 실호출 신호다.** `korea_investment_api._handle_rate_limit()` 의 "토큰 발급 1분 제한" `asyncio.sleep(≈70초)` 에 걸린 것이다. 소켓을 막아도 예외가 폴백에 삼켜진 뒤 잠들기 때문에 느린 것은 그대로다. +- **실호출 없음의 유일한 증거는 소켓 차단 하 통과다.** `socket.getaddrinfo`/`create_connection`/`socket.connect`/`connect_ex` 를 예외로 치환한 pytest 플러그인으로 확인하라(스크래치패드에 두고 프로젝트에 남기지 말 것). +- **`sys.modules` 를 import 시점에 패치하는 방식은 순서 의존을 만든다.** 전체 스위트에서는 앞선 파일이 이미 대상 모듈을 로드해 목이 샌다. 모듈 전역 참조는 **테스트 시점 autouse 픽스처**로 덮어라(`test_mean_reversion.py` 가 이 이유로 단독 74 통과 / 전체 1 실패였다). +- **테스트 기대값에 config 값을 하드코딩하지 말 것.** `settings.*` 를 참조하라. 2026-09-27 에 `MAX_POSITION_SIZE`(0.06→0.04)·`SWING_MAX_CAPITAL_RATIO`(0.50→0.70) 변경이 반영되지 않은 테스트 2건이 이 이유로 깨져 있었다. ## 5. 리스크 규칙 ### 5.1 사이징·필터 -- 종목당 최대 **4%** (`MAX_POSITION_SIZE=0.04`), 최대 **20종목**(`MAX_HOLDINGS_COUNT`), 레버리지/인버스 ETF 최대 **20%**(`LEVERAGE_ETF_MAX_RATIO`). -- 최소 주문금액 **15만원**(`MIN_BUY_AMOUNT_KRW`). 이 값이 종목당 예산에 근접하면 신뢰도·현금 보정으로 깎인 주문이 전부 탈락한다(2026-09-23 하루 141건 차단). -- 가격 필터: 국내 >5,000원, 미국 >$4 (ETF·리츠 예외) (`MIN_PRICE_KRW`, `MIN_PRICE_USD`). +- 종목당 **4%** (`MAX_POSITION_SIZE`) — **절대 상한(hard ceiling)**. 적응형 모드가 6~7%를 지시해도 소비 지점에서 `min(_adp.position_size, MAX_POSITION_SIZE)` 로 클램프되고, 패턴메모리 조정 후에도 `try` 밖에서 재클램프된다. +- 최대 **20종목**(`MAX_HOLDINGS_COUNT`), 레버리지/인버스 ETF 최대 **20%**(`LEVERAGE_ETF_MAX_RATIO`), 전 전략 합산 현금 사용 상한 **95%**(`MAX_TOTAL_CAPITAL_USAGE`). +- 최소 주문금액 **15만원**(`MIN_BUY_AMOUNT_KRW`) — **단 실효값은 `min(150,000, 총자산 × 적응형포지션상한 × 0.9)`** 다. 소액계좌에서 주문이 전부 탈락하는 것을 막기 위한 의도적 완화(2026-09-23 하루 141건 차단 사례). +- 가격 필터: 국내 >5,000원, 미국 >$4 (`MIN_PRICE_KRW`, `MIN_PRICE_USD`). **ETF 예외는 `trade_executor` 경로에만 있고** `market_analyzer`·`portfolio_v2`·`manual_buy` 경로에는 없어 경로별로 동작이 다르다. - 상관관계: 섹터당 최대 3종목, 기존 포트폴리오와 상관계수 >0.7 진입 거부 (`correlation_filter`). -- 연속 손실: 동일 종목 **3연패 → 42캘린더일(≈30거래일) 진입 금지** (`loss_tracker`). `record_trade_result()` 후 반드시 `save_to_db()`(db_worker upsert_stock_ban 경유). 재시작 시 `load_from_db()` 복원. + - **섹터 매핑 (2026-09-27 확충)**: `sector_mapper` 가 네이버 업종 API(`m.stock.naver.com/api/stocks/industry`)로 수집한 캐시를 쓴다. 실측 커버리지 **97.27%(747/768)** — 미매핑 21종목은 전부 ETF/ETN 이라 실질 주식 커버리지 100%. 조회 순서: 메모리 → 큐레이션 정적 표(44종목, 의도적 그룹핑이라 외부보다 우선) → 파일 캐시 → `None`. + - 업종 79개 → 정본 섹터 20개. **네이버 `no=25 "기타"`(1,542종목: ETF/ETN/우선주/스팩)는 수집 단계에서 제외**해 거대 버킷을 만들지 않는다(최대 버킷 9.6%). + - ⚠️ `industry` API 는 **`page`/`pageSize` 를 주지 않으면 20개 업종만 반환하고 나머지가 "기타"로 뭉쳐 보인다.** 수집 코드를 고칠 때 주의. + - **배포 후 `collect_sector_map.py` 를 한 번 돌려야 캐시가 생긴다**(§1-4). 없으면 44종목 표로만 폴백. + - **미매핑 종목의 섹터캡**: `SECTOR_CAP_UNMAPPED_ENABLED`(기본 **False**). 미매핑을 "UNKNOWN" 단일 버킷으로 묶어 3종목 한도를 적용하는 스위치다. 켜기 전 조건 — 운영 환경 커버리지 ≥95% & 캐시 7일 이내, 미매핑 잔여가 ETF/ETN 만(일반 주식이 남아 있으면 UNKNOWN 3종목 상한에 걸려 거래가 죽는다), 최대 버킷 <20%, 그리고 켠 상태 백테스트에서 체결 건수가 유의미하게 줄지 않고 `MAX_HOLDINGS_COUNT=20` 도달이 가능함을 확인. +- 연속 손실: 동일 종목 **3연패 → `CONSECUTIVE_LOSS_BAN_DAYS`(42캘린더일 ≈ 30거래일) 진입 금지** (`loss_tracker`). 값은 config 연동이며 하드코딩이 아니다. `record_trade_result()` 후 반드시 `save_to_db()`(db_worker `upsert_stock_ban` 경유). 재시작 시 `load_from_db()` 복원. ### 5.2 SL/TP (일봉/스윙) -- 기본 SL: **-7%**(안정) / -10%(변동성). ATR SL: `진입가 − 2.5×ATR` (`ATR_STOP_MULTIPLIER=2.5`). -- **SL은 기본/ATR 중 타이트한 쪽 — 항상 `min()`, `max()` 금지.** +- 기본 SL: **-7%**(안정) / **-10%**(변동성, ATR/종가 > 3%). ATR SL: `진입가 − 2.5×ATR` (`ATR_STOP_MULTIPLIER`). +- **SL은 기본/ATR 중 타이트한 쪽.** 비교 기준에 따라 연산자가 다르다 — 혼동 금지: + - **가격 기준** 비교: 더 **높은** 손절가가 타이트 → `max(atr_stop, pct_stop)` 이 올바르다. + - **음수 퍼센트 기준** 비교: 더 **큰**(0에 가까운) 값이 타이트 → `max()` 가 올바르다. + - `stop_loss_pct = -abs(...)` 부호 가드를 항상 유지한다(양수가 들어가면 전 종목 매도 사고). - TP: +2R 도달 시 50% 분할 매도, 잔량 트레일링 스탑 `고점 − 1.5×ATR`. +- **분할매도 `sell_pct` 는 "진입 시 원본 수량 대비" 비율**이다. `remaining_pct` 게이트(1.0→0.70→0.40)가 원본 누적 전제로 설계되어 있으므로 잔량 기준으로 바꾸면 같은 단계가 재트리거된다. -### 5.3 MDD (mdd_manager — portfolio.py·swing/scheduler.py에서 자동 호출, 중복 실행 방지 내장) -- **-10%**: 신규 진입 중단 + 포지션 30% 축소 + 단기·데이트레이딩 중단 (REDUCE_AND_PAUSE) +### 5.3 MDD (mdd_manager — swing/scheduler.py 에서 매 사이클 자동 호출, 중복 실행 방지 내장) +- **-10%**: 신규 진입 중단 + 포지션 30% 축소 + 데이트레이딩 중단 (REDUCE_AND_PAUSE) - **-20%**: 전 포지션 50% 강제 축소, 레버리지/인버스 ETF 우선 청산 (FORCE_REDUCE) - 잔고가 peak 갱신 시 모든 MDD 플래그 리셋. +- **MDD 갱신은 `swing/scheduler.py` 한 곳이 유일한 실효 경로**다(`portfolio.py` 경로는 deprecated). 갱신이 실패하면 peak/drawdown이 정지해 -10%/-20% 게이트가 영구 미발동한다. +- **fail-safe (2026-09-27 적용)**: 갱신이 `MDD_BLIND_FAILURE_THRESHOLD`(5)회 연속 실패하면 `can_open_new_position()` 이 `False` 를 반환해 **모든 신규 매수가 차단**된다(`MDD_BLIND_BLOCK_ENTRY` 기본 **True**). 스윙 진입·수동매수 API·미국장·데이트레이딩 진입·`trade_executor` 전 경로에 일괄 적용되고, **청산 경로는 이 메서드를 호출하지 않아 영향이 없다.** 빈 계좌(`tot_evlu_amt<=0`)는 정상 응답으로 분류되어 차단 사유가 아니다. 갱신 1회 성공이면 재시작 없이 자동 해제. + - ⚠️ **KIS 장애가 5분 연속되면 당일 매수 0건이 될 수 있다.** 토큰 충돌·계좌 교체(40910000)·네트워크 단절이 해당한다. `[MDD] 🚨 신규 진입 차단(fail-safe)` error 로그로 즉시 식별되고, 급하면 `MDD_BLIND_BLOCK_ENTRY=false` 로 끌 수 있으나 그 상태의 진입은 -20% 구간에서도 무제한 추가 진입을 허용한다는 뜻이다. + - 상태 조회: `mdd_manager.get_update_health()` / `swing.scheduler.get_mdd_update_health()` +- MDD 축소가 `ActiveTrade` 를 닫을 때는 스윙 전략명을 명시한다(`_sync_active_trade_after_reduction`). 전략명 없이 닫으면 db_worker가 "가장 최근 active 행"을 닫아 DT 행을 오폐기한다. -## 6. 전략 A: RSI+BB 평균 회귀 v2 (`mean_reversion_strategy`, `swing/`) — 스윙 자본 최대 70%(`SWING_MAX_CAPITAL_RATIO`) -- **시장 국면(일봉)**: BULL = 코스피200>MA200 & MA200 기울기>0 / BEAR = 코스피20020MA / BEAR: RSI<35 + 가격1.5×20MA + 반전캔들 필수 / 선제: 일봉 RSI<46 & 60분봉 RSI<30 & MA5 상향돌파 -- **청산**: 50% 부분매도: RSI≥55 or 가격>BB중앙 or 수익>2×ATR / 전량: RSI≥70 or 가격>BB상단(99%) or 트레일링(고점−1.5×ATR) +## 6. 스윙 전략 (`mean_reversion_strategy`, `swing/`) — 스윙 자본 최대 70%(`SWING_MAX_CAPITAL_RATIO`) -## 7. 전략 B: 공격적 성장 -### 7.1 코스닥 스캐너 (`kosdaq_scanner`) — 자본 25% -코스닥 전 종목(~1,600) / 가격>5,000원 + 거래량 상위 20% / 점수 = `0.5×RSI + 0.3×BB + 0.2×Vol`, ≥0.15 중 상위 20개 선정. +세 전략이 한 파일에 있고 모두 `swing/entry_service` 에서 호출됩니다. -### 7.2 단기 15분봉 (`short_term_strategy`) — 자본 최대 25%, **MDD -10% 시 비활성** -- 타겟: 일봉 RSI<40 / 진입: 15분 RSI<30 + BB하단 부근 + 종가>MA5(상향돌파) + 거래량>1.3×20MA(`SHORT_TERM_VOLUME_MULTIPLIER`) + ATR/close≥0.8%(`SHORT_TERM_MIN_ATR_RATIO`, 거래비용 커버) -- 청산: 15분 RSI>55 or 수익>1.5×ATR(15분) / SL: `min(진입가−1.0×ATR, -2% 하드스탑)` — 타이트한(stop가 높은) 쪽. *§5.2 ATR 2.5×는 일봉/스윙 전용* +| 전략 | 진입 | 청산 | 국면 조건 | +|------|------|------|----------| +| 평균회귀 | RSI 과매도 + BB하단(%B) | RSI 55 부분 → RSI 70 전량 | BULL·BEAR | +| 모멘텀 돌파 | 20일 신고가 + 거래량 2× + RSI 50~70 | +10% 30% → +20% 30% → 트레일링 | **BULL 전용** | +| 눌림목 매수 | MA20>MA60 + MA20 −3%~+2% + RSI 40~55 + RSI 반등 + 거래량 ≥1.2×20MA | 평균회귀와 동일 | **BULL 전용** | -### 7.3 데이트레이딩 5분봉 (`day_trading_strategy`, `daytrading/`) — 자본 최대 **30%**(`DAY_TRADE_MAX_RATIO_DT`, 스윙:DT = 70:30, 스윙은 `SWING_MAX_CAPITAL_RATIO=0.70`로 상한), **MDD -10% 시 비활성** +### 6.1 시장 국면 (일봉) +- **BULL** = 코스피200 > MA200 & MA200 기울기 > 0 +- **BEAR** = 코스피200 < MA200 & 기울기 < 0 +- **SIDEWAYS** → **모든 진입 무시** (market_analyzer: position_multiplier=0, max_trades=0) + - 판정은 `ADX < 18` **AND** 기울기 ≈ 0 동시 충족 (히스테리시스: 진입 ADX<18 / 해제 ADX>22). 기울기 비교 기간은 20일. +- **국면 커버리지 불균형**: 신호원 3개 중 2개가 BULL 전용이고, BEAR는 평균회귀 분기 하나, SIDEWAYS는 담당 전략이 없다(진입 차단이 곧 전략). 코스피가 BULL이 아닌 구간에서는 사실상 거래가 나오지 않는다. + - **2026-09-27: 두 빈 칸을 채우는 코드를 넣었으나 둘 다 플래그 OFF 로 출고했다.** DB(NAS) 다운으로 백테스트를 돌릴 수 없고, 게다가 기존 백테스트 자체가 실전과 다른 지표로 돌아간 것이 밝혀져(§8.4) 참고할 근거가 없다. + - **박스권**: `services/range_trading_strategy.py` + `swing/entry_service.scan_range_trading_signals`. `RANGE_TRADE_ENABLED`(기본 **False**). **스케줄러 배선도 아직 하지 않았다** — 호출부가 없어 현재 실행 경로에 존재하지 않는다. + - **BEAR 완화**: `mean_reversion_strategy.check_bear_buy_signal_v2` (점수제). `MEAN_REVERSION_BEAR_V2_ENABLED`(기본 **False**). OFF 면 기존 4중 AND 경로가 그대로 동작한다. + - 측정된 문제 크기: 같은 합성 샘플에서 **BEAR 발화율 2.3% vs BULL 13.2%** — 실질 게이트 8개가 전부 AND 였다. v2 는 12.4%(5.4배)로 올라가고 구 발화를 모두 포함하는 상위집합이다. + +### 6.2 평균회귀 진입 조건 (실제 코드) +- **공통 선행 필터** (BULL·BEAR 모두): + - `current_volume < avg_volume_20d` → 진입 거부 (거래량 하한, 무조건 선적용) + - **own_trend 게이트**: 종목 MA20 < MA60 × 0.97 → 진입 거부 + - **RSI 반등 필수**: `rsi <= rsi_prev` 면 진입 거부 (RSI 하락 중 진입 금지) +- **BULL**: RSI < 46 (`MEAN_REVERSION_RSI_THRESHOLD`) + **%B 임계값** 방식으로 BB 하단 근접 판정 + - %B 임계: RSI<25 → 0.35 / RSI<30 → 0.30 / 그 외 → 0.20, 여기에 적응형 `bb_pct_b/0.30` 스케일(상한 1.3) 적용 + - ※ 과거 문서의 "BB하단 −3%~+1.5% 가격 밴드"는 코드에 존재하지 않는다. +- **BEAR**: RSI < 35 (`MEAN_REVERSION_BEAR_RSI_THRESHOLD`) + %B ≤ 0.20 + 거래량 > 1.5×20MA + 반전캔들 +- **선제 진입(60분봉 RSI<30 + MA5 상향돌파)은 구현되어 있지 않다.** 헬퍼 `check_h1_ma5_breakout` 은 호출부가 없다. 기능이 필요하면 새로 배선할 것. + +### 6.3 평균회귀 청산 +- 50% 부분매도: RSI ≥ 55 or 가격 > BB중앙 or 수익 > 2×ATR — **단 최소수익 0.5% 가드**가 있다 +- 전량: RSI ≥ 70 or 가격 > BB상단(99%) or 트레일링(고점 − 1.5×ATR) +- 시간손절: 20일 보유 + 3% 미달 시 전량 (`check_time_stop_exit`) + +### 6.4 문서화되지 않았던 진입 차단 게이트 (전부 실재하며 거래 빈도에 큰 영향) +- **ML 앙상블 HOLD 게이트** (`trade_executor`): ML이 HOLD이고 신뢰도 ≥60% 면 rule 매수를 차단 +- **지수 서킷브레이커** (`swing/entry_service`): KODEX200 종가 < MA20 이면 신규 진입 **전면 중단** +- **마켓 플레이북** (`market_playbook`): FOMC·옵션만기 등 이벤트 당일 진입 차단 +- **거시/국면 position_multiplier** (`market_analyzer`): 장외 0.5, CRASH 0.0~0.1, BEAR ratio +- **적응형 Crisis 모드**: 진입 전면 차단 (§8.4) +- **추격매수 방지·추세 필터·이격도 과열·일일 매매한도·재매수 쿨다운** (`trade_executor`) +- **AI 전략 계층** (`ai_strategy_service`): 08:30 장전 분석 / 08:50 DT 후보 선정 / 뉴스 감시 / 15:40 장후 리뷰. 적합성 필터링과 뉴스 악재 배율로 실제 매매에 영향을 준다. +- **듀얼 전략 후보 소스 의존**: `StockAnalysis.analysis_date >= today` 에만 의존하므로 **일일 배치가 실패하면 국면이 BULL이어도 후보 0건**으로 조용히 아무 일도 일어나지 않는다. + +**2026-09-27 해소된 치명적 결함 2건** (이전에는 듀얼 전략이 사실상 매수를 못 했다): +- `scan_dual_strategy_signals` 가 `"signal"`(단수 문자열)만 넣고 `"signals"`(복수 리스트)를 넣지 않아, `trade_executor` 의 `should_buy = any(k in "" for k in BUY_KEYWORDS)` 가 **항상 False** 였다. `rule_id` 도 없어 rule 강제 매수 경로도 타지 않았다 → **ML 앙상블이 독립적으로 BUY 를 낼 때만** 진입했다. §8.3 의 "3종 병행" 서술과 어긋났다. + - 비-rule 요건은 `has_strong_signal OR (matched_count >= 2 AND score_val >= 4)` 인데 듀얼의 `score` 는 0.8/0.6 이라 **STRONG 신호가 필수**다. 모멘텀 돌파를 표현하는 값이 없어 `BuySignal.신고가돌파`("20일 신고가 돌파")를 신설했다. + - **신호 문자열은 실제 진입 근거와 일치해야 한다.** 통과시키려고 맞지 않는 키워드를 끼우면 로그·저널·ML 학습 데이터가 오염된다(예: 모멘텀에 `RSI 안정 상승`은 정의가 `45~60` 이라 60~70 구간에서 거짓). +- `execute_auto_trade_background` 가 `trade_results` 를 채우고도 `return` 문이 없어 **항상 `None`** 을 반환했다 → 호출부의 `isinstance(result, list)` 가 영구 False → "진입 n건" 로그가 나오지 않았다. 매매는 정상 수행되고 결과만 유실됐다. 이제 종료 경로 6개 전부 list 를 반환한다. + - 반환 리스트에는 **`action="SELL"` 과 `success=False` 도 섞인다.** "진입 n건"은 `action=="BUY" and success` 로 세야 한다(호출부 3곳 정정됨). + +거래가 나오지 않을 때는 전략 로직보다 **이 목록을 먼저 확인할 것.** + +## 7. 데이트레이딩 (5분봉, `day_trading_strategy`, `daytrading/`) — 자본 최대 **30%**(`DAY_TRADE_MAX_RATIO_DT`, 스윙:DT = 70:30) + +**MDD -10% 시 비활성.** CRASH/HALTED 국면 차단. | 전략 | 진입 | SL | |------|------|-----| -| ORB | 시초 레인지 돌파 + 거래량≥1.5×평균분당 + 직전 5분봉 양봉 | `max(ORB저점, 현재가×(1-ORB_SL%))` + ATR 하한, -2.5% 클램프 | -| VWAP | VWAP 지지 반등 + RSI(14)<40 + 거래량≥1.3× + 양봉 | `min(ATR×1.5, -2.5% 하드)` + 최소 1원 | -| RSI_BB | RSI≤30 + 종가≤BB하단 + 거래량≥1.3× + 양봉 | `min(ATR×1.5, -2.5% 하드)` + 최소 1원 | +| ORB | 시초 레인지 돌파(+0.2% 여유) + 거래량 ≥1.5×평균분당 + 직전 5분봉 양봉 + 갭 4%↑ 거부 + 레인지폭 0.3% 미만 거부 | `max(ORB저점, 현재가×(1−3%))` → ATR 하한 → 최소 손절폭 **-2%** 클램프 | +| VWAP | VWAP 지지 반등(−1%~+0.5%) + RSI(14) ≤ **45** + 거래량 ≥1.3× + 양봉 | ATR 있으면 `현재가 − 1.5×ATR`, 없으면 −**3.5%** → 최소 손절폭 -2% 보장 | +| RSI_BB | RSI ≤ **35** + 종가 ≤ BB하단×1.01 + 거래량 ≥1.3×(**직전 봉 대비**) + 양봉 | VWAP과 동일 | -- 사이징: 종목당 6%(`DAY_TRADE_MAX_CAPITAL_RATIO`), 동시 5종목(`DAY_TRADE_MAX_POSITIONS`), confidence 조정 `예산×(confidence/0.7)` -- 청산: +2R 시 50% 분할→잔량 트레일링(ATR×1.0) / 진입 30분(6봉)간 +0.3% 미달 시 시간손절 / 5분 RSI≥70 & 수수료 이상 수익 시 / **15:20 EOD 전량 청산**(`DAY_TRADE_EOD_FLAT_*`) / **전일 미청산 포지션은 장중 발견 즉시 전량 청산**(`close_stale_positions`, EOD 누락 안전장치) -- 제한: CRASH/HALTED 국면 차단 / **15:00 이후 신규 마감**(`DAY_TRADE_ENTRY_CUTOFF_*`, 점심 제한은 2026-09-22 제거) / 일일 -3% 손실 시 당일 중단(`DAY_TRADE_DAILY_LOSS_LIMIT`) / 당일 3연패 쿨다운(position_manager) / 전량 매도 시 loss_tracker 기록 + `save_to_db()` -- 감시종목: 09:00에 당일 분석이 없으면 **최근 분석일 결과로 선채움**(ORB 수집 보장), 자동 목록은 30분마다 갱신(수동 설정 목록은 유지) +- **최소 손절폭 보장(`DAY_TRADE_MIN_STOP_LOSS_PCT` = 2%)은 §5.2의 "타이트한 쪽" 규칙의 의도적 예외**다. 장중 전략에서 손절이 너무 타이트하면 노이즈에 털리므로, `min(raw_stop, min_stop)` 으로 **더 느슨한 쪽**을 고른다. 이 `min()` 을 "버그"로 판단해 뒤집지 말 것. +- 사이징: 종목당 **6%**(`DAY_TRADE_MAX_CAPITAL_RATIO`), 동시 **5종목**(`DAY_TRADE_MAX_POSITIONS`) = 5×6% = 30%. confidence 조정 `예산 × min(1.2, confidence/0.7)` 후 **세 한도(종목당 6% / DT 잔여 30% / 가용현금)로 재클램프**된다. +- 청산: **1.5R**(`DAY_TRADE_TARGET_MULTIPLIER`) 도달 시 50% 분할 → 잔량 트레일링(ATR×**1.5**, 발동 최소수익 1%) / 진입 30분(6봉)간 **-0.3% 미만**이면 시간손절(수익 중이면 보유 유지) / 5분 RSI ≥70 & 수수료 이상 수익 시 / **15:20 EOD 전량 청산** / **전일 미청산 포지션은 장중 발견 즉시 전량 청산**(`close_stale_positions`) +- **전일 포지션 청산 손익은 당일 실현손익에 넣지 않는다**(`_finalize_exit_impl(count_daily_pnl=False)`). 넣으면 당일 매매 0건인데 -3% 손실한도가 발동해 정상 포지션까지 전량 청산된다. 전일 손익은 `get_stale_realized_pnl()` + warning 로그 + TradeHistory로 추적한다. stale 청산 종목은 `closed_today` 에 추가해 당일 재진입을 막는다(주문 실패 시에도). +- **분봉 조회 실패 시 폴백 손절**이 있다(`_check_fallback_stop_loss`). 현재가만으로 손절선 이탈을 판정해 전량 청산한다. 폴백 손절가는 `min()` 으로 고른 가장 타이트한 하드캡(-2%) 기준이며, 현재가조차 못 구하면 `logger.error` 로 수동 확인을 요청한다. +- 제한: **15:00 이후 신규 마감**(`DAY_TRADE_ENTRY_CUTOFF_*`) / 일일 **-3%** 손실 시 당일 중단(`DAY_TRADE_DAILY_LOSS_LIMIT`) / 당일 3연패 쿨다운(position_manager) / 전량 매도 시 loss_tracker 기록 + `save_to_db()` +- 감시종목: 09:00에 당일 분석이 없으면 최근 분석일 결과로 선채움(ORB 수집 보장), 자동 목록은 30분마다 갱신(수동 설정 목록은 유지) +- **1분봉 데이터 주의**: `get_minute_chart_full_day` 는 과거 데이터 포함 요청(`FID_PW_DATA_INCU_YN="Y"`)이라 전일 캔들이 섞인다. dedupe·정렬 키에 `stck_bsop_date` 를 포함하고 당일 외 캔들을 제외해야 ORB 거래량(`candles_1min[-1]`)·VWAP이 오염되지 않는다. ## 8. Claude 지침 ### 8.1 체크포인트 -1. DB 쓰기 = db_worker 경유 (예외: sync_portfolio) -2. 모든 매매에 §5 리스크 규칙 적용 -3. SIDEWAYS 국면 진입 제안 금지 -4. 백테스트·Walk-forward 없이 실전 투입 금지 -5. **SL 선택 = 항상 `min()`(타이트한 쪽). `max()` 절대 금지** -6. `record_trade_result()` 후 `save_to_db()` 호출 확인 -7. 쓰기/삭제 API에 `require_api_key` 필수 -8. JSON 출력 시 `_dumps()` / 새 기능은 새 파일 / 기존 시그니처 수정 금지 +1. **파라미터 값을 주장하기 전에 §0의 세 소스를 모두 확인** (adaptive / strategy_cfg(DB) / config.py) +2. DB 쓰기 = db_worker 경유 (예외: §3.2의 3종) +3. 모든 매매에 §5 리스크 규칙 적용 +4. SIDEWAYS 국면 진입 제안 금지 +5. 백테스트·Walk-forward 없이 실전 투입 금지 — **단 §8.5의 백테스트 신뢰성 주의사항을 먼저 읽을 것** +6. **SL 선택 = 타이트한 쪽. 단 비교 기준(가격 vs 음수 퍼센트)에 따라 연산자가 다르고, 장중 최소 손절폭 보장은 의도적 예외** (§5.2, §7) +7. `record_trade_result()` 후 `save_to_db()` 호출 확인 +8. 쓰기/삭제 API에 `require_api_key` 필수 +9. JSON 출력 시 `_dumps()` / 새 기능은 새 파일 / 기존 시그니처 수정 금지 +10. **리스크 가드·한도 클램프를 `try` 안에 두지 말 것.** 예외 로그에 `logger.debug` 금지 +11. **청산은 항상 매수보다 우선.** 리스크 가드(MDD/밴/보유수/쿨다운)는 신규 매수만 차단하고 청산 판정은 통과시킬 것 +12. 한 전략이 청산할 때 다른 전략 보유분 차감 확인 (§3.5) -### 8.2 config.py 주요값 (백테스트 검증 2023-03~2026-03 완료) +### 8.2 config.py 주요값 | 설정 | 값 | 비고 | |------|-----|------| -| `MAX_POSITION_SIZE` | 0.04 | 0.06→0.04 (2026-09-23, 종목 수 확대) | -| `MIN_BUY_AMOUNT_KRW` | 150,000 | 500,000→150,000 (거래 차단 해소) | +| `MAX_POSITION_SIZE` | 0.04 | 절대 상한. 적응형보다 우선 | +| `MIN_BUY_AMOUNT_KRW` | 150,000 | 실효값은 소액계좌에서 하향 (§5.1) | | `MAX_HOLDINGS_COUNT` | 20 | | +| `MAX_TOTAL_CAPITAL_USAGE` | 0.95 | 현금 5% 유보 | | `LEVERAGE_ETF_MAX_RATIO` | 0.20 | | | `ATR_STOP_MULTIPLIER` | 2.5 | 스윙 ATR SL 배수 | -| `STOP_LOSS_PERCENT` | 7.0 | 6.0→7.0 | -| `MEAN_REVERSION_RSI_THRESHOLD` | 46.0 | 40→46 | -| `MEAN_REVERSION_BEAR_RSI_THRESHOLD` | 35.0 | 30→35 | -| `MEAN_REVERSION_RSI_EXIT_THRESHOLD` | 55.0 | 1차 익절, 50→55 | -| `MEAN_REVERSION_RSI_FULL_EXIT` | 70.0 | 전량 청산, 65→70 | -| `DAY_TRADE_MAX_POSITIONS` | 5 | 8→5 (70:30 배분) | -| `DAY_TRADE_ENTRY_CUTOFF` | 15:00 | 14:30→15:00 (시간대 완화) | -| `DAY_TRADE_MAX_RATIO_DT` / `SWING_MAX_CAPITAL_RATIO` | 0.30 / 0.70 | 스윙:DT 70:30 (2026-09-23) | -| `CONSECUTIVE_LOSS_BAN_COUNT` | 3 | | +| `STOP_LOSS_PERCENT` | 7.0 | config는 양수, DB는 `-7.0` (부호 규약 차이) | +| `STOP_LOSS_HIGH_VOL` | 10.0 | config는 양수, DB는 `-10.0` | +| `MEAN_REVERSION_RSI_THRESHOLD` | 46.0 | | +| `MEAN_REVERSION_BEAR_RSI_THRESHOLD` | 35.0 | | +| `MEAN_REVERSION_RSI_EXIT_THRESHOLD` | 55.0 | 1차 익절 | +| `MEAN_REVERSION_RSI_FULL_EXIT` | 70.0 | 전량 청산 | +| `DAY_TRADE_MAX_POSITIONS` | 5 | 5×6% = 30% | +| `DAY_TRADE_MAX_CAPITAL_RATIO` | 0.06 | 종목당 | +| `DAY_TRADE_ORB_STOP_LOSS_PCT` | 0.03 | | +| `DAY_TRADE_REVERSION_STOP_LOSS_PCT` | 0.035 | VWAP/RSI_BB | +| `DAY_TRADE_MIN_STOP_LOSS_PCT` | 0.02 | 최소 손절폭 (느슨한 쪽 선택) | +| `DAY_TRADE_TARGET_MULTIPLIER` | 1.5 | 1.5R | +| `DAY_TRADE_TRAILING_ATR_MULT` | 1.5 | | +| `DAY_TRADE_VWAP_RSI_THRESHOLD` | 45.0 | | +| `DAY_TRADE_RSI_BB_RSI_THRESHOLD` | 35.0 | | +| `DAY_TRADE_TIME_STOP_MIN_GAIN` | -0.003 | -0.3% **미만**일 때 청산 | +| `DAY_TRADE_ENTRY_CUTOFF` | 15:00 | | +| `DAY_TRADE_MAX_RATIO_DT` / `SWING_MAX_CAPITAL_RATIO` | 0.30 / 0.70 | 합 100% | +| `CONSECUTIVE_LOSS_BAN_COUNT` / `_DAYS` | 3 / 42 | 캘린더일 | +| `SECTOR_CAP_UNMAPPED_ENABLED` | False | 켜기 전 §5.1 확인 | +| `MDD_BLIND_BLOCK_ENTRY` / `_FAILURE_THRESHOLD` | True / 5 | MDD 갱신 실패 시 신규 진입 차단 (§5.3) | +| `RANGE_TRADE_ENABLED` | False | 박스권 전략. **백테스트 미검증 — True 금지** (§6.1) | +| `MEAN_REVERSION_BEAR_V2_ENABLED` | False | BEAR 점수제. **백테스트 미검증 — True 금지** (§6.1) | -### 8.3 스윙 듀얼 전략 (3종 병행) +### 8.3 적응형 모드 (`adaptive_strategy.py`, 2분 주기 자동 전환) -| 전략 | 진입 | 청산 | -|------|------|------| -| 평균회귀 | RSI 과매도 + BB하단 | RSI 55 부분 → RSI 70 전량 | -| 모멘텀 돌파 | 20일 신고가 + 거래량 2× + RSI 50~70 | +10% 30%매도 → +20% 30%매도 → 트레일링 | -| 눌림목 매수 | MA20>MA60 + MA20 근처 조정 + RSI 반등 | 평균회귀와 동일 | +| 모드 | RSI 진입 | 포지션 | SL | 트리거 | 해제 | +|------|---------|--------|-----|--------|------| +| Aggressive | 50 | 7% → **4%로 클램프** | -9% | ATR백분위<30 **AND** ADX>25 **AND** 5일수익>+2% | ATR<40 & ADX>20 & 5일>0 | +| Normal | 46 | 6% → **4%로 클램프** | -7% | 기본 | — | +| Defensive | 38 | 4% | -6% | ATR백분위>70 **OR** 5일수익<-3% | ATR<60 & 5일>-1% | +| Crisis | 25 | 1.5% | -3% | ATR백분위>90 **OR** 5일수익<-5% **OR** **MDD < -10%** | ATR<80 & 5일>-3% & MDD>-8% | -### 8.4 적응형 모드 (`adaptive_strategy.py` 자동 전환) +- `_MODE_PARAMS` 숫자는 문서·백테스트 기준값이므로 **바꾸지 말 것.** 상한 제약은 소비 지점에서 클램프한다. +- **Crisis 는 파라미터 조정이 아니라 on/off 스위치**다: `max_daily_trades=0` 으로 스윙·DT 신규 진입을 전면 차단한다. MDD -10%가 곧 Crisis를 유발하므로 §5.3의 REDUCE_AND_PAUSE와 이중으로 걸린다. +- 모드 해제에는 히스테리시스 밴드가 있어 진입/해제 임계가 다르다(위 표). -| 모드 | RSI 진입 | 포지션 | SL | 트리거 | -|------|---------|--------|-----|--------| -| Aggressive | 50 | 7% | -9% | ATR백분위<30 + ADX>25 + 5일수익>+2% | -| Normal | 46 | 6% | -7% | 기본 | -| Defensive | 38 | 4% | -6% | ATR백분위>70 or 5일수익<-3% | -| Crisis | 25 | 1.5% | -3% | ATR백분위>90 or 5일수익<-5% | +### 8.4 백테스트 신뢰성 — ❌ 기존 결과물 전부 폐기 -*마지막 업데이트: 2026-09-23 (종목당 4%·최소주문 15만원, 시간대 KST 통일, 감사 수정 14건)* +**2026-09-27 조사 결론: 파라미터를 검증했다는 백테스트가 실전 로직을 돌린 적이 없다.** 세 가지가 겹쳤다. + +**① `run_backtest.calculate_adx_simple` 이 정확히 `period` 배(14.0×)로 발산했다.** +DX(0~100)에 합산형 평활을 적용한 버그. 측정: `up 1361 vs 97 / flat 165 vs 12 / vol 206 vs 15` — 전 패턴에서 배율 14.0. +결과적으로 §8.3 Aggressive 게이트 `ADX > 25` 가 **모든 레짐에서 100% 통과**했다. 수정 후 43~87%. +**Aggressive 전환 빈도 5.6% → 2.0% (−65%).** Aggressive 는 포지션 7%·SL −9% 이므로, 기존 백테스트는 해당 구간에서 실전보다 큰 사이즈·느슨한 손절로 돌았다 = **성과 상방 편향**. + +**② 백테스트 RSI 는 SMA, 실전은 Wilder 였다.** +측정(n≈70,000): `mean|Δ| 4.7~6.5`, `p95 ≈ 15`, `max ≈ 35`. **진입 게이트 `RSI<46` 판정이 10~11% 의 봉에서 불일치**, 전량청산 `RSI≥70` 은 6~10% 불일치. + +**③ 미국 백테스트 RSI 가 1봉 지연**이었다(같은 엔진의 BB 는 당일 포함, 실전도 당일 포함). 진입 게이트 판정 8.4% 불일치. + +세 건 모두 `indicators_core` 로 정합화했다(룩어헤드 없음을 절단 테스트로 증명 — 각 `i` 의 값이 `closes[:i+1]` 만으로 결정됨). + +**`backend/backtest_results/` 의 25개 JSON 은 인용 금지.** 근거: +- 전부 2026-09-06 생성 = ADX 발산 수정(2026-09-16 `a097381`) **이전** 코드의 산출물. 당시 SIDEWAYS 게이트 ADX 도 발산 상태여서 §6.1 의 "SIDEWAYS 전면 진입 무시"가 사실상 적용되지 않았다. +- `total_trades` 10~13건 — 파라미터당 최소 100거래 기준 대비 2자리수 부족. Sharpe −2.2~−2.6 자체가 통계적 무의미. +- `params.adaptive_modes.normal.rsi_entry` 가 파일별로 20/43/70 으로 흔들림 = optimizer 그리드 스윕 산출물. IS/OOS 분리·walk-forward 기록 없음. +- 저장 기간은 2024-09 이후뿐이고 파라미터도 현행 config(4%/7.0/46)와 불일치(0.05/−6.0/43) → **§8.2 표 주석의 "백테스트 검증 2023-03~2026-03 완료"를 뒷받침하는 산출물이 디렉터리에 없다. 현재 운영 파라미터는 검증 이력이 없다.** + +**재실행 최소 요건** (§8.1-5 를 만족시키려면): +1. 국내 2023-03~2026-09 전 구간, BULL/BEAR/SIDEWAYS 각 6개월 이상 포함 +2. Walk-forward 5구간 이상(Train:Test = 4:1, 앵커드 1회 + 롤링 1회). 판정선: OOS Sharpe / IS Sharpe ≥ 0.5, 구간별 Sharpe 변동계수 ≤ 0.6 +3. 파라미터당 최소 100거래 → 유효 자유 파라미터 8~10개면 총 800~1,000 거래. 미달이면 유니버스 확대보다 **파라미터 감축이 정답** +4. **모드별 거래 수 분리 보고.** Aggressive 표본이 5.6%→2.0% 로 줄었으므로 100 미달 가능성이 높다 → 미달이면 "Aggressive 는 통계적으로 검증되지 않음"으로 명시하고 실전 활성화 보류 +5. 거래비용 재확인(수수료 0.015%×2 + 매도세 0.18% + 슬리피지 0.1~0.3%). RSI 변경으로 진입 타점이 달라져 회전율이 바뀌므로 비용 민감도 재측정 +6. 몬테카를로(거래 순서 셔플 10,000회) — MDD 95th pctile, §5.3 트리거 빈도 변화 +7. 미국은 수정 전/후 A-B 로 1봉 정렬 효과를 분리 측정한 뒤 walk-forward + +### 8.5 미해결 항목 (알려진 문제, 임의로 "고쳤다"고 판단하지 말 것) + +> 2026-09-27 작업으로 이전 목록 11건 + 추가 발견 결함 다수가 해소되었다. 아래는 **그 과정에서 새로 드러난 것과 의도적으로 남긴 것**이다. +> 전체 유닛 스위트는 `559 passed / 0 failed`(11.8초, 소켓 차단 하) 상태다. + +**백테스트 (최우선)** +1. **기존 백테스트 산출물 전부 폐기 상태 — 재실행 필요.** §8.4 의 최소 요건 7개를 갖춘 새 산출물이 나오기 전까지 **어떤 파라미터도 "검증됐다"고 말할 수 없다.** NAS(DB) 복구가 선행 조건. +2. `RANGE_TRADE_ENABLED` / `MEAN_REVERSION_BEAR_V2_ENABLED` 는 **백테스트 통과 전 True 금지**. 각 파일 docstring 에 통과 조건이 기재되어 있다. BEAR v2 는 "반전캔들 없이 %B+거래량으로 통과"가 발화의 53% 이므로 그 버킷만 분리 집계해 PF < 1.0 이면 `_W_REVERSAL` 을 필수화하거나 `_MIN_SCORE` 를 0.75 로 올려야 한다. +3. 박스권 전략이 **스케줄러에 배선되어 있지 않다.** 배선 위치는 `swing/scheduler.py` 의 MDD/`AUTO_TRADE_ENABLED` 게이트 직후(기존 step 7 은 추천 데이터 존재를 전제하므로 SIDEWAYS 에서 도달하지 못한다). `swing/exit_service.py` 에 `RANGE_TRADE` 전용 청산 분기도 없어 현재는 평균회귀 청산으로 처리된다 — 박스 상단 익절·박스 이탈 즉시 손절이 적용되지 않는다. + +**미국 전략 (`US_TRADING_ENABLED=False` 라 당장 위험은 없으나 켜기 전 필수)** +4. **`us_backtest_engine.py` 는 import 자체가 불가하다.** `check_us_sell_signal` 을 import 하는데 `us_stock_strategy.py` 에 그 함수가 **없다**(`check_us_buy_signal` 만 존재). 즉 **미국 백테스트는 실행된 적이 없다.** +5. `us_backtest_engine.US_COMMISSION_RATE = 0.001`(0.1%) vs `config.US_COMMISSION_RATE = 0.0`(0%) — 백테스트와 실전의 수수료가 다르다. §8.4 의 "백테스트가 실전과 다르다" 계열이 미국 쪽에도 있다. +6. `US_STOP_LOSS_PCT` 는 **음수 규약**(-7.0)이다. 국내 `STOP_LOSS_PERCENT`(7.0 양수)와 다르며, `us_backtest_engine.py:581` 이 `pnl_pct <= US_STOP_LOSS_PCT` 로 음수 그대로 비교한다. **부호를 바꾸면 백테스트 손절이 "수익률 ≤ +7%" 로 뒤집힌다.** +7. `us_market_scheduler.US_DEFAULT_FX_RATE`(1350.0)는 **주문 수량 결정 입력**이다(표시값이 아니다). `buy_budget_krw / exchange_rate → quantity`. 잔고 조회 실패 또는 "USD 자산 0"(신규 계좌) 경로에서 발동하며, 실환율 1450 에 1350 을 쓰면 약 7% 과다 주문이 된다. +8. `us_market_scheduler.py` 에 남은 하드코딩: 시장 시간(`dtime(9,30)/(16,0)/…`), 현금 하한 500,000, 최소 주문 $100, 지정가 마진 1.005/0.995, 총자산 폴백 10,000,000. `us_stock_strategy.check_us_exit_signal` 의 인라인 리터럴(`rsi >= 65`, `bb_upper * 0.99`, `sell_pct=0.50`, `remaining_pct > 0.25`). `round(buy_price * 0.93, 2)` 는 `1+(-7.0/100)=0.9299999999999999` 와 **부동소수 비트가 달라** 의도적으로 리터럴을 유지했다. + +**지표 정확성 (전부 SL/신호를 움직이므로 백테스트 동반 필수)** +9. **`atr_sma` 는 TR 의 단순평균이며 표준 Wilder ATR 이 아니다.** 바꾸면 스윙 SL(`진입가−2.5×ATR`)·트레일링(`고점−1.5×ATR`)·DT SL 이 전부 이동한다. 비교 지표는 CAGR 이 아니라 **손절 체결 건수·평균 손절폭·whipsaw 비율**. +10. `determine_adaptive_mode()` 의 ATR 백분위가 `closes[:i]`(당일 제외) 기준으로 1봉 지연. 백분위라 영향이 작을 것으로 보이나 확인되지 않았다. +11. `run_backtest.calculate_adx_simple` 은 본체가 `adx_wilder` 위임으로 통일됐으나 **폴백 계약이 달라 함수 2개로 남아 있다**(전자는 `float`+20.0 폴백, 후자는 `Optional[float]`). +12. `us_backtest_engine._compute_sma` 미통합(중복 아님, 유일 구현). `_compute_bollinger` 는 봉별 슬라이싱으로 O(n²) — 값은 정확하고 성능만 손실. + +**설정·구조** +13. `config.py` 의 `SHORT_TERM_*` 키들은 삭제된 전략의 잔재로 아무 효과가 없다(백테스트 폴백에서만 참조). +14. DT 호출부(`api/endpoints/day_trading.py`, `daytrading/entry_service.py`)는 예산→주수 변환에 float `//` 를 쓰고, 스윙/집행은 `sizing_core.amount_to_quantity`(Decimal)를 쓴다. 이관 시 **극단적 경우 1주 차이**가 날 수 있어 동일성 재검증이 필요하다. `sizing_core.apply_confidence_scale` 도 아직 그쪽에 도입되지 않았다. +15. `scan_range_trading_signals` 가 결과에 `action=="BUY"` 만 확인하고 `success` 를 확인하지 않아 주문 실패건이 섞일 수 있다. +16. `portfolio.py` 의 ActiveTrade **ORM UPDATE** 경로들은 같은 트랜잭션에서 SELECT 한 행의 수정이라 남겨 두었다. 직접 INSERT 는 코드베이스 전체 0건이다. +17. `backend/test_krx.py`(tests/ 밖, 백엔드 루트 스크립트)는 인자 없이 `pytest` 를 실행하면 수집돼 컬렉션 에러를 낸다. `pytest tests/ --ignore=tests/integration` 로 돌리면 무관. `pytest.ini` 에 `testpaths = tests` 로 정리 가능. +18. `naver_finance` 등 **KIS 외 외부 HTTP 경로는 conftest 가 전역 차단하지 않는다**. 현재 소켓 차단 하 전체 통과로 실호출은 없음을 확인했으나, 추가 방어가 필요하면 별도 작업. + +**멀티테넌트** +19. MT 2단계 잔여 — 스케줄러 사용자별 순회, legacy 라우터 격리, `user_id` 를 실제로 payload 에 싣는 호출부 전환, alembic **0013**(NOT NULL + `uq_portfolio_user_stock`). `db_worker` 의 `MultipleResultsFound` 전제조건 1건은 해소됐다. **`MULTI_TENANT_ENABLED=True` 는 계속 금지.** + +*마지막 업데이트: 2026-09-27 — 죽은 코드 2,695줄 제거 / 지표·사이징 단일화(수치 무변경 증명) / 파라미터 3중 소스 정합 + alembic 0012 / 섹터 매핑 44→747종목 / 백테스트를 실전 로직에 정합(ADX 14배 발산·RSI SMA↔Wilder·1봉 지연) / 미국 상수 13개 config 편입 / 테스트 실 KIS 호출 차단(conftest 신설) / 유닛 스위트 559 passed·0 failed* diff --git a/README.md b/README.md index 5c1579d..06fb532 100644 --- a/README.md +++ b/README.md @@ -28,9 +28,32 @@ auto-stock/ ├── backend/ # FastAPI 백엔드 ├── mcp-server/ # MCP 서버 ├── docker/ # Docker 설정 +├── deploy/ # NAS 배포 +├── docs/ # 문서 (아래 목록) └── scripts/ # 배포 스크립트 ``` +## 문서 + +작업 규칙과 전략 명세는 **`CLAUDE.md`** 가 정본이다. 파라미터 값을 확인할 때는 §0(파라미터 우선순위)을 먼저 읽을 것. + +| 문서 | 내용 | +|------|------| +| [CLAUDE.md](./CLAUDE.md) | **작업 규칙·전략 명세·미해결 항목 (정본)** | +| [docs/INSTALLATION.md](docs/INSTALLATION.md) | 설치 | +| [docs/USER_GUIDE.md](docs/USER_GUIDE.md) | 사용법 | +| [docs/PROJECT_STRUCTURE.md](docs/PROJECT_STRUCTURE.md) | 코드 구조 | +| [docs/backend_guide.md](docs/backend_guide.md) | 백엔드 가이드 | +| [docs/API_DOCS.md](docs/API_DOCS.md) | API 명세 | +| [docs/API_SAFE_OPERATION.md](docs/API_SAFE_OPERATION.md) | API 안전 운영 | +| [docs/RISK_MANAGEMENT_API.md](docs/RISK_MANAGEMENT_API.md) | 리스크 API | +| [docs/BATCH_GUIDE.md](docs/BATCH_GUIDE.md) | 배치 분석 (cron 3개 잡) | +| [deploy/NAS-SETUP-GUIDE.md](deploy/NAS-SETUP-GUIDE.md) | NAS 배포 | +| [docs/multi_tenant_design.md](docs/multi_tenant_design.md) | 멀티테넌트 설계 | +| [docs/multi_tenant_phase1_review.md](docs/multi_tenant_phase1_review.md) | 멀티테넌트 1단계 검수 | + +이력 참고용(현행 아님): [docs/REFACTORING_PLAN.md](docs/REFACTORING_PLAN.md)(폐기), [docs/AUTOTRADE_DIAGNOSIS.md](docs/AUTOTRADE_DIAGNOSIS.md), [docs/SPEED_OPTIMIZATION.md](docs/SPEED_OPTIMIZATION.md) + ## 빠른 시작 ```bash diff --git a/backend/alembic/versions/0012_refresh_strategy_config_defaults.py b/backend/alembic/versions/0012_refresh_strategy_config_defaults.py new file mode 100644 index 0000000..b8a6cfc --- /dev/null +++ b/backend/alembic/versions/0012_refresh_strategy_config_defaults.py @@ -0,0 +1,154 @@ +"""refresh stale strategy_configs parameter values (data-only migration) + +Revision ID: 0012 +Revises: 0011 +Create Date: 2026-09-27 + +[배경] +`strategy_config_service.auto_init()` 은 `on_conflict_do_nothing(index_elements=["key"])` +이므로 이미 존재하는 행의 `value` / `default_value` / `min_value` / `max_value` 를 +절대 갱신하지 않는다. 또한 `reset_to_default()` 는 `cfg.value = cfg.default_value`, +즉 **DB 컬럼** `default_value` 를 사용한다. +따라서 코드의 `DEFAULT_PARAMS` 를 수정해도 DB에 행이 있는 한 옛 값이 계속 런타임에 +적용된다. 이 마이그레이션이 그 간극을 메운다. + +[갱신 규칙] +- `default_value` (및 MEAN_REVERSION_RSI_THRESHOLD 의 `max_value`) 는 코드에서 파생된 + 값이므로 **항상** 새 값으로 덮어쓴다. +- `value` 는 **옛 기본값과 수치적으로 동일할 때만** 새 값으로 갱신한다. + 운영자가 UI 로 직접 바꾼 값(옛 기본값과 다른 값)은 보존한다. +- 모든 값 컬럼이 `String` 이므로 `"0.10"` vs `"0.1"`, `"-6.0"` vs `"-6"` 같은 표기 + 차이를 흡수하기 위해 `numeric` 캐스팅 후 비교한다. 숫자로 파싱 불가한 값은 + 정규식 가드로 제외해 캐스팅 예외를 방지한다. +- 해당 key 의 행이 없으면 아무것도 하지 않는다 (`auto_init` 이 새 값으로 seed 한다). + +[downgrade 한계] +upgrade() 가 "실제로 바꾼 행" 은 별도 저장 없이 식별할 수 없다. downgrade() 는 +대칭 규칙(값이 새 기본값과 수치적으로 같을 때만 옛 값으로 되돌림 + default_value/ +max_value 는 항상 옛 값 복원)을 적용한다. 그 결과 **운영자가 UI 로 새 기본값과 +동일한 값을 직접 설정했던 경우, downgrade 시 그 설정이 옛 값으로 되돌아간다.** +이는 데이터 전용 마이그레이션의 본질적 한계이며 스키마에는 영향이 없다. + +스키마 변경(DDL)은 전혀 없다. +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = "0012" +down_revision = "0011" +branch_labels = None +depends_on = None + + +# (key, 옛 기본값, 새 기본값) +# 값은 모두 문자열 컬럼(strategy_configs.value / .default_value)에 저장되는 표기이며, +# 비교는 numeric 캐스팅 후 수행하므로 표기 차이는 문제되지 않는다. +_PARAM_UPDATES = [ + ("ANALYSIS_STOCK_COUNT", "150", "300"), + ("DAY_TRADE_CAPITAL_RATIO", "0.25", "0.30"), + ("DAY_TRADE_MAX_POSITIONS", "3", "5"), + ("DAY_TRADE_ORB_STOP_LOSS_PCT", "0.02", "0.03"), + ("DAY_TRADE_PER_STOCK_RATIO", "0.10", "0.06"), + ("DAY_TRADE_REVERSION_STOP_LOSS_PCT", "0.025", "0.035"), + ("DAY_TRADE_TRAILING_TRIGGER", "0.015", "0.01"), + ("MEAN_REVERSION_RSI_THRESHOLD", "30.0", "46.0"), + ("SHORT_TERM_VOLUME_MULTIPLIER", "1.5", "1.3"), + ("STOP_LOSS_PERCENT", "-6.0", "-7.0"), + # [L2 2026-09-27] 연속 손실 진입금지 기간: 30거래일 ≈ 42캘린더일로 변경됐으나 + # 기존 DB 행에는 "30" 이 남아 UI 표시/reset_to_default() 가 코드(settings=42)와 어긋난다. + # strategy_config_service.DEFAULT_PARAMS: ("RISK", ..., "int", "42", min="1", max="180") + # → min/max 범위(1~180)가 42를 포함하므로 max_value 갱신은 불필요하다. + ("CONSECUTIVE_LOSS_BAN_DAYS", "30", "42"), +] + +# max_value 도 함께 넓혀야 하는 키: (key, 옛 max, 새 max) +_MAX_VALUE_UPDATES = [ + ("MEAN_REVERSION_RSI_THRESHOLD", "50", "60"), +] + +_TABLE = "strategy_configs" + +# 숫자로 안전하게 캐스팅 가능한 문자열만 통과시키는 가드 +_NUMERIC_GUARD = r"^\s*[+-]?[0-9]+(\.[0-9]+)?\s*$" + +# value 는 "기존 값이 기대한 기본값과 수치적으로 같을 때만" 갱신 +_UPDATE_VALUE_SQL = sa.text( + """ + UPDATE strategy_configs + SET value = :new_value + WHERE key = :key + AND value ~ :guard + AND CAST(trim(value) AS numeric) = CAST(:expected AS numeric) + AND CAST(trim(value) AS numeric) <> CAST(:new_value AS numeric) + """ +) + +# default_value 는 코드 파생값이므로 무조건 갱신 (불필요한 write 는 회피) +_UPDATE_DEFAULT_SQL = sa.text( + """ + UPDATE strategy_configs + SET default_value = :new_value + WHERE key = :key + AND default_value IS DISTINCT FROM :new_value + """ +) + +_UPDATE_MAX_SQL = sa.text( + """ + UPDATE strategy_configs + SET max_value = :new_max + WHERE key = :key + AND max_value IS DISTINCT FROM :new_max + """ +) + + +def _has_table(conn, table: str) -> bool: + """0011_backfill_legacy_owner._has_table 와 동일한 가드 패턴.""" + return conn.execute(sa.text( + "SELECT 1 FROM information_schema.tables WHERE table_name=:t" + ), {"t": table}).fetchone() is not None + + +def _apply(pairs, max_pairs): + """pairs: (key, expected_old_value, target_value) 순서로 value/default_value 갱신.""" + conn = op.get_bind() + + # [M2 2026-09-27] `strategy_configs` 는 alembic 이 만들지 않고 앱 기동 시 + # `Base.metadata.create_all` 로만 생성된다. 그런데 배포 순서는 + # "alembic upgrade head → 앱 기동" 이므로 신규 볼륨에서는 이 테이블이 아직 없다. + # 가드 없이 UPDATE 하면 UndefinedTableError → 마이그레이션 전체 롤백 → 배포 중단. + # 테이블이 없으면 조용히 no-op (앱 기동 시 auto_init 이 새 값으로 seed 한다). + if not _has_table(conn, _TABLE): + print(f"[0012] '{_TABLE}' 테이블 없음 — 데이터 갱신 스킵 (auto_init 이 새 값으로 seed)") + return + + for key, expected, target in pairs: + # 1) 운영자가 손대지 않은 행의 value 만 갱신 (default_value 갱신보다 먼저!) + conn.execute( + _UPDATE_VALUE_SQL, + {"key": key, "expected": expected, "new_value": target, "guard": _NUMERIC_GUARD}, + ) + # 2) default_value 는 항상 코드 값으로 동기화 (reset_to_default 가 이 컬럼을 사용) + conn.execute(_UPDATE_DEFAULT_SQL, {"key": key, "new_value": target}) + + for key, new_max in max_pairs: + conn.execute(_UPDATE_MAX_SQL, {"key": key, "new_max": new_max}) + + +def upgrade() -> None: + _apply( + [(key, old, new) for key, old, new in _PARAM_UPDATES], + [(key, new_max) for key, _old_max, new_max in _MAX_VALUE_UPDATES], + ) + + +def downgrade() -> None: + # 대칭 복원: 새 기본값과 동일한 value 만 옛 값으로 되돌리고, + # default_value / max_value 는 옛 값으로 복원한다 (docstring 의 한계 참조). + _apply( + [(key, new, old) for key, old, new in _PARAM_UPDATES], + [(key, old_max) for key, old_max, _new_max in _MAX_VALUE_UPDATES], + ) diff --git a/backend/app/api/endpoints/backtest.py b/backend/app/api/endpoints/backtest.py index 3f5f7a2..649c29f 100644 --- a/backend/app/api/endpoints/backtest.py +++ b/backend/app/api/endpoints/backtest.py @@ -28,6 +28,7 @@ from app.services.stock_universe import StockUniverse, StockFilter from app.services.ml_ensemble import get_ensemble from app.services.feature_engineering import FEATURE_COLUMNS from app.services.strategy_config_service import strategy_cfg +from app.core.config import settings router = APIRouter() logger = logging.getLogger(__name__) @@ -70,12 +71,14 @@ async def _get_preset(preset_key: str) -> Optional[Dict[str, Any]]: if preset_key == "current": await strategy_cfg._refresh() params = { - "MEAN_REVERSION_STOP_LOSS_PCT": strategy_cfg.get("STOP_LOSS_PERCENT", -6.0), - "MEAN_REVERSION_TAKE_PROFIT_PCT": strategy_cfg.get("TAKE_PROFIT_PERCENT", 12.0), - "MAX_TOTAL_POSITIONS": strategy_cfg.get("MAX_HOLDINGS_COUNT", 5), - "MEAN_REVERSION_RSI_THRESHOLD": strategy_cfg.get("MEAN_REVERSION_RSI_THRESHOLD", 30.0), - "RSI_OVERBOUGHT_LEVEL": strategy_cfg.get("RSI_OVERBOUGHT_LEVEL", 70.0), - "ATR_STOP_MULTIPLIER": strategy_cfg.get("ATR_STOP_MULTIPLIER", 2.5), + # [2026-09-27] 하드코딩 폴백 → config.py 정합 (백테스트가 실거래 기본값을 모사하도록) + # 우선순위: strategy_cfg(DB) > settings(config.py). 하드코딩 금지. + "MEAN_REVERSION_STOP_LOSS_PCT": strategy_cfg.get("STOP_LOSS_PERCENT", -abs(settings.STOP_LOSS_PERCENT)), # 옛 폴백 -6.0 + "MEAN_REVERSION_TAKE_PROFIT_PCT": strategy_cfg.get("TAKE_PROFIT_PERCENT", settings.TAKE_PROFIT_PERCENT), # 옛 폴백 12.0 → config 5.0 + "MAX_TOTAL_POSITIONS": strategy_cfg.get("MAX_HOLDINGS_COUNT", settings.MAX_HOLDINGS_COUNT), # 옛 폴백 5 → config 20 + "MEAN_REVERSION_RSI_THRESHOLD": strategy_cfg.get("MEAN_REVERSION_RSI_THRESHOLD", settings.MEAN_REVERSION_RSI_THRESHOLD), # 옛 폴백 30.0 → config 46.0 + "RSI_OVERBOUGHT_LEVEL": strategy_cfg.get("RSI_OVERBOUGHT_LEVEL", settings.RSI_OVERBOUGHT_LEVEL), # 70.0 동일 + "ATR_STOP_MULTIPLIER": strategy_cfg.get("ATR_STOP_MULTIPLIER", settings.ATR_STOP_MULTIPLIER), # 2.5 동일 } return {"name": "현재 설정", "params": params} return _STRATEGY_PRESETS.get(preset_key) diff --git a/backend/app/api/endpoints/day_trading.py b/backend/app/api/endpoints/day_trading.py index ca3c557..12e6fc8 100644 --- a/backend/app/api/endpoints/day_trading.py +++ b/backend/app/api/endpoints/day_trading.py @@ -562,7 +562,15 @@ async def _scan_signals( if position_size < signal.entry_price: continue # 최소 1주도 살 수 없으면 스킵 - quantity = max(1, int(position_size // signal.entry_price)) + # [항목10 2026-09-27] max(1, ...) 제거 — 예산 0/음수에서도 1주를 + # 매수해 사이징 한도(일일 DT 예산·현금)를 무시하던 문제. + quantity = int(position_size // signal.entry_price) if signal.entry_price > 0 else 0 + if quantity <= 0: + logger.info( + f"⛔ 데이트레이딩 진입 스킵 [{code}]: 예산 부족 " + f"(예산 {position_size:,.0f}원 / 단가 {signal.entry_price:,.0f}원 → 0주)" + ) + continue results.append({"signal": signal, "quantity": quantity}) logger.info( f"🎯 데이트레이딩 신호 [{code}] {signal.strategy}: " @@ -928,7 +936,20 @@ async def execute_day_trade(req: ExecuteRequest): position_size = calculate_day_trade_position_size( total_assets, available_cash, current_day_trade_amount ) - quantity = max(1, int(position_size // signal.entry_price)) + # [항목10 2026-09-27] max(1, ...) 제거 — 예산이 0이어도 1주를 사던 문제. + quantity = int(position_size // signal.entry_price) if signal.entry_price > 0 else 0 + if quantity <= 0: + logger.info( + f"⛔ 데이트레이딩 진입 스킵 [{code}]: 예산 부족 " + f"(예산 {position_size:,.0f}원 / 단가 {signal.entry_price:,.0f}원 → 0주)" + ) + return { + "status": "no_budget", + "message": ( + f"데이트레이딩 예산이 부족합니다 " + f"(가용 예산 {position_size:,.0f}원, 단가 {signal.entry_price:,.0f}원)." + ), + } if req.dry_run: return { diff --git a/backend/app/api/endpoints/portfolio.py b/backend/app/api/endpoints/portfolio.py index dd19a8c..b1c75c7 100644 --- a/backend/app/api/endpoints/portfolio.py +++ b/backend/app/api/endpoints/portfolio.py @@ -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) diff --git a/backend/app/core/config.py b/backend/app/core/config.py index 559f443..03125fa 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -142,6 +142,12 @@ class Settings(BaseSettings): MAX_HOLDINGS_COUNT: int = 20 # 동시 보유 최대 20종목 (섹션 5.1) LEVERAGE_ETF_MAX_RATIO: float = 0.20 # 레버리지/인버스 ETF 최대 20% (섹션 5.1) + # [HIGH-4] 섹터 미매핑 종목에 "UNKNOWN" 단일 버킷으로 섹터당 최대 3종목 한도를 적용할지. + # True로 켜면 §5.1 섹터캡이 전 종목에 적용되지만, _STOCK_CODE_SECTOR_MAP 커버리지가 + # 낮은 현재 상태에서는 총 보유가 ~3종목으로 묶여 MAX_HOLDINGS_COUNT(20)에 도달할 수 없다. + # → 매핑 테이블 확충 + 백테스트 검증 후 활성화할 것 (correlation_filter 참고). + SECTOR_CAP_UNMAPPED_ENABLED: bool = False + # 자본 배분 글로벌 가드 — 전 전략 합산 현금 사용 상한 MAX_TOTAL_CAPITAL_USAGE: float = 0.95 # 전체 자본의 95%까지만 투자 (5% 현금 보유) @@ -162,6 +168,18 @@ class Settings(BaseSettings): MDD_PAUSE_THRESHOLD: float = -10.0 # -10% MDD: 신규진입 중단 + 30% 축소 MDD_FORCE_THRESHOLD: float = -20.0 # -20% MDD: 50% 강제 축소 + # [항목7 2026-09-27] MDD 갱신 실패 시 fail-safe (신규 진입 차단) + # swing/scheduler 의 mdd_manager.update() 가 peak/drawdown 을 갱신하는 유일한 실효 + # 경로다. KIS 잔고 조회가 계속 실패하면 peak/drawdown 이 그 시점에 얼어붙고 + # -10%/-20% 게이트는 "무증상으로" 영구 미발동한다 → 리스크 게이트가 눈이 먼 상태에서 + # 계속 진입하게 되고, 실제 낙폭이 -20% 를 넘어가도 추가 진입이 무제한 허용된다. + # 거래 기회를 잃는 손실(<진입 몇 건)보다 게이트 없는 매매의 손실이 압도적으로 크므로 + # 기본값을 True(차단)로 둔다. 청산은 절대 막지 않는다(can_open_new_position 한정). + MDD_BLIND_BLOCK_ENTRY: bool = True + # 몇 회 연속 갱신 실패부터 차단할지. 1~2회는 KIS 순간 오류로 흔하므로 + # 알림 임계값(swing/scheduler.MDD_UPDATE_FAILURE_ALERT)과 동일한 5회로 맞춘다. + MDD_BLIND_FAILURE_THRESHOLD: int = 5 + # 가격 필터 (섹션 5.4) MIN_PRICE_KRW: int = 5000 # 국내 5,000원 이하 매수 금지 MIN_PRICE_USD: float = 4.0 # 미국 $4 이하 매수 금지 @@ -175,7 +193,10 @@ class Settings(BaseSettings): # 연속 손실 관리 (섹션 5.5) CONSECUTIVE_LOSS_BAN_COUNT: int = 3 # 3회 연속 손실 - CONSECUTIVE_LOSS_BAN_DAYS: int = 30 # 30거래일 진입 금지 + # [HIGH-3 수정] 기존 30은 어디서도 쓰이지 않는 죽은 값이었고, 실제 ban 기간은 + # loss_tracker의 하드코딩 42캘린더일이었다. 문서 §5.1("3연패 → 42캘린더일 ≈ 30거래일")과 + # 실동작에 맞춰 42(캘린더일)로 정정하고, loss_tracker가 이 값을 읽도록 연결했다. + CONSECUTIVE_LOSS_BAN_DAYS: int = 42 # 진입 금지 기간(캘린더일) — 30거래일 ≈ 42캘린더일 # ═════════════════════════════════════════════════════════════════════════════ # 평균회귀(스윙) 전략 (섹션 6) @@ -199,6 +220,39 @@ class Settings(BaseSettings): MEAN_REVERSION_ADX_STRONG_THRESHOLD: float = 15.0 # ADX < 15 → 추세 약함 MEAN_REVERSION_ADX_STRONG_REDUCE: float = 30.0 # ADX > 30 → 추세 강함 + # ── BEAR 진입 점수제 v2 (§8.5-9) ────────────────────────────────────────── + # 기존 BEAR 분기는 RSI<35 AND %B≤0.20~0.30 AND 거래량>1.5×20MA AND 반전캔들의 + # 4중 AND 여서 사실상 미발화했다. v2는 %B/거래량/반전캔들을 가중 점수로 바꾸고 + # RSI 반등(rsi>rsi_prev)과 RSI 상한만 필수(AND)로 유지한다. + # ⚠️ 백테스트 미검증 — True 전환 금지. + # (1) 실전과 동일한 지표(Wilder RSI/ADX, indicators_core)로 재산출한 백테스트, + # (2) BEAR 국면 구간만 분리한 승률·Profit Factor·MDD, + # (3) 최대 연속 손실 횟수가 기존 경로 대비 악화되지 않음 + # — 3개를 모두 통과한 뒤에만 켠다. 하락장 진입을 늘리는 변경이므로 특히 위험. + MEAN_REVERSION_BEAR_V2_ENABLED: bool = False # 기본 False = 기존 4중 AND 경로 그대로 + # RSI 상한(필수 AND): 기존 임계값(35) + relax, 단 hard cap(40) 이하. + # 근거: 40은 BULL 진입 임계(46)보다 낮게 유지해 "하락장은 더 보수적"이라는 원칙을 깨지 않는다. + MEAN_REVERSION_BEAR_V2_RSI_RELAX: float = 5.0 + MEAN_REVERSION_BEAR_V2_RSI_HARD_CAP: float = 40.0 + # 진입 임계 점수. 가중치 합은 %B 0.40 + 반전 0.35 + 거래량 0.25 = 1.00 (+RSI깊이 보너스 0.15). + # 0.60 = "핵심 조건(%B) + 보조 1개" 또는 "보조 2개 + 깊은 과매도" 수준. + MEAN_REVERSION_BEAR_V2_MIN_SCORE: float = 0.60 + # %B: 평균회귀 엣지의 직접 근거(하단 이탈 후 되돌림) → 최대 가중치. + MEAN_REVERSION_BEAR_V2_W_PCT_B: float = 0.40 + # 반전캔들: 낙하 중 진입 방지 대리변수. RSI 반등과 상호보완이나 도지/데이터부족 시 + # 판정 불가라 필수화하면 다시 미발화한다 → 높은 가중치의 점수 항목으로만 둔다. + MEAN_REVERSION_BEAR_V2_W_REVERSAL: float = 0.35 + # 거래량: 확인 지표. 하락장 대량 거래는 투매(추가 하락)일 수도 있어 단독 예측력이 약함 → 최소 가중치. + MEAN_REVERSION_BEAR_V2_W_VOLUME: float = 0.25 + # RSI 깊이 보너스: RSI<20 → 만점, RSI<25 → 절반. 깊은 과매도의 반등 확률 프리미엄. + MEAN_REVERSION_BEAR_V2_W_RSI_DEPTH: float = 0.15 + # %B 부분점수 배수: pct_b <= pct_b_max*1.6 이면 절반 점수. + MEAN_REVERSION_BEAR_V2_PCT_B_RELAX_MULT: float = 1.6 + # %B 하드 상한(필수 AND 성격의 잔존 하한): BB 중심선(0.5) 위는 평균회귀 진입이 아니다. + MEAN_REVERSION_BEAR_V2_PCT_B_HARD_CAP: float = 0.50 + # 거래량 중간 등급 배수: 1.2×20MA 이상이면 부분 점수(0.6배). + MEAN_REVERSION_BEAR_V2_VOLUME_MID_MULT: float = 1.2 + # ═════════════════════════════════════════════════════════════════════════════ # 단기(15분봉) 전략 (섹션 7.2) # ═════════════════════════════════════════════════════════════════════════════ @@ -259,6 +313,41 @@ class Settings(BaseSettings): DAY_TRADE_TRAILING_MIN_HOLD_SEC: int = 300 # 트레일링 활성화 최소 보유 시간 (초, 5분) DAY_TRADE_MIN_STOP_LOSS_PCT: float = 0.02 # 최소 손절폭 -2.0% (기존 1.0% → 노이즈 손절 방지) + # ═════════════════════════════════════════════════════════════════════════════ + # 박스권(SIDEWAYS) 레인지 매매 (CLAUDE.md §8.5 항목 9) + # ═════════════════════════════════════════════════════════════════════════════ + # ⚠️ **백테스트 미검증 — True 로 바꾸지 말 것 (CLAUDE.md §8.1-4).** + # 이 전략은 SIDEWAYS 국면 전용이며, market_analyzer 의 SIDEWAYS 전면 차단(§6.1)을 + # 의도적으로 우회한다. False 인 동안 swing/entry_service.scan_range_trading_signals() + # 는 함수 진입 즉시 [] 를 반환하므로 기존 동작에 어떤 영향도 없다. + # 켜기 전 통과해야 할 검증: SIDEWAYS 표본 ≥ 60거래일 확보 / in-sample 승률 ≥ 50% · + # PF ≥ 1.3 · MDD ≥ -8% / walk-forward 4구간 전부 PF ≥ 1.0 · MDD -10% 초과 없음 / + # 파라미터 민감도 그리드 과반 통과. 상세는 range_trading_strategy.py docstring 참조. + RANGE_TRADE_ENABLED: bool = False + RANGE_TRADE_LOOKBACK_DAYS: int = 20 # 박스 판정 구간(일). SIDEWAYS 기울기 비교 기간(20일)과 정합 + RANGE_TRADE_MIN_WIDTH_PCT: float = 6.0 # 박스 최소 폭. 왕복 이익이 거래비용(왕복 ~0.5%)+손절폭을 못 넘으면 무의미 + RANGE_TRADE_MAX_WIDTH_PCT: float = 20.0 # 박스 최대 폭. 이상이면 박스가 아니라 추세/고변동 구간 + RANGE_TRADE_TOUCH_TOLERANCE_PCT: float = 2.0 # 상·하단 접촉 허용 오차. 정확히 같은 값을 찍는 경우는 드뭄 + RANGE_TRADE_MIN_TOUCHES: int = 2 # 상단·하단 각 최소 접촉 봉 수 (지지/저항 확인) + RANGE_TRADE_MIN_ALTERNATIONS: int = 2 # 상단↔하단 접촉 교대 최소 횟수 = 레인지 내 왕복 이력 + RANGE_TRADE_MAX_ADX: float = 20.0 # ADX 상한. SIDEWAYS 히스테리시스(진입 18/해제 22)의 중앙값 + RANGE_TRADE_ENTRY_ZONE_PCT: float = 25.0 # 박스 하단 25% 구간에서만 진입 (지지대 매수) + RANGE_TRADE_BB_PCT_B_MAX: float = 0.25 # %B 상한. 평균회귀 BULL(0.20~0.35)보다 보수적 중간값 + RANGE_TRADE_RSI_ENTRY: float = 40.0 # 진입 RSI. 평균회귀 BULL(46)보다 타이트 — 미검증 전략이라 보수적 + RANGE_TRADE_VOLUME_RATIO: float = 1.0 # 거래량 ≥ 20일 평균 × 배수 (지지 반등 수급 확인, 과도한 요구는 표본 소멸) + RANGE_TRADE_RSI_PARTIAL_EXIT: float = 55.0 # 1차 익절 RSI (§8.2 MEAN_REVERSION_RSI_EXIT_THRESHOLD 와 동일) + RANGE_TRADE_RSI_FULL_EXIT: float = 65.0 # 전량 청산 RSI. 박스 상단에서 오래 버티지 않음 → 스윙(70)보다 빠르게 + RANGE_TRADE_EXIT_ZONE_PCT: float = 80.0 # 박스 상단 20% 진입 시 전량 익절 (상단 정확히 기다리면 미체결 위험) + RANGE_TRADE_BOX_BREAK_PCT: float = 1.0 # 박스 하단 -1% 하향 이탈 → 즉시 전량 손절 (노이즈 여유 1%) + RANGE_TRADE_ATR_STOP_MULTIPLIER: float = 2.0 # ATR 손절 배수. 스윙(2.5)보다 타이트 — 박스 폭이 좁아 여유가 불필요 + RANGE_TRADE_STOP_LOSS_PERCENT: float = 5.0 # 기본 손절 -5%. 스윙(-7%)보다 타이트, 박스 폭 하한(6%)과 정합 + RANGE_TRADE_TRAILING_ATR_MULT: float = 1.5 # 트레일링 = 고점 − 1.5×ATR (§5.2 동일) + RANGE_TRADE_MIN_PROFIT_PCT: float = 0.5 # 익절 최소 수익률 (수수료+세금 ~0.4% 커버) + RANGE_TRADE_MAX_POSITIONS: int = 3 # 동시 보유 상한 (미검증 전략 노출 최소화) + RANGE_TRADE_MAX_CAPITAL_RATIO: float = 0.10 # 스윙 몫(SWING_MAX_CAPITAL_RATIO=0.70) **내부** 비율 → 총자본 7% + RANGE_TRADE_POSITION_MULTIPLIER: float = 0.5 # 종목당 = MAX_POSITION_SIZE(4%) × 0.5 = 2% (미검증 전략) + RANGE_TRADE_SCAN_LIMIT: int = 10 # 사이클당 차트 조회 종목 수 상한 (KIS 레이트 리밋) + # ═════════════════════════════════════════════════════════════════════════════ # 코스닥 스캐너 (섹션 7.1) # ═════════════════════════════════════════════════════════════════════════════ @@ -271,6 +360,31 @@ class Settings(BaseSettings): US_MAX_CAPITAL_RATIO: float = 0.30 # 전체 자본 대비 미국 주식 최대 비중 (30%) US_SCAN_INTERVAL: int = 300 # 미국장 스캔 간격 (초, 기본 5분) + # ── [§8.5-8 상수 편입 2026-09-27] us_stock_strategy / us_market_scheduler ── + # 아래 키는 두 모듈에 하드코딩돼 있던 숫자를 **값 변경 없이** 그대로 옮긴 것이다 + # (CLAUDE.md §8.1-4: 백테스트 없이 전략값 변경 금지 — 이번 작업은 "출처 단일화"뿐). + # US_MAX_CAPITAL_RATIO(위)는 모듈 상수와 값이 완전히 같았으므로 이 키를 정본으로 쓰고 + # 모듈 상수는 제거했다. + # 국내 키와 값이 겹치는 것(US_MIN_PRICE_USD/US_STOP_LOSS_PCT/US_ATR_STOP_MULT)은 + # 국내 키를 재사용하지 않고 US 전용 키를 새로 뒀다 — 미국은 거래비용·틱·변동성이 + # 달라 앞으로 갈라질 값이고, 공유하면 국내 튜닝이 미국에 조용히 전파된다. + US_MAX_POSITION_SIZE: float = 0.05 # 미국 종목당 최대 5% (국내 MAX_POSITION_SIZE=0.04 와 별개, 의도적으로 더 큼) + US_RSI_BUY_THRESHOLD: float = 35.0 # RSI(14) 매수 상한 (국내 평균회귀 46 대비 보수적) + US_BB_LOWER_MARGIN_LOW: float = 0.97 # 진입 허용 구간 하한 = BB하단 × 0.97 (-3%) + US_BB_LOWER_MARGIN_HIGH: float = 1.02 # 진입 허용 구간 상한 = BB하단 × 1.02 (+2%) + US_MIN_PRICE_USD: float = 4.0 # 페니스탁 필터(이 값 "이하" 진입 불가). 현재 MIN_PRICE_USD(4.0)와 값 동일 — 별 키로 분리 보관 + US_STOP_LOSS_PCT: float = -7.0 # 기본 손절 **음수 규약**(국내 STOP_LOSS_PERCENT=7.0 양수와 부호 규약 다름, 절대값은 현재 동일). us_backtest_engine 이 `pnl_pct <= US_STOP_LOSS_PCT` 로 음수 그대로 소비하므로 음수 유지 + US_ATR_STOP_MULT: float = 2.5 # ATR 손절 배수. 현재 ATR_STOP_MULTIPLIER(2.5)와 값 동일 — 별 키로 분리 보관 + US_FIRST_EXIT_RSI: float = 55.0 # 1차 익절 RSI 기준 + US_FIRST_EXIT_SELL_PCT: float = 0.30 # 1차 익절 매도 비율 30% + US_TRAILING_STOP_PCT: float = 0.04 # 트레일링 스탑 고점 대비 -4% + US_MAX_NEW_ENTRIES_PER_CYCLE: int = 3 # 스캔 사이클당 신규 진입 종목 수 상한 + # 환율 폴백: KIS 해외잔고로 환율을 역산할 수 없을 때(잔고 조회 실패, 또는 USD 평가액+예수금이 0) + # 사용된다. 이 값은 포지션 사이징(KRW예산 ÷ 환율 → USD → 수량)과 TradeHistory.fx_rate 에 + # 그대로 들어가므로 **실제 주문 수량·기록 금액에 직접 영향**을 준다. 실환율과 크게 벌어지면 + # 주문 수량이 그만큼 어긋난다(예: 실제 1450 인데 1350 사용 → 예산 대비 약 7% 과다 주문). + US_DEFAULT_FX_RATE: float = 1350.0 + # ═════════════════════════════════════════════════════════════════════════════ # 필터 & 시장 국면 (섹션 5.4) # ═════════════════════════════════════════════════════════════════════════════ diff --git a/backend/app/realtime/README.md b/backend/app/realtime/README.md index b0c55dc..e0f4900 100644 --- a/backend/app/realtime/README.md +++ b/backend/app/realtime/README.md @@ -2,6 +2,13 @@ WebSocket 기반 실시간 시세 수신 및 이벤트 처리 모듈 +> ⚠️ **아래 "4. 실시간 전략 실행" 예제는 더 이상 동작하지 않습니다 (2026-09-27)** +> 예제가 쓰는 `app.strategy` 패키지(`strategy_manager`, `DayTradingStrategy`)는 죽은 코드로 +> 확인되어 삭제되었습니다. 현재 전략 실행 경로는 `services/swing/scheduler.py` 와 +> `services/daytrading/scheduler.py` 이며, 전략 로직은 `services/mean_reversion_strategy.py`, +> `services/day_trading_strategy.py` 에 있습니다. `CLAUDE.md` §2 참고. + + ## 모듈 구조 ``` diff --git a/backend/app/services/adaptive_strategy.py b/backend/app/services/adaptive_strategy.py index 664061b..941b6d1 100644 --- a/backend/app/services/adaptive_strategy.py +++ b/backend/app/services/adaptive_strategy.py @@ -11,7 +11,7 @@ - 횡보장: 평균회귀 확대, 데이트레이딩 축소 - 급락 후 반등: 공격적 매수, 포지션 확대 -[모드 전환 규칙] CLAUDE.md 섹션 8.5 +[모드 전환 규칙] CLAUDE.md 섹션 8.4 Aggressive: ATR백분위 < 30 AND ADX > 25 AND 5일수익률 > +2% Normal: 기본 Defensive: ATR백분위 > 70 OR 5일수익률 < -3% @@ -146,6 +146,12 @@ class AdaptiveParams: # 백테스트 검증 완료 파라미터 (2023-03~2026-03, 34종목, 듀얼전략) # 수익률 10.29%, CAGR 3.44%, MDD -3.78%, 승률 71.8%, 매매 284회 +# +# [HIGH-2 주의] 아래 position_size(Normal 0.06 / Aggressive 0.07)는 문서·백테스트 +# 기준값이므로 변경하지 않는다. 다만 CLAUDE.md §5.1/§8.2의 종목당 절대 상한은 +# settings.MAX_POSITION_SIZE(4%)이며, 소비 지점(trade_executor._adaptive_position_cap)에서 +# min(_adp.position_size, settings.MAX_POSITION_SIZE)로 클램프된다. +# → 실효 종목당 비중은 어떤 모드에서도 4%를 넘지 않는다. _MODE_PARAMS: Dict[str, Dict[str, Any]] = { TradingMode.AGGRESSIVE.value: { "rsi_entry": 50.0, # 진입 기회 최대화 @@ -298,7 +304,7 @@ def _determine_trading_mode( """모드 전환 규칙 적용 (히스테리시스 포함). [우선순위: Crisis > Defensive > Aggressive > Normal] - CLAUDE.md 섹션 8.5: + CLAUDE.md 섹션 8.4: Crisis: ATR백분위 > 90 OR 5일수익률 < -5% OR MDD < -10% Defensive: ATR백분위 > 70 OR 5일수익률 < -3% Aggressive: ATR백분위 < 30 AND ADX > 25 AND 5일수익률 > +2% @@ -310,7 +316,7 @@ def _determine_trading_mode( """ prev_mode = _current_params.trading_mode if _current_params else TradingMode.NORMAL.value - # Crisis 체크 (최우선) — CLAUDE.md 섹션 8.5 정합 + # Crisis 체크 (최우선) — CLAUDE.md 섹션 8.4 정합 if atr_percentile > 90 or recent_5d_return < -5.0 or mdd_current < -10.0: return TradingMode.CRISIS.value # Crisis 해제: 더 완화된 조건 (히스테리시스) @@ -318,7 +324,7 @@ def _determine_trading_mode( if atr_percentile > 80 or recent_5d_return < -3.0 or mdd_current < -8.0: return TradingMode.CRISIS.value # 아직 위기 유지 - # Defensive 체크 — CLAUDE.md 섹션 8.5 정합 + # Defensive 체크 — CLAUDE.md 섹션 8.4 정합 if atr_percentile > 70 or recent_5d_return < -3.0: return TradingMode.DEFENSIVE.value # Defensive 해제: ATR<60, 5일수익>-1% (히스테리시스) diff --git a/backend/app/services/analysis_db_service.py b/backend/app/services/analysis_db_service.py index 2f9d3c9..711c558 100644 --- a/backend/app/services/analysis_db_service.py +++ b/backend/app/services/analysis_db_service.py @@ -177,7 +177,7 @@ async def queue_bulk_upsert_analysis( # DB 스캐너 — 저장된 분석 결과로 신호 종목 필터링 (API 호출 없음) # ───────────────────────────────────────────────────────────────────────────── async def run_db_scanner(analysis_date: date) -> set[str]: - """배치 DB 기반 스캐너 — KosdaqScanner 와 동일 기준, 추가 API 호출 없음. + """배치 DB 기반 코스닥·코스피 스캐너 (추가 API 호출 없음). 조건: - RSI(14) ≤ 30 (과매도) diff --git a/backend/app/services/correlation_filter.py b/backend/app/services/correlation_filter.py index 6a7a22e..bfcadc8 100644 --- a/backend/app/services/correlation_filter.py +++ b/backend/app/services/correlation_filter.py @@ -26,6 +26,9 @@ from app.core.database import AsyncSessionLocal from app.models.models import StockDailyPrice, Portfolio from app.services.korea_investment_api import korea_investment_api from app.core.json_helpers import _dumps +# [§8.5 항목 8] 섹터 매핑 단일 소스 — 파일 캐시 동기 조회만 사용(네트워크 호출 없음). +# sector_mapper 쪽은 정적 표를 지연 임포트하므로 순환 임포트 문제 없음. +from app.services import sector_mapper logger = logging.getLogger(__name__) @@ -33,6 +36,63 @@ logger = logging.getLogger(__name__) _sector_cache: Dict[str, Tuple[str, float]] = {} # {stock_code: (sector_name, timestamp)} _SECTOR_CACHE_TTL = 3600 # 1시간 +# ─ 섹터 미매핑 종목 집계 (로그 폭주 방지) ───────────────────────────────────── +# [LOW-1 2026-09-27] 미매핑 종목마다 warning 을 남기면 매핑 커버리지(44/760)가 낮아 +# 검증 사이클마다 수십~수백 줄이 쏟아진다. 종목코드를 모듈 수준 set 에 모아두고 +# 주기적으로 1줄 요약 warning 만 남긴다. (가시성 유지 + 노이즈 제거) +_unmapped_sector_codes: set = set() +_UNMAPPED_SUMMARY_EVERY = 50 # 신규 미매핑 종목이 N개 늘어날 때마다 요약 1줄 +_unmapped_summary_last_size = 0 + + +def get_unmapped_sector_codes() -> List[str]: + """섹터 미매핑으로 관측된 종목코드 목록 (읽기 전용 스냅샷, 정렬). + + `_STOCK_CODE_SECTOR_MAP` 보강 대상을 뽑는 용도. 부수효과 없음. + """ + return sorted(_unmapped_sector_codes) + + +def get_sector_coverage(universe_codes: Optional[List[str]] = None) -> Dict: + """섹터 매핑 커버리지 스냅샷 (읽기 전용, 네트워크·DB 호출 없음). + + sector_mapper 파일 캐시 + 정적 표를 합친 매핑 종목 수, 섹터 분포, + (universe_codes 를 주면) 유니버스 대비 커버리지 %를 돌려준다. + `SECTOR_CAP_UNMAPPED_ENABLED` 활성화 판단 근거로 사용. + """ + snapshot = sector_mapper.get_coverage(universe_codes) + snapshot["observed_unmapped"] = len(_unmapped_sector_codes) + return snapshot + + +def _note_unmapped_sector(stock_code: str, cap_unmapped: bool) -> None: + """미매핑 종목을 집계하고, 임계치마다 1줄 요약 warning 을 남긴다. + + - 상세는 항상 debug (플래그 On/Off 무관하게 개별 라인은 debug 로 내린다) + - 요약은 신규 미매핑 종목이 `_UNMAPPED_SUMMARY_EVERY` 개 늘어날 때만 warning + """ + global _unmapped_summary_last_size + + logger.debug( + f"[SECTOR_FILTER] {stock_code}: 섹터 미매핑 — " + f"{'UNKNOWN 버킷으로 섹터캡 적용' if cap_unmapped else '섹터캡 미적용(상관계수 필터에 위임)'}" + ) + + _before = len(_unmapped_sector_codes) + _unmapped_sector_codes.add(stock_code) + _size = len(_unmapped_sector_codes) + if _size == _before: + return # 이미 집계된 종목 — 로그 없음 + + if _size == 1 or (_size - _unmapped_summary_last_size) >= _UNMAPPED_SUMMARY_EVERY: + _unmapped_summary_last_size = _size + _sample = sorted(_unmapped_sector_codes)[:10] + logger.warning( + f"[SECTOR_FILTER] 섹터 미매핑 {_size}종목 (예: {', '.join(_sample)}" + f"{' …' if _size > len(_sample) else ''}) — _STOCK_CODE_SECTOR_MAP 보강 필요. " + f"전체 목록: correlation_filter.get_unmapped_sector_codes()" + ) + # ─ 종목코드 기반 간이 섹터 매핑 테이블 ──────────────────────────────────────── # [수정 7] KIS API는 섹터 정보를 제공하지 않으므로 cross_market_correlation.py의 # CROSS_MARKET_SECTOR_MAP과 동일한 데이터를 활용하여 get_stock_sector()가 @@ -138,6 +198,23 @@ async def get_stock_sector( logger.debug(f"[SECTOR_MAP] {stock_code} -> {sector}") return sector + # 2-b. [§8.5 항목 8 / 2026-09-27] sector_mapper 파일 캐시 조회 + # 정적 표(_STOCK_CODE_SECTOR_MAP, ~44종목)만으로는 유니버스(~760종목)의 6%만 덮여 + # §5.1 섹터캡이 사실상 무력화됐다. sector_mapper 는 네이버 업종 API 수집 결과를 + # ~/.cache/auto-stock/sector_map.json 에 적재하고 **동기 캐시 읽기만** 제공한다. + # → 이 경로에 네트워크·DB I/O 없음 (§3.1 실시간 계층 제약 준수). + # 캐시가 비어 있으면 None → 기존 폴백(DB → 미매핑) 그대로 진행. + if is_domestic: + try: + mapped = sector_mapper.get_sector(stock_code) + except Exception as e: # 캐시 손상 등 — 필터를 죽이지 않는다 + logger.debug(f"[SECTOR_MAP_CACHE] {stock_code} 조회 실패: {e}") + mapped = None + if mapped: + _sector_cache[stock_code] = (mapped, datetime.now().timestamp()) + logger.debug(f"[SECTOR_MAP_CACHE] {stock_code} -> {mapped}") + return mapped + # 3. DB에서 조회 시도 (Portfolio에서 sector 정보) # [수정 8] db 파라미터가 None이면 AsyncSessionLocal로 자체 세션 생성 async def _query_db(session: AsyncSession) -> Optional[str]: @@ -186,22 +263,42 @@ async def check_sector_concentration( """ # 진입 종목의 섹터 조회 sector = await get_stock_sector(stock_code, is_domestic=True) - # [FIX 2026-09-16] 미매핑 종목을 "기타" 한 버킷에 뭉쳐 max_per_sector로 캡하면, - # 섹터 매핑 테이블이 유니버스의 극히 일부(~44종목/수백~수천)만 커버하는 탓에 업종이 - # 서로 무관한 종목들까지 "기타" 3종목 상한에 걸려 MAX_HOLDINGS_COUNT(20) 달성이 - # 구조적으로 불가해진다. 백테스트(walk-forward 전 구간)로 이 완화가 MDD를 키우지 않음을 - # 검증했다. 실제 섹터가 확인된 종목만 섹터캡을 적용하고, 미매핑 종목의 실질 집중통제는 - # 상관계수 필터(0.7)에 위임한다. - if not sector: - return True, None - # 같은 섹터 종목들의 섹터 정보 조회 (미매핑 보유분은 섹터캡 계수에서 제외) + # [HIGH-4 2026-09-27] 미매핑 종목 처리 + # 기존 동작: 섹터 미매핑 종목은 `return True, None`으로 섹터캡을 완전히 우회 → 매핑 + # 테이블(_STOCK_CODE_SECTOR_MAP, ~44종목)이 유니버스의 극히 일부만 덮으므로 사실상 + # "섹터당 3종목" 규칙이 대부분 종목에 적용되지 않았다(§5.1 우회). + # 수정: 미매핑 종목을 단일 "UNKNOWN" 버킷으로 묶어 동일한 max_per_sector 한도를 적용한다. + # + # ⚠ 단, 이 강화는 즉시 켜면 거래를 구조적으로 죽인다: + # 유니버스 대부분이 UNKNOWN이므로 UNKNOWN 한도 3 = 사실상 총 보유 3종목 상한이 되어 + # MAX_HOLDINGS_COUNT(20) 달성이 불가능하다(2026-09-16 완화의 원래 이유, 프로젝트 + # 메모리 "filters-killing-trades" 참고). + # 따라서 스위치(settings.SECTOR_CAP_UNMAPPED_ENABLED, 기본 False)로 제어한다. + # - False(기본): 기존 완화 동작 유지 + 미매핑 종목 집계(요약 warning)로 가시화 + # - True: UNKNOWN 버킷에도 max_per_sector 적용 (매핑 테이블 확충 후 활성화 권장) + _UNKNOWN_SECTOR = "UNKNOWN" + _cap_unmapped = bool(getattr(settings, "SECTOR_CAP_UNMAPPED_ENABLED", False)) + + if not sector: + # 매핑 테이블 보강이 가능하도록 종목코드를 집계한다 (요약 warning, 상세는 debug). + _note_unmapped_sector(stock_code, _cap_unmapped) + if not _cap_unmapped: + return True, None + sector = _UNKNOWN_SECTOR + + # 보유 종목들의 섹터 집계 + # (_cap_unmapped=False면 미매핑 보유분은 계수에서 제외 — 기존 동작) sector_counts: Dict[str, int] = {} for holding in portfolio_holdings: if holding.quantity > 0: h_sector = await get_stock_sector(holding.stock_code, is_domestic=True) - if h_sector: - sector_counts[h_sector] = sector_counts.get(h_sector, 0) + 1 + if not h_sector: + _note_unmapped_sector(holding.stock_code, _cap_unmapped) + if not _cap_unmapped: + continue + h_sector = _UNKNOWN_SECTOR + sector_counts[h_sector] = sector_counts.get(h_sector, 0) + 1 current_count = sector_counts.get(sector, 0) if current_count >= max_per_sector: diff --git a/backend/app/services/day_trading_strategy.py b/backend/app/services/day_trading_strategy.py index de5cf05..c819d3e 100644 --- a/backend/app/services/day_trading_strategy.py +++ b/backend/app/services/day_trading_strategy.py @@ -32,6 +32,8 @@ from dataclasses import dataclass from datetime import time from app.core.config import settings from app.services.mean_reversion_strategy import calculate_rsi, calculate_atr, calculate_bollinger +# §8.5-2 포지션 사이징 단일 출처 (순수 계산 모듈) +from app.services.sizing_core import budget_by_ratios logger = logging.getLogger(__name__) @@ -266,19 +268,28 @@ def calculate_opening_range(candles_1min: List[MinuteCandle]) -> Optional[Openin def _rsi(candles: List[MinuteCandle], period: int = 14) -> Optional[float]: - """Wrapper: extract closes from MinuteCandle list and delegate to canonical calculate_rsi""" + """Wrapper: MinuteCandle → closes 추출 후 canonical calculate_rsi 위임. + + 알고리즘: Wilder 평활 RSI (indicators_core.rsi_wilder) — 5분봉도 일봉과 동일 방식. + """ closes = [c.close for c in candles] return calculate_rsi(closes, period) def _bollinger(candles: List[MinuteCandle], period: int = 20) -> Optional[Tuple[float, float, float]]: - """Wrapper: extract closes from MinuteCandle list and delegate to canonical calculate_bollinger""" + """Wrapper: MinuteCandle → closes 추출 후 canonical calculate_bollinger 위임. + + 알고리즘: SMA 중앙선 + 모표준편차 2σ (indicators_core.bollinger_sma, num_std=2.0). + """ closes = [c.close for c in candles] return calculate_bollinger(closes, period) def _atr(candles: List[MinuteCandle], period: int = 14) -> Optional[float]: - """Wrapper: extract highs/lows/closes from MinuteCandle list and delegate to canonical calculate_atr""" + """Wrapper: MinuteCandle → highs/lows/closes 추출 후 canonical calculate_atr 위임. + + 알고리즘: TR 의 단순이동평균 (indicators_core.atr_sma) — Wilder ATR 아님. + """ if len(candles) < period + 1: return None highs = [c.high for c in candles] @@ -632,11 +643,16 @@ def calculate_day_trade_position_size( - 전체 데이트레이딩 합산: 총자산 × 25% (CLAUDE.md 8.2) - 동시 최대 3종목 (MAX_POSITIONS) """ - # [이슈6 수정] 모듈 레벨 상수 대신 동적 접근자 사용 - total_dt_budget = total_assets * _get_max_capital_ratio() # 전체 DT 한도 (25%) - remaining_dt = total_dt_budget - current_day_trade_amount # 남은 DT 한도 - per_position_budget = total_assets * _get_max_day_trade_ratio() # 종목당 한도 (4%, CLAUDE.md 5.1) - return max(0.0, min(per_position_budget, remaining_dt, available_cash)) + # [이슈6 수정] 모듈 레벨 상수 대신 동적 접근자 사용 (한도 값의 출처는 settings) + # [§8.5-2 통합 2026-09-27] 3중 min 식을 sizing_core.budget_by_ratios 로 위임. + # 비율 자체는 여전히 settings 소유(_get_* 접근자) — 수식만 공유한다. + return budget_by_ratios( + total_assets=total_assets, + available_cash=available_cash, + current_allocation=current_day_trade_amount, + max_per_position=_get_max_day_trade_ratio(), # 종목당 한도 (DAY_TRADE_MAX_CAPITAL_RATIO) + max_strategy_ratio=_get_max_capital_ratio(), # 전체 DT 한도 (DAY_TRADE_MAX_RATIO_DT) + ) def is_daily_loss_exceeded( diff --git a/backend/app/services/daytrading/entry_service.py b/backend/app/services/daytrading/entry_service.py index 7030c77..b6ea8f6 100644 --- a/backend/app/services/daytrading/entry_service.py +++ b/backend/app/services/daytrading/entry_service.py @@ -69,6 +69,33 @@ _watchlist: dict[str, str] = {} _watchlist_auto: bool = False +def _clamp_day_trade_budget( + budget: float, + total_assets: float, + available_cash: float, + current_day_trade_amount: float, +) -> float: + """[H3] 데이트레이딩 주문 예산을 세 가지 한도로 재클램프. + + calculate_day_trade_position_size()와 동일한 한도를 적용한다 + (종목당 DAY_TRADE_MAX_CAPITAL_RATIO / DT 전체 DAY_TRADE_MAX_RATIO_DT 잔여 / 보유 현금). + confidence 배율처럼 클램프 이후 예산을 변형하는 로직 뒤에 반드시 호출해야 한다. + calculate_day_trade_position_size() 자체는 변경하지 않는다(시그니처 보존). + + Args: + budget: 클램프 대상 예산 (원) + total_assets: 총자산 + available_cash: 주문 가능 현금 + current_day_trade_amount: 현재 데이트레이딩 투입 금액 + + Returns: + float: 세 한도를 모두 만족하는 예산 (0 이상) + """ + per_position_cap = total_assets * settings.DAY_TRADE_MAX_CAPITAL_RATIO + remaining_dt_cap = total_assets * settings.DAY_TRADE_MAX_RATIO_DT - current_day_trade_amount + return max(0.0, min(budget, per_position_cap, remaining_dt_cap, available_cash)) + + def set_watchlist(stocks: list[dict]) -> None: """감시 종목 설정 (수동 — 이후 자동 갱신 대상에서 제외). @@ -123,35 +150,45 @@ async def populate_daytrading_watchlist(min_score: int = 1, max_stocks: int = 20 from sqlalchemy import select, func today = datetime.now(_KST).date() - async with AsyncSessionLocal() as session: - # [ORB 복구] 09:00 시점엔 당일 배치 분석이 아직 없어 watchlist가 비고 → ORB 수집 0 → - # ORB 전략이 하루 종일 불가했음(2026-09-22). 당일 데이터가 없으면 가장 최근 - # 분석일(전일 등)의 결과로 채운다. 이후 30분 주기 갱신에서 당일 데이터로 교체됨. - latest_date = ( - await session.execute( - select(func.max(StockAnalysis.analysis_date)).where( - StockAnalysis.analysis_date <= today + # [M9 / CLAUDE.md §3.1 예외 — 읽기 전용, 쓰기는 db_worker(db_task_queue) 경유] + # 이 세션은 SELECT 2건만 수행하며 commit()/flush()/add() 를 호출하지 않는다. + # 세션 수명은 아래 블록으로 최소화하고, 실패 시 warning 으로 운영에 노출한다. + latest_date = None + rows: list = [] + try: + async with AsyncSessionLocal() as session: + # [ORB 복구] 09:00 시점엔 당일 배치 분석이 아직 없어 watchlist가 비고 → ORB 수집 0 → + # ORB 전략이 하루 종일 불가했음(2026-09-22). 당일 데이터가 없으면 가장 최근 + # 분석일(전일 등)의 결과로 채운다. 이후 30분 주기 갱신에서 당일 데이터로 교체됨. + latest_date = ( + await session.execute( + select(func.max(StockAnalysis.analysis_date)).where( + StockAnalysis.analysis_date <= today + ) ) - ) - ).scalar_one_or_none() - if latest_date is None: - logger.info("[DayEntry] stock_analysis 데이터 없음") - return 0 - if latest_date != today: - logger.info(f"[DayEntry] 당일({today}) 분석 없음 → 최근 분석일 {latest_date} 결과로 watchlist 구성") + ).scalar_one_or_none() + if latest_date is None: + logger.info("[DayEntry] stock_analysis 데이터 없음") + return 0 - result = await session.execute( - select(StockAnalysis.stock_code, StockAnalysis.stock_name, StockAnalysis.score) - .where( - StockAnalysis.analysis_date == latest_date, - StockAnalysis.score >= min_score, - StockAnalysis.rsi.isnot(None), - StockAnalysis.current_price > 5000, + result = await session.execute( + select(StockAnalysis.stock_code, StockAnalysis.stock_name, StockAnalysis.score) + .where( + StockAnalysis.analysis_date == latest_date, + StockAnalysis.score >= min_score, + StockAnalysis.rsi.isnot(None), + StockAnalysis.current_price > 5000, + ) + .order_by(StockAnalysis.score.desc()) + .limit(max_stocks) ) - .order_by(StockAnalysis.score.desc()) - .limit(max_stocks) - ) - rows = result.all() + rows = result.all() + except Exception as e: + logger.warning(f"[DayEntry] watchlist 자동 구성용 분석 조회 실패(읽기 전용): {e}") + return 0 + + if latest_date != today: + logger.info(f"[DayEntry] 당일({today}) 분석 없음 → 최근 분석일 {latest_date} 결과로 watchlist 구성") if not rows: logger.info(f"[DayEntry] stock_analysis({latest_date})에 조건 충족 종목 없음") @@ -209,6 +246,7 @@ async def scan_and_enter() -> list[dict]: 진입 전 체크 순서 (P0 = 최우선, P1 = 중요): [P0] MDD -10% 체크 → 단기 전략 비활성화 + [P0] MDD 공통 진입 게이트 can_open_new_position() (is_paused + 갱신 장애 fail-safe) [P0] 플레이북 체크 → FOMC/옵션만기 등 이벤트 당일 차단 [P0] 일일 손실 한도 -3% 체크 [P1] 3연패 쿨다운 체크 @@ -222,12 +260,31 @@ async def scan_and_enter() -> list[dict]: day_trading_state.reset_if_new_day() entries = [] - # MDD 체크 + # MDD 체크 ① — 단기/데이트레이딩 전략 비활성화 여부 (MDD -10% 시 pause) short_term_ok = await mdd_manager.is_short_term_strategy_enabled() if not short_term_ok: logger.info("[DayEntry] MDD 차단 — 단기 전략 비활성화") return entries + # MDD 체크 ② — 공통 신규 진입 게이트 (CLAUDE.md §5.3) + # [항목7 2026-09-27] DT 진입 경로는 is_short_term_strategy_enabled() 만 봤기 때문에 + # can_open_new_position() 의 fail-safe(MDD 갱신 연속 실패 = peak/drawdown 정지로 + # -10%/-20% 게이트 무력화)와 is_paused 가 적용되지 않았다. 두 게이트를 모두 + # 통과해야 진입한다. 이 블록은 try 밖에 두어 예외로 우회되지 않게 하고, + # 종목 루프/분봉 API 호출 전에 배치해 불필요한 KIS 호출을 없앤다. + # 청산 경로(exit_service)에는 영향 없음. + if not await mdd_manager.can_open_new_position(): + _health = mdd_manager.get_update_health() + if _health.get("blind_block_active"): + logger.error( + "[DayEntry] ⛔ MDD 갱신 장애(fail-safe) — 데이트레이딩 신규 진입 차단 " + f"(연속 실패 {_health.get('consecutive_failures')}회 / 임계 " + f"{_health.get('blind_block_threshold')}, 마지막 오류: {_health.get('last_error')})" + ) + else: + logger.warning("[DayEntry] ⛔ MDD 임계 초과(is_paused) — 데이트레이딩 신규 진입 차단") + return entries + # 적응형 전략 체크 — Defensive/Crisis 모드에서 데이트레이딩 비활성화 try: from app.services.adaptive_strategy import get_current_adaptive_params @@ -433,8 +490,17 @@ async def _try_enter( current_day_trade_amount=current_day_trade_amount, ) # confidence 스케일링: 기준 0.7 대비 비례 조정 (0.6→86%, 0.7→100%, 0.85→121%) + # [H3 fix 2026-09-27] 예전 코드는 calculate_day_trade_position_size()가 이미 + # min(종목당 6%, DT 잔여 30%, 보유현금)로 클램프한 값에 최대 1.2배를 곱해 + # 세 한도를 모두 최대 20% 초과했다(ORB confidence 상한 0.85 → 실제로 1.2배 발동). + # → 배율 적용 후 동일한 세 한도로 재클램프한다. 감액(<1.0)은 그대로 적용된다. confidence_scale = min(1.2, signal.confidence / 0.7) - budget = budget * confidence_scale + budget = _clamp_day_trade_budget( + budget * confidence_scale, + total_assets=total_assets, + available_cash=available_cash, + current_day_trade_amount=current_day_trade_amount, + ) quantity = int(budget // current_price) if current_price > 0 else 0 if quantity <= 0: return None diff --git a/backend/app/services/daytrading/exit_service.py b/backend/app/services/daytrading/exit_service.py index a6e73d3..5c6b377 100644 --- a/backend/app/services/daytrading/exit_service.py +++ b/backend/app/services/daytrading/exit_service.py @@ -25,6 +25,7 @@ from datetime import datetime import pytz +from app.core.config import settings from app.core.market_schedule import is_eod_flat_time, is_market_open from app.services.korea_investment_api import korea_investment_api from app.services.day_trading_strategy import ( @@ -40,6 +41,42 @@ logger = logging.getLogger(__name__) # 한국 표준시(KST) 타임존 _KST = pytz.timezone("Asia/Seoul") +# [M1] 전일(stale) 포지션 강제청산으로 발생한 실현손익 누적 (당일 집계에서 제외된 금액). +# 당일 손실 한도 판단에는 쓰지 않고 추적/로그 용도로만 사용한다. +_stale_realized_pnl: float = 0.0 + + +def get_stale_realized_pnl() -> float: + """전일 포지션 강제청산으로 실현된 손익 누적값(당일 daily_pnl 에서 제외된 금액).""" + return _stale_realized_pnl + + +def _fallback_stop_pct() -> float: + """[M7] stop_loss 가 없을 때 사용할 폴백 손절 비율. + + 기존 하드코딩 0.06(-6%)은 전략 하드캡(ORB 3%, VWAP/RSI_BB 3.5%)의 2배로 느슨했다. + 가장 보수적(타이트)한 하드캡을 사용해 폴백이 실제 전략보다 헐거워지지 않게 한다. + position_manager.restore_from_db() 의 폴백(DAY_TRADE_STOP_LOSS_PCT)과도 일치. + """ + return min( + settings.DAY_TRADE_STOP_LOSS_PCT, + settings.DAY_TRADE_ORB_STOP_LOSS_PCT, + settings.DAY_TRADE_REVERSION_STOP_LOSS_PCT, + ) + + +def _fallback_stop_price(entry_price: float) -> float: + """진입가 기준 폴백 손절가.""" + return entry_price * (1 - _fallback_stop_pct()) + + +def _fallback_target_price(entry_price: float, stop_loss: float) -> float: + """진입가·손절가 기준 폴백 목표가 (DAY_TRADE_TARGET_MULTIPLIER 정합).""" + risk = entry_price - stop_loss + if risk <= 0: + risk = entry_price * _fallback_stop_pct() + return entry_price + risk * settings.DAY_TRADE_TARGET_MULTIPLIER + def _kst_now() -> datetime: """현재 KST 시간을 timezone-naive datetime으로 반환. @@ -134,23 +171,42 @@ async def _check_single_position(stock_code: str) -> dict | None: return None entry_price = pos.get("entry_price", 0) - # [FIX] 0 값이 저장된 경우 dict.get() 기본값이 적용되지 않으므로 `or`로 방어 + # [M7] stop_loss / target_price 는 None 뿐 아니라 0 으로 저장될 수 있다. + # `or` 패턴은 0 도 폴백으로 넘기지만 의도가 모호하므로 명시적으로 None/비정상값을 검사한다. # (target_price=0이면 day_trading_strategy.py의 목표가 도달 조건이 항상 참이 되어 즉시 오매도됨) - stop_loss = pos.get("stop_loss") or (entry_price * 0.94) - target_price = pos.get("target_price") or (entry_price * 1.06) - quantity = pos.get("quantity", 0) + _sl_raw = pos.get("stop_loss") + stop_loss = float(_sl_raw) if (_sl_raw is not None and float(_sl_raw) > 0) else _fallback_stop_price(entry_price) + _tp_raw = pos.get("target_price") + target_price = ( + float(_tp_raw) if (_tp_raw is not None and float(_tp_raw) > 0) + else _fallback_target_price(entry_price, stop_loss) + ) highest_price = pos.get("highest_price", entry_price) entry_time = pos.get("entry_time") - # 5분봉 데이터 조회 + # [H4] try 범위를 "분봉 조회 + 지표/신호 계산"까지로 한정한다. + # 예전 코드는 _execute_exit 까지 하나의 try 안에 있고 로그가 debug 였기 때문에 + # 분봉 조회 실패 시 손절·트레일링·시간손절이 전부 미적용된 채 15:20 까지 방치되고, + # 주문 실패조차 로그에 보이지 않았다. + exit_signal = None + current_price = 0.0 try: raw = await korea_investment_api.get_minute_chart_full_day(stock_code) candles = parse_minute_candles(raw) candles_5m = aggregate_to_5min(candles) - if len(candles_5m) < 5: - return None + except Exception as e: + logger.warning(f"[DayExit] 5분봉 조회 실패 ({stock_code}): {e} — 폴백 손절만 판정") + return await _check_fallback_stop_loss(stock_code, pos, stop_loss) - current_price = float(candles_5m[-1].close) if candles_5m else 0.0 + if not candles_5m or len(candles_5m) < 5: + # 분봉이 부족하면 ATR/RSI 기반 판정이 불가 → 손절선만 지킨다(방치 금지) + logger.warning( + f"[DayExit] 5분봉 부족 ({stock_code}): {len(candles_5m or [])}개 — 폴백 손절만 판정" + ) + return await _check_fallback_stop_loss(stock_code, pos, stop_loss) + + try: + current_price = float(candles_5m[-1].close) # highest_price 갱신 — 5분봉 high 값도 반영 (30초 주기 보완) candle_high = max(c.high for c in candles_5m[-3:]) if len(candles_5m) >= 3 else current_price @@ -176,18 +232,83 @@ async def _check_single_position(stock_code: str) -> dict | None: current_time=current_time, entry_time=entry_time, ) - - if exit_signal: - sell_pct = exit_signal.sell_pct - return await _execute_exit( - stock_code, pos, current_price, exit_signal.reason, - sell_pct=sell_pct, - ) - except Exception as e: - logger.debug(f"[DayExit] 5분봉 조회 실패 ({stock_code}): {e}") + logger.warning(f"[DayExit] 청산 지표 계산 실패 ({stock_code}): {e} — 폴백 손절만 판정") + return await _check_fallback_stop_loss(stock_code, pos, stop_loss) - return None + if not exit_signal or current_price <= 0: + return None + + # 주문 실행은 try 밖(별도 try) — 실패를 조회 실패와 구분해 error 로 남긴다. + try: + return await _execute_exit( + stock_code, pos, current_price, exit_signal.reason, + sell_pct=exit_signal.sell_pct, + ) + except Exception as e: + logger.error(f"[DayExit] 청산 주문 실행 실패 ({stock_code}): {e}", exc_info=True) + return None + + +async def _check_fallback_stop_loss( + stock_code: str, pos: dict, stop_loss: float, +) -> dict | None: + """[H4] 분봉 미확보 시 최소 안전장치 — 손절선 이탈만 판정해 전량 청산. + + swing/exit_service.py 의 고정비율 폴백 손절과 같은 취지. 분봉이 없으면 + 트레일링/시간손절/RSI 청산은 판정할 수 없으므로 "손절선"만 지킨다. + 현재가는 KIS 현재가 조회 → 인메모리 최신가(current_price) 순으로 확보하고, + 둘 다 실패하면 logger.error 로 남긴다(손절 미적용을 침묵시키지 않는다). + + Args: + stock_code: 종목 코드 + pos: 포지션 정보 + stop_loss: 적용할 손절가 (0 이하이면 진입가 기준 폴백 산출) + + Returns: + dict | None: 청산 결과 또는 None (손절 미도달·판정 불가) + """ + entry_price = float(pos.get("entry_price", 0) or 0) + if entry_price <= 0 or int(pos.get("quantity", 0) or 0) <= 0: + return None + if stop_loss <= 0: + stop_loss = _fallback_stop_price(entry_price) + + current_price = 0.0 + try: + resp = await korea_investment_api.get_current_price(stock_code) + current_price = float(resp.get("output", {}).get("stck_prpr", 0) or 0) if resp else 0.0 + except Exception as e: + logger.warning(f"[DayExit] 폴백 손절 현재가 조회 실패 ({stock_code}): {e}") + if current_price <= 0: + current_price = float(pos.get("current_price", 0) or 0) + if current_price <= 0: + logger.error( + f"[DayExit] 폴백 손절 현재가 확보 실패 ({stock_code}) — 손절 미적용 상태, 수동 확인 필요" + ) + return None + + async with day_trading_state.trade_lock: + if stock_code in day_trading_state.positions: + day_trading_state.positions[stock_code]["current_price"] = current_price + + if current_price > stop_loss: + return None + + loss_rate = (current_price - entry_price) / entry_price * 100 + logger.warning( + f"[DayExit] ⚠️ 폴백 손절 발동 ({stock_code}): 현재가={current_price:.0f} " + f"<= 손절={stop_loss:.0f} ({loss_rate:.2f}%) — 분봉 미확보" + ) + try: + return await _execute_exit( + stock_code, pos, current_price, + reason=f"폴백 손절(분봉 미확보) {loss_rate:.1f}%", + sell_pct=1.0, + ) + except Exception as e: + logger.error(f"[DayExit] 폴백 손절 주문 실패 ({stock_code}): {e}", exc_info=True) + return None async def _execute_exit( @@ -213,9 +334,31 @@ async def _execute_exit( Returns: dict | None: 청산 결과 (stock_code, price, quantity, profit_loss 등) """ + return await _execute_exit_impl(stock_code, pos, price, reason, sell_pct, is_stale=False) + + +async def _execute_exit_stale( + stock_code: str, pos: dict, price: float, reason: str, +) -> dict | None: + """[M1] 전일 미청산(stale) 포지션 전용 청산 경로. + + _execute_exit 과 동일하게 주문·상태반영·DB 기록을 수행하지만, + 실현손익을 **당일** daily_pnl / loss_streak_today 에 가산하지 않는다. + (전일 손실이 당일 손실 한도(-3%)를 오발동시켜 정상 포지션까지 전량 청산되고 + 신규 진입이 종일 차단되던 문제 — 2026-09-27 수정) + """ + return await _execute_exit_impl(stock_code, pos, price, reason, 1.0, is_stale=True) + + +async def _execute_exit_impl( + stock_code: str, pos: dict, price: float, reason: str, + sell_pct: float, is_stale: bool, +) -> dict | None: + """매도 주문 실행 + 상태 반영 공통 구현 (신규 함수 — 기존 시그니처 불변). + + is_stale=True 이면 당일 손익 집계(update_pnl)를 건너뛴다. + """ total_qty = pos.get("quantity", 0) - entry_price = pos.get("entry_price", 0) - stock_name = pos.get("stock_name", stock_code) if total_qty <= 0 or price <= 0: return None @@ -247,6 +390,10 @@ async def _execute_exit( # [2026-09-23] 여기서부터는 이미 체결된 주문의 상태 반영이다. 스케줄러의 단계 # 타임아웃(_guard)이 이 지점을 취소하면 "브로커는 팔았는데 인메모리는 그대로" 상태가 # 되어 다음 사이클이 같은 물량을 다시 판다 → shield 로 취소로부터 보호한다. + if is_stale: + return await asyncio.shield(_finalize_exit_stale( + stock_code, pos, price, reason, order_resp, quantity, total_qty, + )) return await asyncio.shield(_finalize_exit( stock_code, pos, price, reason, sell_pct, order_resp, quantity, total_qty, is_full_exit )) @@ -256,7 +403,44 @@ async def _finalize_exit( stock_code: str, pos: dict, price: float, reason: str, sell_pct: float, order_resp: dict, quantity: int, total_qty: int, is_full_exit: bool, ) -> dict | None: - """체결 이후 상태 반영 (인메모리·DB·캐시). 취소되면 안 되는 구간.""" + """체결 이후 상태 반영 (인메모리·DB·캐시). 취소되면 안 되는 구간. + + 당일 포지션 전용 — 실현손익을 당일 daily_pnl / loss_streak_today 에 반영한다. + (전일 포지션은 _finalize_exit_stale 사용 — M1) + """ + return await _finalize_exit_impl( + stock_code, pos, price, reason, sell_pct, order_resp, + quantity, total_qty, is_full_exit, count_daily_pnl=True, + ) + + +async def _finalize_exit_stale( + stock_code: str, pos: dict, price: float, reason: str, + order_resp: dict, quantity: int, total_qty: int, +) -> dict | None: + """[M1] 전일(stale) 포지션 체결 이후 상태 반영. + + 당일 손익 집계(daily_pnl·loss_streak_today)에는 가산하지 않고, + 별도 누적값(_stale_realized_pnl)과 로그로만 추적한다. + DB TradeHistory 기록은 동일하게 수행되므로 실제 손익 추적은 유지된다. + """ + return await _finalize_exit_impl( + stock_code, pos, price, reason, 1.0, order_resp, + quantity, total_qty, True, count_daily_pnl=False, + ) + + +async def _finalize_exit_impl( + stock_code: str, pos: dict, price: float, reason: str, sell_pct: float, + order_resp: dict, quantity: int, total_qty: int, is_full_exit: bool, + count_daily_pnl: bool = True, +) -> dict | None: + """체결 이후 상태 반영 공통 구현 (신규 함수 — 기존 시그니처 불변). + + count_daily_pnl=False 이면 당일 손익/연속손실 집계를 건너뛰고 + 전일 손익 누적값에만 더한다(전일 포지션 강제청산 경로). + """ + global _stale_realized_pnl entry_price = pos.get("entry_price", 0) stock_name = pos.get("stock_name", stock_code) order_id = order_resp.get("output", {}).get("ODNO", "") @@ -267,13 +451,25 @@ async def _finalize_exit( async with day_trading_state.trade_lock: if is_full_exit: day_trading_state.remove_position(stock_code) + if not count_daily_pnl: + # [M10] DB에서만 발견된 전일 포지션은 인메모리에 없어 remove_position()의 + # closed_today 추가가 일어나지 않는다 → 강제청산한 종목이 같은 날 + # scan_and_enter 후보로 되살아난다. 명시적으로 재진입 금지 집합에 넣는다. + day_trading_state.closed_today.add(stock_code) else: # 분할 매도: 잔량 유지, target_price 제거(트레일링만 적용) remaining = total_qty - quantity day_trading_state.positions[stock_code]["quantity"] = remaining day_trading_state.positions[stock_code]["target_price"] = float("inf") day_trading_state.positions[stock_code]["partial_sold"] = True - day_trading_state.update_pnl(profit_loss, is_full_close=is_full_exit) + if count_daily_pnl: + day_trading_state.update_pnl(profit_loss, is_full_close=is_full_exit) + else: + _stale_realized_pnl += profit_loss + logger.warning( + f"[DayExit] 전일 포지션 실현손익은 당일 집계 제외: {stock_code} " + f"{profit_loss:+.0f}원 (전일 누적={_stale_realized_pnl:+.0f}원)" + ) # [P0] loss_tracker 연동 — 전량 매도 시 연속 손실 추적 if is_full_exit: @@ -356,6 +552,10 @@ async def close_stale_positions() -> list[dict]: if pos and entry_date is not None and entry_date < today: stale[stock_code] = pos + # [M9 / CLAUDE.md §3.1 예외 — 읽기 전용, 쓰기는 db_worker(db_task_queue) 경유] + # SELECT 1건만 수행하며 commit()/flush()/add() 호출이 없다. 세션 수명은 아래 + # async with 블록(쿼리 1회)로 최소화하고, 실패는 warning 으로 운영에 노출한다. + # 실제 청산 기록은 아래 _execute_exit_stale → db_task_queue 경유로만 이뤄진다. try: from app.core.database import AsyncSessionLocal from app.models.models import ActiveTrade @@ -407,10 +607,16 @@ async def close_stale_positions() -> list[dict]: f"[DayExit] ⚠️ 전일 미청산 포지션 즉시 청산: {stock_code} " f"(진입일={entry_date}, 수량={pos.get('quantity')})" ) - result = await _execute_exit( + # [M10] 청산 성공/실패와 무관하게 당일 재진입은 금지한다. 주문이 실패해도 + # 다음 사이클에서 재시도되며, 그 사이 scan_and_enter 가 같은 종목을 다시 + # 매수하면 전일 포지션이 되살아난다. + async with day_trading_state.trade_lock: + day_trading_state.closed_today.add(stock_code) + + # [M1] 전일 손익은 당일 daily_pnl / loss_streak_today 에 가산하지 않는다. + result = await _execute_exit_stale( stock_code, pos, price, reason=f"전일 미청산 포지션 강제 청산 (진입일 {entry_date}, EOD 누락)", - sell_pct=1.0, ) if result: results.append(result) diff --git a/backend/app/services/daytrading/scheduler.py b/backend/app/services/daytrading/scheduler.py index 3afe273..e7f75ed 100644 --- a/backend/app/services/daytrading/scheduler.py +++ b/backend/app/services/daytrading/scheduler.py @@ -85,19 +85,22 @@ async def daytrading_scheduler() -> None: # 앱 재시작 시 DB에서 당일 데이트레이딩 포지션 복원 (CRITICAL 버그 수정) try: + # [M9 / CLAUDE.md §3.1 예외 — 기동 시 1회, 읽기 전용 복원] + # restore_from_db()는 SELECT 만 수행하며 commit() 이 없다. 쓰기는 db_worker 경유. + # 세션 수명을 복원 쿼리로 한정하고, 주문이 발생하는 stale 청산은 블록 밖에서 수행한다. from app.core.database import AsyncSessionLocal async with AsyncSessionLocal() as db: restored = await day_trading_state.restore_from_db(db) - if restored: - logger.info(f"[DayTrading] 시작 시 {restored}개 포지션 복원됨") - # 전일 미청산 포지션은 기동 직후(장중이면) 즉시 청산 — EOD 누락 안전장치 - try: - from app.services.daytrading.exit_service import close_stale_positions - stale = await close_stale_positions() - if stale: - logger.warning(f"[DayTrading] 기동 시 전일 포지션 {len(stale)}건 청산") - except Exception as e: - logger.error(f"[DayTrading] 기동 시 전일 포지션 청산 실패: {e}", exc_info=True) + if restored: + logger.info(f"[DayTrading] 시작 시 {restored}개 포지션 복원됨") + # 전일 미청산 포지션은 기동 직후(장중이면) 즉시 청산 — EOD 누락 안전장치 + try: + from app.services.daytrading.exit_service import close_stale_positions + stale = await close_stale_positions() + if stale: + logger.warning(f"[DayTrading] 기동 시 전일 포지션 {len(stale)}건 청산") + except Exception as e: + logger.error(f"[DayTrading] 기동 시 전일 포지션 청산 실패: {e}", exc_info=True) except Exception as e: logger.error(f"[DayTrading] 시작 시 포지션 복원 실패: {e}", exc_info=True) diff --git a/backend/app/services/db_worker.py b/backend/app/services/db_worker.py index 00447d9..c92172e 100644 --- a/backend/app/services/db_worker.py +++ b/backend/app/services/db_worker.py @@ -34,7 +34,7 @@ import uuid from datetime import date, datetime from pathlib import Path -from sqlalchemy import select +from sqlalchemy import or_, select from app.core.database import AsyncSessionLocal from app.models.models import TradeHistory @@ -42,6 +42,74 @@ from app.core.timeutil import now_kst logger = logging.getLogger(__name__) +# ───────────────────────────────────────────────────────────────────────────── +# 전략명 상수 / 필터 헬퍼 (CLAUDE.md §8.5 항목 3·6) +# +# DT_STRATEGY_NAMES 는 mdd_manager.DT_STRATEGY_NAMES 와 **동일 목록**이어야 한다. +# 여기(db_worker)를 정본으로 두는 이유: +# - db_worker 는 어떤 서비스 모듈도 모듈 수준에서 import 하지 않아 순환 참조가 없다. +# - ActiveTrade 를 실제로 쓰는 계층이므로 필터 표현을 한 곳에 모을 수 있다. +# mdd_manager 쪽은 담당 에이전트가 달라 이번에 수정하지 않는다(후속 과제). +# ───────────────────────────────────────────────────────────────────────────── +DT_STRATEGY_NAMES: tuple[str, ...] = ("day_trade", "daytrading") + + +def not_dt_strategy_clause(column): + """DT 계열 전략을 제외하는 SQL 3값 논리 안전 필터. + + `column != "day_trade"` 는 column IS NULL 인 legacy 행까지 함께 탈락시킨다 + (SQL 3값 논리: NULL != 'x' → UNKNOWN → WHERE 에서 거짓 취급). + → NULL 행을 명시적으로 포함시킨 뒤 DT 계열만 NOT IN 으로 제외한다. + + Args: + column: ActiveTrade.strategy_name 등 비교 대상 컬럼 + + Returns: + SQLAlchemy boolean expression + """ + return or_(column.is_(None), column.notin_(DT_STRATEGY_NAMES)) + + +# upsert_active_trade payload 중 ORM 컬럼이 아닌 '메타' 키 (setattr/생성 인자 제외 대상) +_ACTIVE_TRADE_META_KEYS = ( + "remaining_pct_multiply", + "strategy_names", + "strategy_name_not", +) + + +def _apply_strategy_name_filter(query, data: dict, column): + """payload 의 전략명 필터 키를 SELECT 쿼리에 적용. + + 지원 키 (우선순위 순 — 상위 키가 있으면 하위 키는 무시): + 1) strategy_name : str → column == value (기존 동작, 무변경) + 2) strategy_names : list → column IN (...) + 3) strategy_name_not : str | list → column IS NULL OR column NOT IN (...) + + 세 키가 모두 없거나 비어 있으면 query 를 **그대로** 반환하므로, + 새 키를 쓰지 않는 기존 호출부의 동작은 수정 전과 완전히 동일하다. + """ + strategy_name = data.get("strategy_name") + if strategy_name: + return query.where(column == strategy_name) + + names = data.get("strategy_names") + if names: + _names = [n for n in names if n] + if _names: + return query.where(column.in_(_names)) + return query + + excluded = data.get("strategy_name_not") + if excluded: + if isinstance(excluded, str): + excluded = [excluded] + _excluded = [n for n in excluded if n] + if _excluded: + return query.where(or_(column.is_(None), column.notin_(_excluded))) + return query + + # ───────────────────────────────────────────────────────────────────────────── # DLQ (Dead Letter Queue) — 3회 재시도 실패 시 파일로 백업 # ───────────────────────────────────────────────────────────────────────────── @@ -228,12 +296,24 @@ async def enqueue_active_trade_upsert(data: dict) -> None: data 필수 키: stock_code, quantity, entry_price data 선택 키: strategy_name ('swing' | 'day_trade'), stop_loss, take_profit, entry_reason, highest_since_entry + data 선택 키(조회 필터 전용, ORM 컬럼 아님 — _ACTIVE_TRADE_META_KEYS): + strategy_names(list → IN), + strategy_name_not(str|list → NOT IN + strategy_name IS NULL 포함) + 우선순위: strategy_name > strategy_names > strategy_name_not """ await db_task_queue.put({"type": "upsert_active_trade", "data": data}) async def enqueue_close_active_trade(stock_code: str, strategy_name: str | None = None) -> None: - """ActiveTrade 를 'closed' 상태로 변경하는 태스크를 큐에 등록.""" + """ActiveTrade 를 'closed' 상태로 변경하는 태스크를 큐에 등록. + + 전략명 부정 조건(strategy_name_not) 등 확장 필터가 필요하면 + 시그니처 변경 없이 enqueue_db_write() 로 payload 를 직접 넘긴다: + await enqueue_db_write({ + "type": "close_active_trade", + "data": {"stock_code": code, "strategy_name_not": DT_STRATEGY_NAMES}, + }) + """ await db_task_queue.put({ "type": "close_active_trade", "data": {"stock_code": stock_code, "strategy_name": strategy_name}, @@ -536,12 +616,18 @@ async def db_writer_worker() -> None: ActiveTrade.stock_code == stock_code, ActiveTrade.status == "active", ) - if strategy_name: - query = query.where(ActiveTrade.strategy_name == strategy_name) + # [§8.5 항목6] strategy_name(동등) / strategy_names(IN) / + # strategy_name_not(NOT IN + NULL 포함) — 새 키 미사용 시 무변경. + query = _apply_strategy_name_filter(query, data, ActiveTrade.strategy_name) if user_id is not None: query = query.where(ActiveTrade.user_id == user_id) + # [2026-09-27 §8.5 후속] legacy 경로와 동일하게 '가장 최근 행' 우선. + # scalar_one_or_none() 은 한 사용자가 같은 종목에 비-DT active 행을 + # 2건 이상 보유하면 MultipleResultsFound → 3회 재시도 후 DLQ 로 빠진다. + # (멀티테넌트 플래그 ON 전제조건) + query = query.order_by(ActiveTrade.id.desc()).limit(1) result = await session.execute(query) - record = result.scalar_one_or_none() + record = result.scalars().first() else: # legacy(user_id=None) 경로: 사용자 다중화로 동일 종목 active # 행이 2건 이상 존재할 수 있어 scalar_one_or_none() 이 @@ -551,8 +637,11 @@ async def db_writer_worker() -> None: record = result.scalars().first() if record is None: - # remaining_pct_multiply는 신규 생성 시 무시 - create_data = {k: v for k, v in data.items() if k != "remaining_pct_multiply"} + # remaining_pct_multiply·전략명 필터 키는 신규 생성 시 무시 + create_data = { + k: v for k, v in data.items() + if k not in _ACTIVE_TRADE_META_KEYS + } # 필수 컬럼(quantity/entry_price) 없이 신규 생성 요청이 오면 # IntegrityError로 3회 재시도만 낭비 → 경고 후 스킵 _missing = [k for k in ("quantity", "entry_price") if create_data.get(k) is None] @@ -574,7 +663,9 @@ async def db_writer_worker() -> None: current_remaining = getattr(record, "remaining_pct", 1.0) or 1.0 record.remaining_pct = float(current_remaining) * float(multiply_ratio) for key, val in data.items(): - if key not in ("stock_code", "remaining_pct_multiply") and val is not None: + if key == "stock_code" or key in _ACTIVE_TRADE_META_KEYS: + continue + if val is not None: setattr(record, key, val) await session.commit() @@ -597,12 +688,18 @@ async def db_writer_worker() -> None: ActiveTrade.stock_code == stock_code, ActiveTrade.status == "active", ) - if strategy_name: - query = query.where(ActiveTrade.strategy_name == strategy_name) + # [§8.5 항목6] strategy_name(동등) / strategy_names(IN) / + # strategy_name_not(NOT IN + NULL 포함) — 새 키 미사용 시 무변경. + query = _apply_strategy_name_filter(query, data, ActiveTrade.strategy_name) if user_id is not None: query = query.where(ActiveTrade.user_id == user_id) + # [2026-09-27 §8.5 후속] legacy 경로와 동일하게 '가장 최근 행' 우선. + # scalar_one_or_none() 은 한 사용자가 같은 종목에 비-DT active 행을 + # 2건 이상 보유하면 MultipleResultsFound → 3회 재시도 후 DLQ 로 빠진다. + # (멀티테넌트 플래그 ON 전제조건) + query = query.order_by(ActiveTrade.id.desc()).limit(1) result = await session.execute(query) - record = result.scalar_one_or_none() + record = result.scalars().first() else: # legacy(user_id=None) 경로: 사용자 다중화로 동일 종목 active # 행이 2건 이상 존재할 수 있어 scalar_one_or_none() 이 diff --git a/backend/app/services/indicators_core.py b/backend/app/services/indicators_core.py new file mode 100644 index 0000000..fa237ec --- /dev/null +++ b/backend/app/services/indicators_core.py @@ -0,0 +1,223 @@ +"""지표 계산 코어 (순수 계산 — I/O·DB·네트워크·설정 의존성 없음) + +CLAUDE.md §8.5-1 "RSI/BB/ATR 계산 3벌 중복" 통합용 단일 출처(single source of truth). + +### 설계 원칙 +1. **알고리즘 이름을 함수명에 명시한다.** 스무딩 방식이 다르면 값도 다르므로 + `rsi_wilder` / `rsi_sma` 처럼 분리해 유지한다. 억지로 하나로 합치지 않는다. +2. **순수 함수.** settings / DB / 네트워크를 참조하지 않는다. period·계수는 인자로 받는다. +3. **기존 값 보존.** 이 모듈의 함수는 기존 3벌 구현의 수치를 **비트 단위로 재현**하는 + 것이 목적이다. "더 정확한 계산"으로의 개선은 백테스트를 동반한 별도 작업이다. + +### 입력 규약 +- 모든 리스트는 **오래된 → 최신** 순서(시계열 오름차순). `[-1]`이 최신 봉. +- 데이터 부족 시 예외 대신 `None` 반환. + +### 대응 관계 (통합 전 구현 → 이 모듈) +| 통합 전 | 이 모듈 | +|---------|---------| +| `mean_reversion_strategy.calculate_rsi` (Wilder 평활) | `rsi_wilder` | +| `technical_indicators.analyze_rsi` 인라인 (period 구간 단순평균) | `rsi_sma` | +| `mean_reversion_strategy.calculate_atr` (TR의 SMA) | `atr_sma` | +| `mean_reversion_strategy.calculate_bollinger` (2σ 고정) | `bollinger_sma(..., num_std=2.0)` | +| `technical_indicators.analyze_bollinger` 인라인 (σ배수 설정값) | `bollinger_sma(..., num_std=<설정>)` | +| `technical_indicators.analyze_rsi` 인라인 BB (20봉·2σ 하드코딩) | `bollinger_sma(..., 20, 2.0)` | +| `mean_reversion_strategy.calculate_adx` (Wilder) | `adx_wilder` | +| `day_trading_strategy._rsi/_bollinger/_atr` (이미 위임 상태) | 동일 경로 재사용 | +""" +from typing import List, Optional, Tuple + +__all__ = [ + "true_ranges", + "rsi_wilder", + "rsi_sma", + "atr_sma", + "bollinger_sma", + "adx_wilder", +] + + +# ── 공통 헬퍼 ──────────────────────────────────────────────────────────────── + +def true_ranges(highs: List[float], lows: List[float], closes: List[float]) -> List[float]: + """TR 시계열 계산. TR = max(H-L, |H-Cprev|, |L-Cprev|) + + 길이는 `len(highs) - 1` (첫 봉은 이전 종가가 없어 제외). + """ + out: List[float] = [] + for i in range(1, len(highs)): + h, l, c_prev = highs[i], lows[i], closes[i - 1] + out.append(max(h - l, abs(h - c_prev), abs(l - c_prev))) + return out + + +# ── RSI ────────────────────────────────────────────────────────────────────── + +def rsi_wilder(closes: List[float], period: int = 14) -> Optional[float]: + """RSI — Wilder 평활법 (mean_reversion_strategy.calculate_rsi 와 동일) + + - 시드: 첫 `period`개 diff 의 단순평균 + - 이후: avg = (prev_avg * (period-1) + current) / period + - avg_loss == 0 → 100.0 + - len(closes) < period+1 → None + + 주의: `len(closes) == period + 1` 인 경우 평활 루프가 돌지 않으므로 + `rsi_sma()` 와 값이 **정확히 동일**하다. 두 함수가 갈라지는 것은 + 입력이 period+1 보다 길 때뿐이다. + """ + if len(closes) < period + 1: + return None + + diffs = [closes[i] - closes[i - 1] for i in range(1, len(closes))] + + avg_gain = sum(max(d, 0) for d in diffs[:period]) / period + avg_loss = sum(max(-d, 0) for d in diffs[:period]) / period + + for i in range(period, len(diffs)): + avg_gain = (avg_gain * (period - 1) + max(diffs[i], 0)) / period + avg_loss = (avg_loss * (period - 1) + max(-diffs[i], 0)) / period + + if avg_loss == 0: + return 100.0 + rs = avg_gain / avg_loss + return 100 - (100 / (1 + rs)) + + +def rsi_sma(closes: List[float], period: int = 14) -> Optional[float]: + """RSI — 단순평균(SMA) 방식 (technical_indicators.analyze_rsi 인라인 구현과 동일) + + - **마지막 `period`개 diff** 만 단순평균 (평활 없음, Wilder 아님) + - avg_loss == 0 → 100.0 + - len(closes) < period+1 → None + + 금융공학적 표준(Wilder)과 다르지만, 추천/분석 엔진의 기존 신호 수치를 + 보존하기 위해 별도 함수로 유지한다. + """ + if len(closes) < period + 1: + return None + + gains: List[float] = [] + losses: List[float] = [] + for i in range(1, len(closes)): + change = closes[i] - closes[i - 1] + if change > 0: + gains.append(change) + losses.append(0) + else: + gains.append(0) + losses.append(abs(change)) + + avg_gain = sum(gains[-period:]) / period + avg_loss = sum(losses[-period:]) / period + + if avg_loss == 0: + return 100.0 + rs = avg_gain / avg_loss + return 100 - (100 / (1 + rs)) + + +# ── ATR ────────────────────────────────────────────────────────────────────── + +def atr_sma(highs: List[float], lows: List[float], closes: List[float], + period: int = 14) -> Optional[float]: + """ATR — TR 의 단순이동평균 (mean_reversion_strategy.calculate_atr 와 동일) + + - ATR = mean(TR[-period:]) (Wilder 평활 아님) + - highs/lows/closes 중 하나라도 len < period+1 → None + - TR 개수 < period → None + """ + if len(highs) < period + 1 or len(lows) < period + 1 or len(closes) < period + 1: + return None + + trs = true_ranges(highs, lows, closes) + if len(trs) < period: + return None + return sum(trs[-period:]) / period + + +# ── 볼린저 밴드 ────────────────────────────────────────────────────────────── + +def bollinger_sma(closes: List[float], period: int = 20, + num_std: float = 2.0) -> Optional[Tuple[float, float, float]]: + """볼린저 밴드 — SMA 중앙선 + 모표준편차(ddof=0) + + Returns: (upper, middle, lower) — 기존 `calculate_bollinger` 반환 순서 유지 + - middle = mean(closes[-period:]) + - std = sqrt( mean((p - middle)^2) ) ← 모분산(ddof=0), 표본분산 아님 + - len(closes) < period → None + """ + if len(closes) < period: + return None + window = closes[-period:] + middle = sum(window) / period + variance = sum((p - middle) ** 2 for p in window) / period + std = variance ** 0.5 + return (middle + num_std * std, middle, middle - num_std * std) + + +# ── ADX ────────────────────────────────────────────────────────────────────── + +def adx_wilder(highs: List[float], lows: List[float], closes: List[float], + period: int = 14) -> Optional[float]: + """Wilder's ADX (mean_reversion_strategy.calculate_adx 와 동일) + + 1. TR / +DM / -DM 계산 + 2. TR·DM 평활: 초기값 = 첫 period 개 **합산**, 이후 `s - s/period + v` + 3. +DI = 100*(+DM_s / TR_s), -DI 동일 + 4. DX = 100*|+DI - -DI| / (+DI + -DI) + 5. ADX 평활: 초기값 = mean(DX[:period]), 이후 `a - a/period + dx/period` + (DM/TR 과 달리 **평균** 시드 + 정규화된 갱신) + - len < period*2 + 1 → None / DX 개수 < period → None + - 반환값은 `round(..., 2)` + """ + n = len(highs) + if n < period * 2 + 1: + return None + + trs: List[float] = [] + plus_dms: List[float] = [] + minus_dms: List[float] = [] + + for i in range(1, n): + h, l, c_prev = highs[i], lows[i], closes[i - 1] + h_prev, l_prev = highs[i - 1], lows[i - 1] + + tr = max(h - l, abs(h - c_prev), abs(l - c_prev)) + up_move = h - h_prev + down_move = l_prev - l + + plus_dm = up_move if up_move > down_move and up_move > 0 else 0.0 + minus_dm = down_move if down_move > up_move and down_move > 0 else 0.0 + + trs.append(tr) + plus_dms.append(plus_dm) + minus_dms.append(minus_dm) + + def _wilder_smooth(values: List[float]) -> List[float]: + """Wilder 평활: 초기값 = 첫 period개 합산, 이후 재귀 평활.""" + smoothed = [sum(values[:period])] + for v in values[period:]: + smoothed.append(smoothed[-1] - smoothed[-1] / period + v) + return smoothed + + atr_s = _wilder_smooth(trs) + plus_dm_s = _wilder_smooth(plus_dms) + minus_dm_s = _wilder_smooth(minus_dms) + + dxs: List[float] = [] + for atr_v, p_dm, m_dm in zip(atr_s, plus_dm_s, minus_dm_s): + if atr_v == 0: + dxs.append(0.0) + continue + plus_di = 100.0 * p_dm / atr_v + minus_di = 100.0 * m_dm / atr_v + di_sum = plus_di + minus_di + dxs.append(100.0 * abs(plus_di - minus_di) / di_sum if di_sum else 0.0) + + if len(dxs) < period: + return None + + adx_val = sum(dxs[:period]) / period # 초기값 = 평균 + for dx_v in dxs[period:]: + adx_val = adx_val - adx_val / period + dx_v / period + return round(adx_val, 2) diff --git a/backend/app/services/korea_investment_api.py b/backend/app/services/korea_investment_api.py index 022ba61..b14ba0a 100644 --- a/backend/app/services/korea_investment_api.py +++ b/backend/app/services/korea_investment_api.py @@ -953,11 +953,24 @@ class KoreaInvestmentAPI: if not candles: break + # [M4 2026-09-27] get_minute_chart 는 FID_PW_DATA_INCU_YN="Y"(과거 포함)로 + # 호출되므로 장 초반에는 전일 종반(15:00~15:20) 캔들이 섞여 들어온다. + # 시각(HHMMSS)만으로 dedupe/정렬하면 "152000" > "092000" 이라 전일 캔들이 + # 리스트 끝에 놓여 candles[-1] 기반 계산(ORB 거래량 폭발, VWAP)이 전일 값으로 + # 오염된다. 영업일자(stck_bsop_date, 응답 필드 — 본 파일 get_minute_chart + # docstring 참조)를 키에 포함하고, 당일 캔들만 유지한다. new_added = 0 for c in candles: t = c.get("stck_cntg_hour", "") - if t and t not in seen_times: - seen_times.add(t) + d = (c.get("stck_bsop_date") or "").strip() + if not t: + continue + # 영업일자가 있으면 당일이 아닌 캔들은 제외 (없으면 필터 불가 → 유지) + if d and d != today_str: + continue + key = f"{d}_{t}" + if key not in seen_times: + seen_times.add(key) all_candles.append(c) new_added += 1 @@ -970,8 +983,13 @@ class KoreaInvestmentAPI: ref = oldest await asyncio.sleep(self.min_request_interval + 0.05) - # 오래된 것 → 최신 순으로 정렬 - all_candles.sort(key=lambda c: c.get("stck_cntg_hour", "")) + # 오래된 것 → 최신 순으로 정렬 (영업일자 + 시각 — 날짜 누락 시 당일로 간주) + all_candles.sort( + key=lambda c: ( + (c.get("stck_bsop_date") or today_str).strip(), + c.get("stck_cntg_hour", ""), + ) + ) # ── 3. 캐시 저장 ──────────────────────────────────────────────────── if all_candles: diff --git a/backend/app/services/korea_investment_api_adapter.py b/backend/app/services/korea_investment_api_adapter.py deleted file mode 100644 index 1f8323e..0000000 --- a/backend/app/services/korea_investment_api_adapter.py +++ /dev/null @@ -1,106 +0,0 @@ -""" -Korea Investment API Adapter -기존 코드와의 호환성을 위한 어댑터 - -기존: from app.services.korea_investment_api import korea_investment_api -새 구조: app.trading.모듈로 분리 - -이 파일은 기존 코드가 수정 없이 동작하도록 보장합니다. -""" -from typing import Dict, Any, Optional -import logging - -from app.trading.kis_client import kis_client, real_market_client -from app.trading.order_manager import order_manager -from app.trading.market_data import market_data_provider - -logger = logging.getLogger(__name__) - - -class KoreaInvestmentAPIAdapter: - """기존 API 인터페이스 어댑터 (Backward Compatibility)""" - - def __init__(self): - self.client = kis_client - self.order_mgr = order_manager - self.market_data = market_data_provider - - # 기존 속성 노출 - self.base_url = self.client.base_url - self.app_key = self.client.app_key - self.app_secret = self.client.app_secret - self.account_number = self.client.account_number - self.access_token = self.client.access_token - self.token_expires_at = self.client.token_expires_at - - @classmethod - def for_real_market_data(cls) -> "KoreaInvestmentAPIAdapter": - """실계좌 시세조회 전용""" - instance = cls() - instance.client = real_market_client - instance.market_data = market_data_provider # real_client 사용 - return instance - - # ── 주문 관련 (위임) ──────────────────────────────────────────── - async def place_order(self, stock_code: str, order_type: str, quantity: int, price: Optional[float] = None): - return await self.order_mgr.place_order(stock_code, order_type, quantity, price) - - async def cancel_order(self, order_id: str, quantity: int): - return await self.order_mgr.cancel_order(order_id, quantity) - - async def get_balance(self): - return await self.order_mgr.get_balance() - - async def place_overseas_order(self, symbol: str, order_type: str, quantity: int, price: float, exchange_code: str = "NASD"): - return await self.order_mgr.place_overseas_order(symbol, order_type, quantity, price, exchange_code) - - async def get_overseas_balance(self): - return await self.order_mgr.get_overseas_balance() - - # ── 시세/차트 관련 (위임) ───────────────────────────────────── - async def get_current_price(self, stock_code: str): - return await self.market_data.get_current_price(stock_code) - - async def get_daily_chart(self, stock_code: str, period: str = "D", limit: int = 240): - return await self.market_data.get_daily_chart(stock_code, period, limit) - - async def get_minute_chart(self, stock_code: str, ref_time: str = "", include_past: bool = True): - return await self.market_data.get_minute_chart(stock_code, ref_time, include_past) - - async def get_overseas_price(self, symbol: str, exchange_code: str = "NASD"): - return await self.market_data.get_overseas_price(symbol, exchange_code) - - async def get_orderbook(self, stock_code: str): - return await self.market_data.get_orderbook(stock_code) - - async def get_volume_rank(self, market: str = "J"): - return await self.market_data.get_volume_rank(market) - - async def get_stock_list(self, market: str = "J"): - return await self.market_data.get_volume_rank(market) - - # ── 기타 (호환성) ─────────────────────────────────────────────── - async def _get_access_token(self): - """Deprecated: 직접 호출 불필요 (client가 자동 관리)""" - return await self.client.get_access_token() - - async def _make_request(self, method: str, endpoint: str, tr_id: str, extra_headers: Optional[Dict] = None, **kwargs): - """Deprecated: 직접 request 호출""" - return await self.client.request(method, endpoint, tr_id, extra_headers, **kwargs) - - def get_monitor_stats(self): - """API 통계 (향후 monitoring.py로 이동 예정)""" - return {"message": "Monitoring moved to trading.monitoring module"} - - -# 싱글톤 인스턴스 - 기존 코드에서 그대로 사용 가능 -korea_investment_api = KoreaInvestmentAPIAdapter() - -try: - real_market_api = KoreaInvestmentAPIAdapter.for_real_market_data() -except Exception: - real_market_api = None - - -# Backward compatibility alias -KoreaInvestmentAPI = KoreaInvestmentAPIAdapter diff --git a/backend/app/services/kosdaq_scanner.py b/backend/app/services/kosdaq_scanner.py deleted file mode 100644 index e4c6bb2..0000000 --- a/backend/app/services/kosdaq_scanner.py +++ /dev/null @@ -1,209 +0,0 @@ -""" -코스닥 전종목 스캐너 (CLAUDE.md 섹션 8.1 — 승률 최적화) - -매일 장 마감 후 코스닥 전종목(~1,600종목)을 스캔하여 -RSI + 볼린저 밴드 + 거래량 복합 점수로 상위 20종목을 선별한다. - -[승률 개선 항목] - 1. RSI 방향 필터: RSI가 바닥에서 상승 전환 중인 종목만 선별 - 2. 최소 점수 임계값 0.15 적용 (저품질 신호 제외) - 3. 승률 필드(rsi_turning) ScanResult에 노출 -""" -import logging -from dataclasses import dataclass, field -from typing import List, Optional - -logger = logging.getLogger(__name__) - -# 가격 필터 (CLAUDE.md 섹션 6.4) -MIN_PRICE_KRW = 5_000 - -# 전일 거래량 최솟값: 10만주 미만 종목 제외 (유동성 부족) -# market_analyzer.py와 동일 수준. LIQUIDITY_PERCENTILE=0.80 이 추가 필터 역할. -MIN_DAILY_VOLUME = 100_000 - -# 거래대금 상위 20% 임계값 (하위 80% 제외) -LIQUIDITY_PERCENTILE = 0.80 - -# 스캔 결과 상위 N개 -TOP_N = 20 - -# 승률 개선: 최소 점수 임계값 (저품질 신호 필터) -MIN_SCORE = 0.15 - -# RSI 방향 확인용 과거 봉 수 -RSI_DIRECTION_LOOKBACK = 3 - - -@dataclass -class ScanResult: - ticker: str - score: float - rsi14: float - pct_vs_bb_lower: float # (close - bb_lower) / bb_lower * 100 - volume_ratio: float # current_volume / volume_ma20 - close: float - rsi_turning: bool = False # RSI 반등 신호 여부 (승률 지표) - reason: str = "" - - -@dataclass -class TickerData: - """스캐너에 필요한 종목 데이터 구조체""" - ticker: str - close: float - closes: List[float] # 최근 60일 이상 종가 - volume: int - volumes: List[int] # 최근 20일 거래량 - turnover: float # 당일 거래대금 (원) - turnovers: List[float] # 최근 20일 거래대금 - - -class KosdaqScanner: - """코스닥 전종목 스캐너 (승률 최적화) - - 매일 장 마감 후 scan_all()을 호출하면 조건을 충족하는 상위 20종목을 - 점수 내림차순으로 반환한다. - - 스코어링 공식: - score = rsi_score × 0.5 + bb_score × 0.3 + volume_score × 0.2 - → score > 0.15 이상인 종목만 선별 (MIN_SCORE 필터) - - 승률 햵심 필터 (rsi_turning=True): - RSI가 바닥에서 반등 중인 종목은 ScanResult.rsi_turning=True로 표시되며, - 포트폴리오 매니저 쪽에서 제일 먼저 진입 처리하는 것을 권장한다. - """ - - def __init__(self, min_price: int = MIN_PRICE_KRW, top_n: int = TOP_N): - self.min_price = min_price - self.top_n = top_n - - # ------------------------------------------------------------------ - # Public API - # ------------------------------------------------------------------ - - def scan_all(self, tickers_data: List[TickerData]) -> List[ScanResult]: - """코스닥 전종목 스캔 → 상위 N개 ScanResult 반환 - - rsi_turning=True 종목은 리스트 앞쪽으로 정렬하여 - 포트폴리오 매니저가 우선 진입할 수 있도록 한다. - """ - liquidity_threshold = self._calc_liquidity_threshold(tickers_data) - - candidates: List[ScanResult] = [] - for td in tickers_data: - if not self._price_filter(td.close): - continue - if not self._volume_filter(td.volume): - continue - if not self._liquidity_filter(td.turnover, liquidity_threshold): - continue - result = self._score(td) - if result is not None: - candidates.append(result) - - # rsi_turning=True 종목 우선, 동점이면 점수 내림차순 - candidates.sort(key=lambda x: (not x.rsi_turning, -x.score)) - return candidates[: self.top_n] - - # ------------------------------------------------------------------ - # Filters - # ------------------------------------------------------------------ - - def _price_filter(self, close: float) -> bool: - """CLAUDE.md 섹션 6.4: 국내 5,000원 이하 매수 금지""" - return close >= self.min_price - - def _volume_filter(self, volume: int) -> bool: - """전일 거래량 100만주 미만 종목 제외 (유동성 부족)""" - return volume >= MIN_DAILY_VOLUME - - def _liquidity_filter(self, turnover: float, threshold: float) -> bool: - """거래대금 상위 20% 이내 필터""" - return turnover >= threshold - - # ------------------------------------------------------------------ - # Scoring - # ------------------------------------------------------------------ - - def _score(self, td: TickerData) -> Optional[ScanResult]: - """RSI + 볼린저 밴드 + 거래량 복합 점수 계산 - - 승률 개선: - - MIN_SCORE(0.15) 이하 종목 제외 - - RSI 반등 여부(rsi_turning) 노출 - """ - from .mean_reversion_strategy import calculate_rsi, calculate_bollinger - - closes = td.closes - min_len = 25 + RSI_DIRECTION_LOOKBACK - if len(closes) < min_len: - return None - - rsi14 = calculate_rsi(closes, 14) - bb = calculate_bollinger(closes, 20) - if rsi14 is None or bb is None: - return None - - _, _, bb_lower = bb - if bb_lower == 0: - return None - - volume_ma20 = sum(td.volumes[-20:]) / 20 if len(td.volumes) >= 20 else None - if volume_ma20 is None or volume_ma20 == 0: - return None - - pct_vs_lower = (td.close - bb_lower) / bb_lower * 100 - volume_ratio = td.volume / volume_ma20 - - # [S8] RSI 점수: config의 MEAN_REVERSION_RSI_THRESHOLD(46.0) 기준으로 정규화 - from app.core.config import settings as _settings - _rsi_threshold = _settings.MEAN_REVERSION_RSI_THRESHOLD # 46.0 - rsi_score = max(0.0, (_rsi_threshold - rsi14) / _rsi_threshold) - # [S8] BB 점수: CLAUDE.md 6.2 기준 -3%~+1.5% 구간 보상 - # pct_vs_lower <= -3%: 최대 점수 1.0, +1.5% 이상: 점수 0.0 - bb_score = min(max(0.0, (1.5 - pct_vs_lower) / 4.5), 1.0) - volume_score = min(volume_ratio, 2.0) - total_score = rsi_score * 0.5 + bb_score * 0.3 + volume_score * 0.2 - - # 승률 필터: 최소 점수 미달 제외 - if total_score <= MIN_SCORE: - return None - - # RSI 반등 방향 확인 (승률 지표) - rsi_prev = calculate_rsi(closes[:-RSI_DIRECTION_LOOKBACK], 14) - rsi_turning = rsi_prev is not None and rsi14 > rsi_prev - - reason_parts = [] - if rsi14 <= _rsi_threshold: - reason_parts.append(f"RSI({rsi14:.1f})≤{_rsi_threshold:.0f}") - if pct_vs_lower <= 0: - reason_parts.append(f"BB하단({pct_vs_lower:.1f}%)") - if volume_ratio >= 1.5: - reason_parts.append(f"거래량({volume_ratio:.1f}x)") - if rsi_turning: - reason_parts.append("RSI반등↑") - - return ScanResult( - ticker=td.ticker, - score=round(total_score, 4), - rsi14=round(rsi14, 2), - pct_vs_bb_lower=round(pct_vs_lower, 2), - volume_ratio=round(volume_ratio, 2), - close=td.close, - rsi_turning=rsi_turning, - reason=" + ".join(reason_parts) if reason_parts else "복합점수", - ) - - # ------------------------------------------------------------------ - # Helpers - # ------------------------------------------------------------------ - - @staticmethod - def _calc_liquidity_threshold(tickers_data: List[TickerData]) -> float: - """거래대금 상위 20% 임계값 (80번째 백분위수)""" - turnovers = sorted([td.turnover for td in tickers_data if td.turnover > 0]) - if not turnovers: - return 0.0 - idx = int(len(turnovers) * LIQUIDITY_PERCENTILE) - return turnovers[min(idx, len(turnovers) - 1)] diff --git a/backend/app/services/loss_tracker.py b/backend/app/services/loss_tracker.py index b35ed26..e34dd60 100644 --- a/backend/app/services/loss_tracker.py +++ b/backend/app/services/loss_tracker.py @@ -232,9 +232,15 @@ class ConsecutiveLossTracker: def _create_loss_tracker() -> ConsecutiveLossTracker: try: from app.core.config import settings + # [HIGH-3 수정] 하드코딩 42 → settings.CONSECUTIVE_LOSS_BAN_DAYS(기본 42캘린더일). + # 운영자가 config/환경변수로 ban 기간을 조정해도 실제 동작에 반영되지 않던 문제 해소. + # 설정값이 비정상(<=0)이면 문서 기준값 42로 폴백한다. + _ban_days = int(getattr(settings, "CONSECUTIVE_LOSS_BAN_DAYS", 42) or 42) + if _ban_days <= 0: + _ban_days = 42 return ConsecutiveLossTracker( ban_count=settings.CONSECUTIVE_LOSS_BAN_COUNT, - ban_calendar_days=42, # 30거래일 ≈ 42캘린더일 + ban_calendar_days=_ban_days, # 30거래일 ≈ 42캘린더일 (CLAUDE.md §5.1) ) except Exception: return ConsecutiveLossTracker() diff --git a/backend/app/services/mdd_manager.py b/backend/app/services/mdd_manager.py index d95b9c7..308b18d 100644 --- a/backend/app/services/mdd_manager.py +++ b/backend/app/services/mdd_manager.py @@ -10,7 +10,11 @@ import json import logging from typing import Dict, Optional +from app.core.config import settings from app.core.json_helpers import _dumps +# [2026-09-27 §8.5 항목6] DT 전략명 정본은 db_worker (순환 import 없음 — db_worker 는 +# 모듈 레벨에서 서비스 모듈을 import 하지 않는다). +from app.services.db_worker import DT_STRATEGY_NAMES as _DT_STRATEGY_NAMES logger = logging.getLogger(__name__) @@ -28,6 +32,15 @@ class MDDManager: self._reduction_executed_10pct: bool = False # 중복 축소 실행 방지 self._reduction_executed_20pct: bool = False self._lock = asyncio.Lock() # 스케줄러-sync 동시 접근 방지 + # ── [항목7 2026-09-27] MDD 갱신 헬스 (fail-safe 판정용) ────────────── + # 갱신 경로(swing/scheduler)가 record_update_failure/success 로 여기에 보고한다. + # 상태를 mdd_manager 가 소유해야 can_open_new_position() 이 순환 import 없이 + # 판정할 수 있다(scheduler → mdd_manager 는 모듈 레벨 import 이므로 역방향 금지). + self._update_failures: int = 0 + self._update_last_error: Optional[str] = None + self._update_empty_account: bool = False + self._blind_block_active: bool = False + self._blind_block_hits: int = 0 # 차단 발동 누적 횟수(로그 레이트 리밋용) async def save_to_redis(self) -> None: """MDD 상태를 Redis에 영속화 — 앱 재시작 시 복원용.""" @@ -186,9 +199,82 @@ class MDDManager: logger.warning(f"[MDD v2] Redis 복원 실패 (user={user_id}): {e}") return False - async def can_open_new_position(self) -> bool: - """신규 포지션 진입 가능 여부 (thread-safe)""" + # ── [항목7] MDD 갱신 헬스 보고/조회 ──────────────────────────────────────── + # 기존 시그니처 변경 없이 새 메서드로만 추가한다. + + async def record_update_failure(self, error: str) -> int: + """MDD 갱신 실패를 기록하고 누적 연속 실패 횟수를 반환. + + 갱신 경로(swing/scheduler)의 except 절에서 호출한다. + 여기서 누적된 횟수가 MDD_BLIND_FAILURE_THRESHOLD 이상이면 + can_open_new_position() 이 신규 진입을 차단한다(청산은 영향 없음). + """ async with self._lock: + self._update_failures += 1 + self._update_last_error = str(error) + self._update_empty_account = False + return self._update_failures + + async def record_update_success(self, empty_account: bool = False) -> None: + """MDD 갱신 성공(또는 '정상 응답 + 빈 계좌')을 기록해 실패 카운터를 리셋. + + empty_account=True 는 tot_evlu_amt<=0(미입금 계좌)로 갱신을 스킵한 경우다. + 장애가 아니므로 실패로 계수하지 않고, 진입 차단 사유도 되지 않는다. + """ + async with self._lock: + self._update_failures = 0 + self._update_last_error = None + self._update_empty_account = bool(empty_account) + self._blind_block_active = False + self._blind_block_hits = 0 + + def _is_update_blind(self) -> bool: + """MDD 갱신이 '눈이 먼' 상태인지 판정 (Lock 보유 중 호출, DB/IO 없음). + + - 빈 계좌(empty_account)는 차단 사유 아님 — 자산 0이면 애초에 매수 불가이고 + 입금 직후 첫 사이클을 막을 이유가 없다. + - 설정으로 끌 수 있으나 기본값은 True(config.MDD_BLIND_BLOCK_ENTRY). + """ + if not settings.MDD_BLIND_BLOCK_ENTRY: + return False + if self._update_empty_account: + return False + threshold = max(1, int(settings.MDD_BLIND_FAILURE_THRESHOLD)) + return self._update_failures >= threshold + + def get_update_health(self) -> dict: + """MDD 갱신 헬스 스냅샷 (동기·읽기 전용). 차단 여부 관측용.""" + return { + "consecutive_failures": self._update_failures, + "last_error": self._update_last_error, + "empty_account": self._update_empty_account, + "blind_block_enabled": bool(settings.MDD_BLIND_BLOCK_ENTRY), + "blind_block_threshold": int(settings.MDD_BLIND_FAILURE_THRESHOLD), + "blind_block_active": self._blind_block_active, + "blind_block_hits": self._blind_block_hits, + } + + async def can_open_new_position(self) -> bool: + """신규 포지션 진입 가능 여부 (thread-safe) + + [항목7 2026-09-27] MDD 갱신이 연속 실패 중이면(= -10%/-20% 게이트가 작동 + 불가) 신규 진입을 차단한다. 모든 진입 경로가 이 메서드를 await 하므로 + 여기서 한 번에 적용된다. 시그니처 불변, 청산 경로에는 영향 없음. + """ + async with self._lock: + blind = self._is_update_blind() + self._blind_block_active = blind + if blind: + self._blind_block_hits += 1 + if self._blind_block_hits == 1 or self._blind_block_hits % 20 == 0: + logger.error( + f"[MDD] 🚨 신규 진입 차단(fail-safe): MDD 갱신 연속 실패 " + f"{self._update_failures}회 (임계 {settings.MDD_BLIND_FAILURE_THRESHOLD}) — " + f"peak/drawdown 미갱신으로 -10%/-20% 게이트가 작동하지 않음. " + f"마지막 오류: {self._update_last_error} " + f"(누적 차단 {self._blind_block_hits}회, 청산은 정상 동작)" + ) + return False return not self.is_paused async def is_short_term_strategy_enabled(self) -> bool: @@ -197,14 +283,31 @@ class MDDManager: return not self.is_short_term_strategy_paused async def reset(self): - """MDD 상태 리셋 (수동 복구 시) — async + Lock으로 동시성 안전 보장""" + """MDD 상태 리셋 (수동 복구 시) — async + Lock으로 동시성 안전 보장 + + [2026-09-27 fail-safe 해제 누락 수정] MDD 갱신 연속 실패로 인한 + fail-safe 차단(can_open_new_position() 이 False 를 반환하는 상태)은 + 기존에는 여기서 초기화되지 않아, 운영자가 reset()을 호출해도 KIS 장애가 + 스스로 해소될 때까지 신규 진입이 계속 막혀 있었다. 호출부(관리자용 + 수동 초기화 엔드포인트, "신규 진입 차단 해제"가 목적)의 의도에 맞춰 + fail-safe 카운터/플래그도 함께 초기화한다. peak_balance/drawdown 관련 + 상태는 기존 그대로 건드리지 않는다. + """ async with self._lock: self.is_paused = False self.is_short_term_strategy_paused = False self.reduction_applied = None self._reduction_executed_10pct = False self._reduction_executed_20pct = False + was_blind = self._blind_block_active or self._update_failures > 0 + self._update_failures = 0 + self._update_last_error = None + self._update_empty_account = False + self._blind_block_active = False + self._blind_block_hits = 0 logger.info("MDD 관리자 리셋") + if was_blind: + logger.info("[MDD] fail-safe 차단 해제 — 갱신 실패 카운터/차단 플래그 초기화됨") await self.save_to_redis() @@ -313,24 +416,16 @@ class MDDManager: "strategy_type": "mdd_reduction", }) - # [FIX] 전량 매도 시 ActiveTrade 닫기 + # [FIX] 전량 매도 시 ActiveTrade 닫기 / 부분 매도 시 잔량 갱신 + # [M8 2026-09-27] 전략명을 비우면 db_worker 가 '가장 최근 active 행'을 + # 매칭하므로 같은 종목에 DT 행이 있으면 DT 행이 닫히고 스윙 행이 영구 잔존한다. + # → DT 인메모리 포지션이 있는 종목은 전략명을 명시해 처리한다. remaining_qty = quantity - sell_qty - if remaining_qty <= 0: - from app.services.db_worker import enqueue_close_active_trade - await enqueue_close_active_trade(stock_code) - else: - # 부분 매도: remaining_pct 갱신 - # 기존 ActiveTrade의 remaining_pct에 매도 비율을 곱해야 함 - # (예: 기존 0.5 * 남은비율 0.7 = 0.35) - # db_worker가 직접 setattr하므로, reduce_ratio를 전달하여 - # 워커가 기존 값에 곱하도록 처리 - from app.services.db_worker import enqueue_active_trade_upsert - sell_ratio = remaining_qty / quantity # 남은 비율 (0.7 or 0.5) - await enqueue_active_trade_upsert({ - "stock_code": stock_code, - "remaining_pct_multiply": sell_ratio, - "quantity": remaining_qty, # 잔량 갱신(미갱신 시 다음 청산이 과다매도) - }) + await _sync_active_trade_after_reduction( + stock_code=stock_code, + quantity=quantity, + remaining_qty=remaining_qty, + ) # [2026-09-23] 축소 체결분을 Portfolio·DT 인메모리에도 반영. # 누락 시 다음 청산이 축소 전 수량으로 매도한다(과다매도). @@ -486,17 +581,14 @@ class MDDManager: "strategy_type": "mdd_reduction", }) + # [M8 2026-09-27] v1 과 동일 — DT 행 오폐기 방지 (전략명 명시) remaining_qty = quantity - sell_qty - if remaining_qty <= 0: - await enqueue_close_active_trade_v2(user_id, stock_code, strategy_name=None) - else: - sell_ratio = remaining_qty / quantity - await enqueue_active_trade_upsert({ - "user_id": user_id, - "stock_code": stock_code, - "remaining_pct_multiply": sell_ratio, - "quantity": remaining_qty, - }) + await _sync_active_trade_after_reduction( + stock_code=stock_code, + quantity=quantity, + remaining_qty=remaining_qty, + user_id=user_id, + ) # [2026-09-23] 축소 체결분을 Portfolio·DT 인메모리에도 반영. # 누락 시 다음 청산이 축소 전 수량으로 매도한다(과다매도). @@ -542,6 +634,210 @@ class MDDManager: mdd_manager = MDDManager() +# [M8] 비-데이트레이딩(스윙 계열) ActiveTrade.strategy_name 값 전수 목록 +# db_worker 는 strategy_name 동등 비교만 지원하므로 '!= day_trade' 를 표현할 수 없다. +# → 비-DT 후보 전략명을 개별로 지정해 DT 행을 절대 건드리지 않는다. +# +# ⚠️ 경고: 이 목록은 `ActiveTrade.strategy_name` 에 기록되는 **모든 비-DT 값**을 덮어야 +# 한다. 하나라도 빠지면 MDD 축소가 그 행에 반영되지 않아(db_worker 가 '필수 키 누락' +# 으로 스킵) quantity/remaining_pct 가 축소 전 값으로 남고, 다음 청산이 과다매도 → +# 주문거부를 일으킨다. 전량 축소 경로에서는 해당 행이 영구히 status='active' 로 남는다. +# +# [조사 방법 — 전략명 추가/변경 시 반드시 재실행] +# 1) grep -rn 'strategy_name' backend/app --include='*.py' \ +# | grep -E "strategy_name(\"|')?\]?\s*[:=]" +# → enqueue_active_trade_upsert / ActiveTrade(...) 로 들어가는 리터럴 수집 +# 2) grep -rn 'strategy_type' backend/app --include='*.py' | grep -E "strategy_type.*[:=]" +# → trade_executor 는 `stock.get("strategy_name") or stock.get("strategy_type","swing")`, +# portfolio 동기화는 `strategy_name=_h.strategy_type or "swing"` 이므로 +# Portfolio.strategy_type 값도 그대로 ActiveTrade.strategy_name 이 된다. +# 3) 수집값에서 DT 계열(DT_STRATEGY_NAMES)만 제외하고 나머지 전부를 여기에 넣는다. +# +# 2026-09-27 조사 결과: +# "swing" — trade_executor.py:1105 기본값 / swing/entry_service.py:288 +# / portfolio.py:873, :1317 +# "MOMENTUM_BREAKOUT" — swing/entry_service.py:291 +# "PULLBACK_BUY" — swing/entry_service.py:313 +# "manual" — manual_buy.py:156(+:168), portfolio_v2.py:382(+:402), +# portfolio.py:1536 → Portfolio.strategy_type='manual' 경유 +# "us_swing" — us_market_scheduler.py:374, :577 (Portfolio.strategy_type) +# "us_mean_reversion" — us_market_scheduler.py:306, :406 (ActiveTrade 직접) +# "synced" — portfolio.py sync_portfolio 외부 유입 포지션 시딩(§8.5 항목4③) +# +# [2026-09-27 §8.5 항목6] 아래 목록은 **참조/문서용**으로만 남는다. +# 축소 반영 경로는 열거 대신 payload 의 `strategy_name_not=DT_STRATEGY_NAMES` +# (= strategy_name IS NULL OR NOT IN (DT)) 한 건으로 처리하므로, 목록에서 빠진 +# 전략명이 있어도 누락되지 않는다(NULL·미등재 전략명까지 자동 포함). +SWING_STRATEGY_NAMES = ( + "swing", + "MOMENTUM_BREAKOUT", + "PULLBACK_BUY", + "manual", + "us_swing", + "us_mean_reversion", + "synced", +) + +# 데이트레이딩 계열 ActiveTrade.strategy_name 값 — 정본은 db_worker. +# "day_trade" — daytrading/entry_service.py, exit_service.py +# "daytrading" — Portfolio.strategy_type='daytrading' 이 동기화로 그대로 들어가는 경우 +# [2026-09-27 §8.5 항목6] 이중 정의 제거 — 정본은 db_worker.DT_STRATEGY_NAMES +# (모듈 상단에서 import, not_dt_strategy_clause 와 동일 목록 공유). +# db_worker 는 모듈 레벨에서 어떤 서비스 모듈도 import 하지 않으므로 +# (asyncio/sqlalchemy/core.database/models.models 뿐) 순환 참조가 없다. +# 재노출: 기존 `from app.services.mdd_manager import DT_STRATEGY_NAMES` 호출부 호환 유지. +DT_STRATEGY_NAMES = _DT_STRATEGY_NAMES + + +def _dt_position_qty(stock_code: str) -> int: + """데이트레이딩 인메모리 포지션 수량 (조회 실패 시 0). DB 접근 없음.""" + try: + from app.services.daytrading.position_manager import day_trading_state + pos = day_trading_state.positions.get(stock_code) + return int((pos or {}).get("quantity", 0) or 0) + except Exception: + return 0 + + +async def _sync_active_trade_after_reduction( + stock_code: str, + quantity: int, + remaining_qty: int, + user_id: Optional[int] = None, +) -> None: + """MDD 축소 체결을 ActiveTrade 에 반영 (전략 귀속을 명시). + + db_worker 의 close_active_trade / upsert_active_trade 는 strategy_name 이 + 비어 있으면 '가장 최근 active 행'을 매칭한다. 같은 종목에 데이트레이딩 행이 + 있으면 DT 행이 닫히거나 잔량이 덮어써지고 스윙 행은 영구 잔존한다. + + 처리 규칙: + - DT 인메모리 포지션 없음 → 기존 동작 유지(전략명 없이 최신 행 1건) + - DT 인메모리 포지션 있음 → + * 전량 축소: 비-DT 행 + DT 행을 각각 닫음(모두 청산됐으므로) + * 부분 축소: 비-DT 행만 갱신 (DT 잔량은 DT 자체 청산 경로가 갱신) + + [2026-09-27 §8.5 항목6] 전략명 열거(SWING_STRATEGY_NAMES 루프) → payload 필터 + 1건으로 대체. 열거 방식은 (a) 목록에 없는/NULL 전략명 행을 놓치고, + (b) 미존재 전략명마다 db_worker 의 '필수 키 누락' 경고 로그를 남겼다. + - 비-DT 대상: strategy_name_not=DT_STRATEGY_NAMES (NULL 포함, DT 만 제외) + - DT 대상 : strategy_names=DT_STRATEGY_NAMES (IN) + + 시그니처 변경 없이 기존 enqueue_* / enqueue_db_write 인자 범위 내에서만 처리한다. + """ + from app.services.db_worker import ( + enqueue_active_trade_upsert, + enqueue_close_active_trade, + enqueue_close_active_trade_v2, + enqueue_db_write, + ) + + dt_qty = _dt_position_qty(stock_code) + + async def _close(strategy_name: Optional[str]) -> None: + if user_id is not None: + await enqueue_close_active_trade_v2(user_id, stock_code, strategy_name=strategy_name) + else: + await enqueue_close_active_trade(stock_code, strategy_name=strategy_name) + + async def _close_filtered(**filters) -> None: + """전략명 확장 필터(strategy_names / strategy_name_not)로 close 태스크 큐잉. + + enqueue_close_active_trade 시그니처는 strategy_name 만 받으므로 + (시그니처 불변 원칙) 범용 enqueue_db_write 로 payload 를 직접 넘긴다. + """ + data: dict = {"stock_code": stock_code, **filters} + if user_id is not None: + data["user_id"] = user_id + await enqueue_db_write({"type": "close_active_trade", "data": data}) + + async def _upsert(strategy_name: Optional[str], ratio: float, qty: int) -> None: + payload: dict = { + "stock_code": stock_code, + "remaining_pct_multiply": ratio, + # 잔량 갱신(미갱신 시 다음 청산이 축소 전 수량으로 과다매도) + "quantity": qty, + } + if strategy_name: + payload["strategy_name"] = strategy_name + if user_id is not None: + payload["user_id"] = user_id + await enqueue_active_trade_upsert(payload) + + async def _upsert_non_dt(ratio: float, qty: int) -> None: + """비-DT(스윙 계열) 최신 active 행 1건만 잔량 갱신. DT 행은 절대 건드리지 않음.""" + payload: dict = { + "stock_code": stock_code, + "remaining_pct_multiply": ratio, + "quantity": qty, + "strategy_name_not": DT_STRATEGY_NAMES, + } + if user_id is not None: + payload["user_id"] = user_id + await enqueue_active_trade_upsert(payload) + + if remaining_qty <= 0: + if dt_qty > 0: + # 전량 청산 — 비-DT 행과 DT 행을 모두 닫는다(대상 범위 불변). + await _close_filtered(strategy_name_not=DT_STRATEGY_NAMES) + await _close_filtered(strategy_names=list(DT_STRATEGY_NAMES)) + logger.warning( + f"[MDD] {stock_code}: DT {dt_qty}주 병존 — 비-DT/DT ActiveTrade 각각 종료" + ) + else: + await _close(None) + return + + # 부분 축소: 남은 비율을 기존 remaining_pct 에 곱한다 (예: 기존 0.5 × 0.7 = 0.35) + sell_ratio = (remaining_qty / quantity) if quantity > 0 else 1.0 + if dt_qty > 0: + # 스윙 행의 잔량 = 전체 잔량 − DT 잔량 (DT 는 체결분만큼 인메모리에서 차감됨) + _sold = max(0, quantity - remaining_qty) + _dt_left = max(0, dt_qty - _sold) + _swing_left = max(0, remaining_qty - _dt_left) + # [M1 2026-09-27] 배율은 반드시 '스윙 수량 기준' 으로 계산한다. + # 총수량(스윙+DT) 기준 sell_ratio 를 곱하면 스윙 행의 remaining_pct 가 + # 과소평가되어(예: 정답 0.875 대신 0.70) _check_momentum_exit 의 + # 1차 익절 게이트(remaining_pct > 0.75)가 영구 미발동한다. + _swing_entry_qty = max(0, quantity - dt_qty) + if _swing_entry_qty > 0: + _swing_ratio = min(1.0, max(0.0, _swing_left / _swing_entry_qty)) + else: + _swing_ratio = 1.0 + if _swing_left <= 0: + # [M1-guard 2026-09-27] _close() 는 '관리 대상에서 제거'라는 과도한 부작용이 + # 있으므로 인메모리 DT 수량을 신뢰할 수 있을 때만 허용한다. + # quantity 는 KIS 잔고(hldg_qty), dt_qty 는 인메모리 → dt_qty > quantity 면 + # 인메모리가 잔고보다 큼 = stale 확정(외부 경로로 DT 분이 이미 매도된 경우). + # 이때 _swing_left 는 허위 0 이므로 close 하면 실제 보유 중인 스윙 행이 + # status='closed' 가 되어 손절·트레일링 관리가 사라진다. + if dt_qty > quantity: + # 보수적 처리: 잔고 전량을 스윙분으로 간주하고 잔량만 갱신(close 금지). + # 아무것도 안 하면 quantity 가 축소 전 값으로 남아 다음 청산이 과다매도한다. + await _upsert_non_dt(sell_ratio, remaining_qty) + logger.warning( + f"[MDD] {stock_code}: DT 인메모리 수량이 잔고를 초과 — stale 판단 " + f"(잔고 quantity={quantity}, 인메모리 dt_qty={dt_qty}, 매도 {_sold}주) → " + f"스윙 ActiveTrade close 생략, 잔량 {remaining_qty}주·배율 {sell_ratio:.4f} 로 갱신" + ) + return + # 스윙분은 전량 소진, 잔량은 전부 DT — quantity=0 유령 active 행을 남기지 않는다. + await _close_filtered(strategy_name_not=DT_STRATEGY_NAMES) + logger.warning( + f"[MDD] {stock_code}: 스윙분 전량 축소(잔량 {remaining_qty}주는 DT) — " + f"스윙 ActiveTrade 종료, DT 행은 DT 청산 경로에 위임" + ) + return + await _upsert_non_dt(_swing_ratio, _swing_left) + logger.warning( + f"[MDD] {stock_code}: DT {dt_qty}주 병존 — 스윙 ActiveTrade 만 잔량 갱신 " + f"(스윙 잔량={_swing_left}/{_swing_entry_qty}, 배율={_swing_ratio:.4f}, " + f"DT 잔량={_dt_left}, DT 행은 DT 청산 경로에 위임)" + ) + else: + await _upsert(None, sell_ratio, remaining_qty) + + async def _reflect_reduction( stock_code: str, sell_qty: int, price: float, remaining_qty: int ) -> None: @@ -555,17 +851,48 @@ async def _reflect_reduction( except Exception as e: logger.warning(f"[MDD] Portfolio 반영 실패 ({stock_code}): {e}") + # [항목11 2026-09-27] 인메모리 차감 규칙을 _sync_active_trade_after_reduction 과 통일. + # ActiveTrade 측 규칙: _dt_left = max(0, dt_qty - _sold) (= DT 우선 차감) + # 단, dt_qty > quantity(KIS 잔고) 면 stale 로 보고 close 를 생략. + # 기존 코드는 DT 우선 차감(max(0, dt-sell))까지는 같았지만 stale 케이스를 가드하지 + # 않아, 인메모리 잔량이 실제 잔고(remaining_qty)를 초과한 채 남았다. + # 예) 잔고 quantity=10, 인메모리 dt_qty=15, 매도 3주 → 잔고 7주인데 인메모리 12주 + # → 다음 DT 청산이 12주 매도를 시도해 과다매도/주문거부. + # 따라서 (1) DT 우선 차감, (2) 실제 잔고 잔량으로 상한 클램프, (3) 음수 금지. try: from app.services.daytrading.position_manager import day_trading_state async with day_trading_state.trade_lock: pos = day_trading_state.positions.get(stock_code) if pos: - _left = max(0, int(pos.get("quantity", 0) or 0) - sell_qty) + _dt_now = max(0, int(pos.get("quantity", 0) or 0)) + _balance_before = max(0, sell_qty + remaining_qty) # = KIS 잔고(quantity) + _stale = _dt_now > _balance_before + if _stale: + # 인메모리가 잔고보다 큼 = 이미 외부 경로로 매도된 분이 반영 안 됨. + # 여기서 또 sell_qty 를 빼면 실제 잔고와 더 벌어지므로, 중복 차감 없이 + # 실제 잔량으로 클램프만 한다(_sync 의 보수적 stale 처리와 동일 취지). + _left = min(_dt_now, remaining_qty) + logger.warning( + f"[MDD] {stock_code}: DT 인메모리 수량이 잔고를 초과 — stale 판단 " + f"(잔고 {_balance_before}주, 인메모리 {_dt_now}주, 매도 {sell_qty}주) → " + f"중복 차감 생략, 실제 잔량 {remaining_qty}주로 클램프" + ) + else: + # DT 우선 차감 (ActiveTrade 의 _dt_left 계산과 동일 규칙) + _left = max(0, _dt_now - sell_qty) + # 잔고 잔량 초과 방지 (이론상 불가하나 이중 방어) + _left = min(_left, remaining_qty) if _left <= 0: day_trading_state.remove_position(stock_code) - logger.warning(f"[MDD] DT 포지션 전량 축소: {stock_code}") + logger.warning( + f"[MDD] DT 포지션 전량 축소: {stock_code} " + f"(인메모리 {_dt_now}주 → 0, 매도 {sell_qty}주, 잔고 잔량 {remaining_qty}주)" + ) else: pos["quantity"] = _left - logger.warning(f"[MDD] DT 포지션 잔량 갱신: {stock_code} → {_left}주") + logger.warning( + f"[MDD] DT 포지션 잔량 갱신: {stock_code} {_dt_now} → {_left}주 " + f"(매도 {sell_qty}주, 잔고 잔량 {remaining_qty}주)" + ) except Exception as e: logger.warning(f"[MDD] DT 인메모리 반영 실패 ({stock_code}): {e}") diff --git a/backend/app/services/mean_reversion_strategy.py b/backend/app/services/mean_reversion_strategy.py index 6fe27b4..71780d0 100644 --- a/backend/app/services/mean_reversion_strategy.py +++ b/backend/app/services/mean_reversion_strategy.py @@ -17,7 +17,7 @@ RSI + 볼린저 밴드 평균회귀 전략 (CLAUDE.md 섹션 7.1-7.5) [2026-03-15 CLAUDE.md 정합성 수정] ① detect_market_regime: ADX < 20 → SIDEWAYS 판별 추가 (CLAUDE.md 6.1) ② check_buy_signal: SIDEWAYS 국면 진입 차단 (CLAUDE.md 6.1) - ③ check_buy_signal: BULL RSI ≤30→<40, BEAR RSI ≤35→<30 (CLAUDE.md 6.2) + ③ check_buy_signal: BULL/BEAR RSI 임계는 config·adaptive 로 결정 (CLAUDE.md §0/§6) ④ check_buy_signal: BEAR 거래량 prev_volume→1.5×avg_volume_20d (CLAUDE.md 6.2) ⑤ check_exit_signal: 1차 익절 AND→OR, RSI 60→50, ATR 1.5→2.0 (CLAUDE.md 6.3) ⑥ check_exit_signal: 분할매도 Bull 25%/Bear 40%→50% 통일 (CLAUDE.md 5.2) @@ -35,6 +35,15 @@ from dataclasses import dataclass from enum import Enum from app.services.strategy_config_service import strategy_cfg +# §8.5-1 지표 계산 단일 출처 (순수 계산 모듈 — I/O 없음) +from app.services.indicators_core import ( + adx_wilder, + atr_sma, + bollinger_sma, + rsi_wilder, +) +# §8.5-2 포지션 사이징 단일 출처 (순수 계산 모듈 — settings/I/O 없음) +from app.services.sizing_core import budget_by_ratios, clamp_by_ratios logger = logging.getLogger(__name__) @@ -60,19 +69,11 @@ def calculate_atr(highs: List[float], lows: List[float], closes: List[float], pe TR = max(H-L, |H-Cprev|, |L-Cprev|) ATR = SMA(TR, period) + + [2026-09-27 §8.5-1 중복 통합] 계산 본체는 indicators_core.atr_sma() 로 이동. + 이 함수는 시그니처·반환형 호환을 위한 얇은 위임(wrapper)이며 수치는 동일하다. """ - if len(highs) < period + 1 or len(lows) < period + 1 or len(closes) < period + 1: - return None - - true_ranges = [] - for i in range(1, len(highs)): - h, l, c_prev = highs[i], lows[i], closes[i - 1] - tr = max(h - l, abs(h - c_prev), abs(l - c_prev)) - true_ranges.append(tr) - - if len(true_ranges) < period: - return None - return sum(true_ranges[-period:]) / period + return atr_sma(highs, lows, closes, period) def calculate_adx(highs: List[float], lows: List[float], closes: List[float], period: int = 14) -> Optional[float]: @@ -91,65 +92,19 @@ def calculate_adx(highs: List[float], lows: List[float], closes: List[float], pe - ADX < 20: 횡보장 → 평균회귀 유리 - ADX 20~30: 중간 추세 → 조건부 허용 - ADX > 30: 강한 추세 → 평균회귀 위험 (진입 차단) + + [2026-09-27 §8.5-1 중복 통합] 계산 본체는 indicators_core.adx_wilder() 로 이동. + ADX 평활 초기값이 mean(DX[:period])이고 갱신식이 `a - a/p + dx/p` 인 점 + (아래 [FIX] 주석의 발산 버그 수정분)까지 그대로 옮겼다. 수치 동일. + SIDEWAYS 판정 로직(detect_market_regime)은 변경하지 않았다. + + # [FIX] Wilder ADX 평활: ADX = ADX_prev*(p-1)/p + DX/p + # = ADX_prev - ADX_prev/p + DX/p. 기존엔 마지막 항의 /period가 누락돼 + # (+ dx_v) DX(0~100)를 그대로 더해 ADX가 100을 넘어 발산 → SIDEWAYS + # 게이트(ADX<20)가 실서비스에서 영구 무력화되던 버그. (CLAUDE.md 6.1) """ - n = len(highs) - # 최소 2*period + 1개 필요 (초기 평활 + ADX 평활) - if n < period * 2 + 1: - return None + return adx_wilder(highs, lows, closes, period) - trs: List[float] = [] - plus_dms: List[float] = [] - minus_dms: List[float] = [] - - for i in range(1, n): - h, l, c_prev = highs[i], lows[i], closes[i - 1] - h_prev, l_prev = highs[i - 1], lows[i - 1] - - tr = max(h - l, abs(h - c_prev), abs(l - c_prev)) - up_move = h - h_prev - down_move = l_prev - l - - plus_dm = up_move if up_move > down_move and up_move > 0 else 0.0 - minus_dm = down_move if down_move > up_move and down_move > 0 else 0.0 - - trs.append(tr) - plus_dms.append(plus_dm) - minus_dms.append(minus_dm) - - def _wilder_smooth(values: List[float]) -> List[float]: - """Wilder 평활: 초기값 = 첫 period개 합산, 이후 재귀 평활.""" - smoothed = [sum(values[:period])] - for v in values[period:]: - smoothed.append(smoothed[-1] - smoothed[-1] / period + v) - return smoothed - - atr_s = _wilder_smooth(trs) - plus_dm_s = _wilder_smooth(plus_dms) - minus_dm_s = _wilder_smooth(minus_dms) - - dxs: List[float] = [] - for atr_v, p_dm, m_dm in zip(atr_s, plus_dm_s, minus_dm_s): - if atr_v == 0: - dxs.append(0.0) - continue - plus_di = 100.0 * p_dm / atr_v - minus_di = 100.0 * m_dm / atr_v - di_sum = plus_di + minus_di - dxs.append(100.0 * abs(plus_di - minus_di) / di_sum if di_sum else 0.0) - - if len(dxs) < period: - return None - - # ADX 평활: 초기값은 mean(DX[:period]) — ATR/DM과 달리 합산이 아닌 평균 - # _wilder_smooth()는 초기값이 sum이므로 ADX에는 인라인 계산 사용 - adx_val = sum(dxs[:period]) / period # 초기값 = 평균 - for dx_v in dxs[period:]: - # [FIX] Wilder ADX 평활: ADX = ADX_prev*(p-1)/p + DX/p - # = ADX_prev - ADX_prev/p + DX/p. 기존엔 마지막 항의 /period가 누락돼 - # (+ dx_v) DX(0~100)를 그대로 더해 ADX가 100을 넘어 발산 → SIDEWAYS - # 게이트(ADX<20)가 실서비스에서 영구 무력화되던 버그. (CLAUDE.md 6.1) - adx_val = adx_val - adx_val / period + dx_v / period - return round(adx_val, 2) def detect_market_regime(index_closes: List[float], @@ -230,40 +185,22 @@ def calculate_rsi(closes: List[float], period: int = 14) -> Optional[float]: feature_engineering.compute_rsi()와 동일한 Wilder smoothing 사용: - 첫 period개: 단순 평균(seed) - 이후: avg = (prev_avg * (period-1) + current) / period + + [2026-09-27 §8.5-1 중복 통합] 계산 본체는 indicators_core.rsi_wilder() 로 이동. + 이 함수는 시그니처·반환형 호환을 위한 얇은 위임(wrapper)이며 수치는 동일하다. """ - if len(closes) < period + 1: - return None - - # 모든 price diff 계산 - diffs = [closes[i] - closes[i - 1] for i in range(1, len(closes))] - - # Seed: 첫 period개의 단순 평균 - avg_gain = sum(max(d, 0) for d in diffs[:period]) / period - avg_loss = sum(max(-d, 0) for d in diffs[:period]) / period - - # Wilder smoothing: 나머지 구간 - for i in range(period, len(diffs)): - avg_gain = (avg_gain * (period - 1) + max(diffs[i], 0)) / period - avg_loss = (avg_loss * (period - 1) + max(-diffs[i], 0)) / period - - if avg_loss == 0: - return 100.0 - rs = avg_gain / avg_loss - return 100 - (100 / (1 + rs)) + return rsi_wilder(closes, period) def calculate_bollinger(closes: List[float], period: int = 20) -> Optional[Tuple[float, float, float]]: """볼린저 밴드 (20일, 2σ) 계산 Returns: (upper, middle, lower) + + [2026-09-27 §8.5-1 중복 통합] 계산 본체는 indicators_core.bollinger_sma() 로 이동. + σ배수 2.0 고정(기존 동작 그대로) — 설정값을 쓰는 곳은 analyze_bollinger 쪽이다. """ - if len(closes) < period: - return None - window = closes[-period:] - middle = sum(window) / period - variance = sum((p - middle) ** 2 for p in window) / period - std = variance ** 0.5 - return (middle + 2 * std, middle, middle - 2 * std) + return bollinger_sma(closes, period, 2.0) def is_reversal_candle(opens: List[float], highs: List[float], @@ -282,6 +219,185 @@ def is_reversal_candle(opens: List[float], highs: List[float], return is_bullish or has_long_lower_shadow +# ═════════════════════════════════════════════════════════════════════════════ +# [§8.5-9] BEAR 진입 점수제 v2 +# ═════════════════════════════════════════════════════════════════════════════ +def _bear_v2_cfg(key: str) -> float: + """BEAR v2 파라미터 조회 — strategy_cfg(DB) > settings(config.py) 우선순위.""" + from app.core.config import settings + _v = strategy_cfg.get(key) + if _v is not None: + return float(_v) + return float(getattr(settings, key)) + + +def _bear_v2_enabled() -> bool: + """MEAN_REVERSION_BEAR_V2_ENABLED 조회 (DB 오버라이드 허용). 기본 False.""" + from app.core.config import settings + _v = strategy_cfg.get("MEAN_REVERSION_BEAR_V2_ENABLED") + if _v is None: + return bool(settings.MEAN_REVERSION_BEAR_V2_ENABLED) + if isinstance(_v, str): + return _v.strip().lower() in ("1", "true", "yes", "on") + return bool(_v) + + +def check_bear_buy_signal_v2( + rsi: float, + close: float, + bb_upper: float, + bb_lower: float, + avg_volume_20d: float, + current_volume: int, + is_reversal: bool = False, + rsi_prev: Optional[float] = None, + bear_rsi_threshold: Optional[float] = None, +) -> Optional[Dict]: + """BEAR 진입 점수제 v2 — 기존 4중 AND(RSI<35 AND %B≤0.20 AND 거래량>1.5×20MA AND 반전캔들)를 + 가중 점수 합산으로 완화한다. `check_buy_signal`의 BEAR 분기에서 + `MEAN_REVERSION_BEAR_V2_ENABLED=True` 일 때만 호출된다(기본 False). + + ⚠️ 백테스트 미검증 — `True` 전환 금지. + DB(NAS) 중단으로 백테스트를 돌릴 수 없고, 기존 백테스트 산출물은 실전과 다른 지표 + (SMA RSI vs Wilder, 발산한 ADX)로 생성된 것이 확인되어 근거로 쓸 수 없다. + BEAR 완화는 "하락장에서 진입을 늘리는" 변경이므로 아래를 모두 통과한 뒤에만 켠다: + 1) indicators_core(Wilder RSI/ADX)로 재구축한 백테스트 엔진에서 2023-03~2026-03 재검증 + 2) **BEAR 국면 구간만 분리**한 성과: 승률 ≥ 기존 경로, Profit Factor ≥ 1.2, + BEAR 구간 MDD가 기존 경로 대비 악화되지 않음 + 3) **최대 연속 손실 횟수**가 기존 경로 대비 증가하지 않음 + (연속 손실은 loss_tracker 3연패 밴과 직결 — 악화 시 전 종목 진입 봉쇄로 번진다) + 4) Walk-forward(최소 3분할) 통과 + 거래 표본 ≥ 30건 (CLAUDE.md §8.1-4) + + 필수(AND)로 남긴 조건 — 점수로 우회 불가: + · RSI 반등 확인: `rsi_prev` 제공 시 `rsi > rsi_prev`. 하락 중 진입은 BEAR에서 치명적. + · RSI 상한: `min(bear_rsi_threshold + RSI_RELAX, RSI_HARD_CAP)` 미만. + (기본 35+5=40, BULL 진입 임계 46보다 낮게 유지 → "하락장은 더 보수적" 원칙 유지) + · %B 하드 상한(기존 조건의 완화된 잔존 하한): `PCT_B_HARD_CAP`(0.50) 이하. + BB 중심선 위 매수는 평균회귀가 아니다. + · 데이터 유효성: 거래량 0 / 20MA 0 / BB 폭 0 → 진입 금지(거래정지·데이터 결손). + + 점수화 대상(가중치 근거는 config.py 주석 참조): + · %B 근접 0.40 — 평균회귀 엣지의 직접 근거. 만점=기존 동적 임계값 통과, + 절반=임계값×1.6 이내. + · 반전캔들 0.35 — 낙하 중 진입 방지 대리변수. 필수화하면 도지/데이터부족 시 + 영구 미발화 → 점수 항목으로만 둔다. + · 거래량 0.25 — 확인 지표. 하락장 대량거래는 투매일 수도 있어 단독 예측력 약함. + 만점=1.5×20MA 초과, 0.6배=1.2×20MA 이상, 0.2배=20MA 이상. + · RSI 깊이 0.15 (보너스) — RSI<20 만점, RSI<25 절반. 깊은 과매도 반등 프리미엄. + 임계: 합계 ≥ `MEAN_REVERSION_BEAR_V2_MIN_SCORE`(0.60). + + 주의: 공통 선행 필터(거래량 ≥ 20MA, own_trend MA20≥MA60×0.97, SIDEWAYS 차단)는 + 호출부(`check_buy_signal`)에 그대로 남아 있으며 이 함수는 그것을 우회하지 않는다. + """ + from app.core.config import settings + + if bear_rsi_threshold is None: + _t = strategy_cfg.get("MEAN_REVERSION_BEAR_RSI_THRESHOLD") + bear_rsi_threshold = float(_t) if _t is not None else settings.MEAN_REVERSION_BEAR_RSI_THRESHOLD + + # ── 필수 ①: RSI 상한 ──────────────────────────────────────────────────── + rsi_relax = _bear_v2_cfg("MEAN_REVERSION_BEAR_V2_RSI_RELAX") + rsi_hard_cap = _bear_v2_cfg("MEAN_REVERSION_BEAR_V2_RSI_HARD_CAP") + rsi_upper = min(float(bear_rsi_threshold) + rsi_relax, rsi_hard_cap) + if not (rsi < rsi_upper): + return None + + # ── 필수 ②: RSI 반등 확인 (하락 중 진입 차단) ─────────────────────────── + if rsi_prev is not None and rsi <= rsi_prev: + logger.debug( + "⏭️ [BEARv2] RSI 반등 미확인 — 진입 차단: rsi=%.1f, rsi_prev=%.1f", rsi, rsi_prev + ) + return None + + # ── 데이터 유효성 가드 (점수로 우회 불가) ─────────────────────────────── + # 거래량 0/평균 0 = 거래정지·데이터 결손, BB 폭 0 = 지표 산출 불가 → 진입 금지. + # (기존 경로는 호출부 공통 필터 `current_volume < avg_volume_20d` 로 걸렀으나, + # v2 를 직접 호출하는 경우에도 안전하도록 함수 내부에서 재확인한다.) + if avg_volume_20d <= 0 or current_volume <= 0: + return None + bb_width = bb_upper - bb_lower + if bb_width <= 0: + return None + + # ── %B 계산 (기존 BEAR 분기와 동일한 동적 임계값) ─────────────────────── + pct_b = (close - bb_lower) / bb_width + if rsi < settings.MEAN_REVERSION_RSI_STRONG_THRESHOLD: + pct_b_max = 0.30 + elif rsi < settings.MEAN_REVERSION_RSI_NORMAL_THRESHOLD: + pct_b_max = 0.25 + else: + pct_b_max = 0.20 + + # ── 필수 ③: %B 하드 상한 (BB 중심선 위 매수 금지) ─────────────────────── + pct_b_hard_cap = _bear_v2_cfg("MEAN_REVERSION_BEAR_V2_PCT_B_HARD_CAP") + if pct_b > pct_b_hard_cap: + return None + + w_pct_b = _bear_v2_cfg("MEAN_REVERSION_BEAR_V2_W_PCT_B") + w_reversal = _bear_v2_cfg("MEAN_REVERSION_BEAR_V2_W_REVERSAL") + w_volume = _bear_v2_cfg("MEAN_REVERSION_BEAR_V2_W_VOLUME") + w_depth = _bear_v2_cfg("MEAN_REVERSION_BEAR_V2_W_RSI_DEPTH") + pct_b_relax_mult = _bear_v2_cfg("MEAN_REVERSION_BEAR_V2_PCT_B_RELAX_MULT") + vol_mid_mult = _bear_v2_cfg("MEAN_REVERSION_BEAR_V2_VOLUME_MID_MULT") + min_score = _bear_v2_cfg("MEAN_REVERSION_BEAR_V2_MIN_SCORE") + + _bvm = strategy_cfg.get("MEAN_REVERSION_BEAR_VOLUME_MULT") + bear_vol_mult = float(_bvm) if _bvm is not None else settings.MEAN_REVERSION_BEAR_VOLUME_MULT + + # ── 점수 합산 ─────────────────────────────────────────────────────────── + score_pct_b = ( + w_pct_b if pct_b <= pct_b_max + else (w_pct_b * 0.5 if pct_b <= pct_b_max * pct_b_relax_mult else 0.0) + ) + + score_reversal = w_reversal if is_reversal else 0.0 + + # 거래량: 공통 선행 필터가 volume ≥ 20MA 를 이미 보장하므로 하위 등급은 방어적 처리. + vol_ratio = current_volume / avg_volume_20d + if vol_ratio > bear_vol_mult: + score_volume = w_volume + elif vol_ratio >= vol_mid_mult: + score_volume = w_volume * 0.6 + elif vol_ratio >= 1.0: + score_volume = w_volume * 0.2 + else: + score_volume = 0.0 + + if rsi < settings.MEAN_REVERSION_RSI_STRONG_THRESHOLD: + score_depth = w_depth + elif rsi < settings.MEAN_REVERSION_RSI_NORMAL_THRESHOLD: + score_depth = w_depth * 0.5 + else: + score_depth = 0.0 + + total = score_pct_b + score_reversal + score_volume + score_depth + + if total < min_score: + logger.debug( + "⏭️ [BEARv2] 점수 미달 %.2f < %.2f (pctB=%.2f/%.2f vol=%.2fx rev=%s rsi=%.1f)", + total, min_score, pct_b, pct_b_max, vol_ratio, is_reversal, rsi, + ) + return None + + return { + "signal": "BUY", + "strategy": "MEAN_REVERSION_BEAR_V2", + "split_plan": [0.30, 0.30, 0.40], # 3회 분할 매수 (기존 BEAR 와 동일) + "description": ( + f"[BEARv2 score {total:.2f}≥{min_score:.2f}] RSI({rsi:.1f})<{rsi_upper:.1f} " + f"+ %%B({pct_b:.2f}/{pct_b_max:.2f}) + 거래량({vol_ratio:.2f}×) " + f"+ 반전({'Y' if is_reversal else 'N'}) (하락장 과매도, 점수제)" + ), + "bear_v2_score": round(total, 4), + "bear_v2_components": { + "pct_b": round(score_pct_b, 4), + "reversal": round(score_reversal, 4), + "volume": round(score_volume, 4), + "rsi_depth": round(score_depth, 4), + }, + } + + def check_buy_signal( regime: MarketRegime, rsi: float, @@ -327,10 +443,12 @@ def check_buy_signal( return None if regime == MarketRegime.BULL: - # CLAUDE.md 6.2: BULL 진입 RSI < 40 - # 적응형 전략 오버라이드: adaptive.rsi_entry (실패 시 strategy_cfg / 40.0 fallback) + # CLAUDE.md §6 / §8.2: BULL 진입 RSI < 46 (MEAN_REVERSION_RSI_THRESHOLD=46.0) + # 파라미터 우선순위: adaptive_strategy > strategy_cfg(DB) > settings(config.py) + # [2026-09-27] 하드코딩 40.0 폴백 제거 → settings 참조 (BEAR 경로와 동일 패턴) + from app.core.config import settings _rsi_threshold_val = strategy_cfg.get("MEAN_REVERSION_RSI_THRESHOLD") - _base_rsi_threshold = float(_rsi_threshold_val) if _rsi_threshold_val is not None else 40.0 + _base_rsi_threshold = float(_rsi_threshold_val) if _rsi_threshold_val is not None else settings.MEAN_REVERSION_RSI_THRESHOLD try: from app.services.adaptive_strategy import get_current_adaptive_params _adp = get_current_adaptive_params() @@ -380,12 +498,19 @@ def check_buy_signal( "signal": "BUY", "strategy": "MEAN_REVERSION_BULL", "split_plan": [0.50, 0.50], # 2회 분할 매수 - "description": f"RSI({rsi:.1f})<40 + %%B({pct_b:.2f})<{pct_b_max} (상승장 과매도){reversal_info}", + # [2026-09-27] 임계값 40 하드코딩 제거 — 실효 임계값은 _base_rsi_threshold + # (adaptive > strategy_cfg > settings 우선순위, CLAUDE.md §0)이며 46 이 기본이다. + # 40 을 박아두면 로그·TradeJournal·ML 학습 데이터에 거짓 임계값이 남는다. + # `%%B` → `%B` 도 정정(f-string 은 % 를 이스케이프하지 않아 그대로 출력됐다). + "description": ( + f"RSI({rsi:.1f})<{_base_rsi_threshold:.0f} + " + f"%B({pct_b:.2f})<{pct_b_max:.2f} (상승장 과매도){reversal_info}" + ), } elif regime == MarketRegime.BEAR: - # CLAUDE.md 6.2: BEAR 진입 RSI < 30 - # 적응형 전략 오버라이드: adaptive.bear_rsi_entry (실패 시 기존 fallback) + # CLAUDE.md §6 / §8.2: BEAR 진입 RSI < 35 (MEAN_REVERSION_BEAR_RSI_THRESHOLD=35.0) + # 파라미터 우선순위: adaptive_strategy > strategy_cfg(DB) > settings(config.py) from app.core.config import settings _bear_rsi_threshold = strategy_cfg.get("MEAN_REVERSION_BEAR_RSI_THRESHOLD") bear_rsi_threshold = float(_bear_rsi_threshold) if _bear_rsi_threshold is not None else settings.MEAN_REVERSION_BEAR_RSI_THRESHOLD @@ -397,6 +522,20 @@ def check_buy_signal( except Exception: pass + # [§8.5-9] BEAR 점수제 v2 — 기본 OFF. OFF면 아래 기존 4중 AND 경로가 그대로 동작한다. + if _bear_v2_enabled(): + return check_bear_buy_signal_v2( + rsi=rsi, + close=close, + bb_upper=bb_upper, + bb_lower=bb_lower, + avg_volume_20d=avg_volume_20d, + current_volume=current_volume, + is_reversal=is_reversal, + rsi_prev=rsi_prev, + bear_rsi_threshold=bear_rsi_threshold, + ) + if rsi < bear_rsi_threshold: # [2026-03] 하락장도 RSI 반등 확인 적용 if rsi_prev is not None and rsi <= rsi_prev: @@ -465,16 +604,17 @@ def check_exit_signal( # [이슈10 수정] or 패턴 → None 명시 체크 _atr_stop_val = strategy_cfg.get("ATR_STOP_MULTIPLIER") atr_stop_mult = float(_atr_stop_val) if _atr_stop_val is not None else 2.5 - # CLAUDE.md 5.2: 안정적 종목 -6%, 변동성 종목 -10% + # CLAUDE.md §5.2: 기본 -7%(STOP_LOSS_PERCENT), 고변동 -10%(STOP_LOSS_HIGH_VOL) + # 파라미터 우선순위: adaptive_strategy > strategy_cfg(DB) > settings(config.py) # 변동성 판별: ATR / 현재가 > 3% → 변동성 종목 _atr_pct = (atr / close * 100) if close > 0 else 0 # [이슈10 수정] or 패턴 → None 명시 체크 if _atr_pct > 3.0: _sl_high_val = strategy_cfg.get("STOP_LOSS_HIGH_VOL") - _raw_sl = float(_sl_high_val) if _sl_high_val is not None else -10.0 + _raw_sl = float(_sl_high_val) if _sl_high_val is not None else -abs(settings.STOP_LOSS_HIGH_VOL) # [2026-09-27] config.py 참조 else: _sl_val = strategy_cfg.get("STOP_LOSS_PERCENT") - _raw_sl = float(_sl_val) if _sl_val is not None else -settings.STOP_LOSS_PERCENT # config.py 기본값(7.0) 참조 + _raw_sl = float(_sl_val) if _sl_val is not None else -abs(settings.STOP_LOSS_PERCENT) # config.py 기본값(7.0) 참조 # 적응형 전략 오버라이드: stop_loss / atr_stop_multiplier try: @@ -497,7 +637,7 @@ def check_exit_signal( if close <= stop_price: return ExitSignal( action="SELL_ALL", - reason=f"손절 (close={close:.0f} <= stop={stop_price:.0f}, ATR기준={atr_stop:.0f}, -6%기준={pct_stop:.0f})", + reason=f"손절 (close={close:.0f} <= stop={stop_price:.0f}, ATR기준={atr_stop:.0f}, {stop_loss_pct:.1f}%기준={pct_stop:.0f})", sell_pct=1.0, ) @@ -606,9 +746,15 @@ def calculate_position_size( # [2026-03-15 CLAUDE.md 정합성 수정] CLAUDE.md 5.1: 종목당 최대 3~4% # multiplier를 전략 자본 내 비중 조정에만 사용, 절대 상한은 4% 유지 - max_by_position = total_assets * max_per_position # 절대 상한 4% - remaining_budget = (total_assets * max_strategy_capital) - current_strategy_allocation - result = max(0.0, min(max_by_position, remaining_budget, available_cash)) + # [§8.5-2 통합 2026-09-27] 3중 min 식을 sizing_core.budget_by_ratios 로 위임. + # = max(0, min(총자산×종목당한도, 총자산×전략한도 − 현재배분, 가용현금)) — 수식 동일. + result = budget_by_ratios( + total_assets=total_assets, + available_cash=available_cash, + current_allocation=current_strategy_allocation, + max_per_position=max_per_position, # 절대 상한 4% + max_strategy_ratio=max_strategy_capital, # BULL 70% / BEAR 30% + ) if rsi is not None or adx is not None: logger.debug( @@ -659,8 +805,9 @@ def calculate_position_size_v2( if adx is not None and adx > settings.MEAN_REVERSION_ADX_STRONG_REDUCE: mult *= 0.8 - max_allowed = total_assets * max_per_position - return min(base * mult, max_allowed, available_cash) + # [§8.5-2 통합 2026-09-27] min(base*mult, 총자산×종목당한도, 가용현금) 재클램프. + # floor(max(0,...)) 없음 — 통합 전과 동일(base>0 이 이미 보장됨). + return clamp_by_ratios(base * mult, total_assets, [max_per_position], available_cash) def check_h1_breakout_with_ma5( diff --git a/backend/app/services/range_trading_strategy.py b/backend/app/services/range_trading_strategy.py new file mode 100644 index 0000000..6b2e25d --- /dev/null +++ b/backend/app/services/range_trading_strategy.py @@ -0,0 +1,505 @@ +"""박스권(SIDEWAYS) 레인지 매매 전략 — CLAUDE.md §8.5 항목 9 전반부 + +⚠️ **백테스트 미검증 — `RANGE_TRADE_ENABLED=True` 전환 금지 (CLAUDE.md §8.1-4)** + +현재 상태: 코드만 존재. 실계좌·모의계좌 어느 쪽에서도 검증되지 않았다. +`settings.RANGE_TRADE_ENABLED` 는 기본 False 이며, False 인 동안 +`swing/entry_service.scan_range_trading_signals()` 는 함수 진입 즉시 `[]` 를 반환한다 +(기존 동작 완전 불변). + +**켜기 전에 반드시 통과해야 하는 검증 항목** + 1. SIDEWAYS 구간 라벨링 정확도 — `detect_market_regime()` 이 SIDEWAYS 로 판정한 + 구간(2023-03~2026-03)을 추출하고, 그 구간에서만 이 전략을 돌린 결과를 측정한다. + SIDEWAYS 표본이 60거래일 미만이면 통계적 의미 없음 → 판정 불가로 간주. + 2. In-sample 백테스트(2023-03~2025-03): 승률 ≥ 50%, Profit Factor ≥ 1.3, + 평균 보유 5~15거래일, MDD ≥ -8%(더 얕아야 함), 거래비용 0.5%(왕복) 반영 후 기준. + 3. Walk-forward (6개월 학습 / 3개월 검증, 최소 4구간): 모든 검증 구간에서 + PF ≥ 1.0, 어느 구간도 MDD -10% 초과 금지. 한 구간이라도 깨지면 불합격. + 4. 박스 이탈 손절의 실효성: 전체 거래 중 `box_break` 손절 비중과 그때의 평균 손실률을 + 따로 집계. 평균 손실이 -RANGE_TRADE_STOP_LOSS_PERCENT 보다 크면(더 많이 잃으면) + 박스 이탈 판정 임계값(RANGE_TRADE_BOX_BREAK_PCT)을 재조정해야 한다. + 5. 파라미터 민감도: RANGE_TRADE_LOOKBACK_DAYS(15/20/25), + RANGE_TRADE_MIN_WIDTH_PCT(5/6/8), RANGE_TRADE_RSI_ENTRY(35/40/45) 그리드에서 + PF 가 1.0 아래로 떨어지는 조합이 절반을 넘으면 과최적화로 판단 → 폐기. + 6. 기존 전략과의 중복/상관: 같은 날 평균회귀·눌림목과 동일 종목을 잡는 비율이 30%를 + 넘으면 신규 신호원으로서의 가치가 없음 → 배선 재검토. + 7. 미검증 상태로 스케줄러에 배선하지 말 것(swing/scheduler.py 는 본 작업 범위 밖이며 + 현재 호출부가 없다 — 보고서의 후속 작업 항목 참조). + +───────────────────────────────────────────────────────────────────────────── +전략 개요 (순수 로직 — DB·네트워크 접근 없음) + + 진입: 성립된 박스권의 **하단 지지 반등** + - 박스 성립 검증 (detect_range_box): 최근 N일 고/저 레인지 폭이 일정 범위, + 상단·하단을 각각 min_touches 회 이상 접촉, 접촉 순서가 교대(왕복 이력)해야 한다. + - ADX 상한으로 추세장 배제 (박스권 전제 = 추세 없음). + - 가격이 박스 하단 zone 안 + BB 하단 부근(%B) + RSI 과매도. + - **반등 확인 필수**: RSI 상승 전환(rsi > rsi_prev) 또는 양봉. 둘 다 불가하면 진입 거부. + - 거래량 ≥ 20일 평균 × 배수 (지지 반등의 수급 확인). + + 청산: 박스 상단 접근 시 익절 / 박스 이탈 시 즉시 손절 + - 1) 박스 하단 하향 이탈 → 즉시 전량 (레인지 전략의 최대 리스크) + - 2) 일반 손절 (기본%·ATR 중 타이트한 쪽) → 전량 + - 3) 박스 상단 zone 도달 / BB 상단(99%) / RSI 전량임계 / 트레일링 → 전량 + - 4) 박스 중단(mid) 통과 또는 RSI 부분임계 → 50% 부분 익절 (수수료 가드 후) + + 손절 (CLAUDE.md §5.2): + stop_price = max(atr_stop, pct_stop) ← 가격 기준에서 **높은 값이 타이트한 쪽** + atr_stop = entry_price − RANGE_TRADE_ATR_STOP_MULTIPLIER × ATR + pct_stop = entry_price × (1 + stop_loss_pct/100), stop_loss_pct = -abs(...) 보장 + 박스 하단 이탈 손절은 위 stop_price 와 **별개의 OR 트리거**로 동작한다 + (박스 하단이 stop_price 보다 낮을 수도, 높을 수도 있어 max() 에 섞지 않는다). + +모든 파라미터는 `app.core.config.settings` 의 `RANGE_TRADE_*` 설정에서 읽는다 +(하드코딩 금지 — 함수 인자로 오버라이드 가능하게 두어 백테스트 그리드 탐색에 사용). +""" +import logging +from dataclasses import dataclass +from typing import Dict, List, Optional + +logger = logging.getLogger(__name__) + +# ActiveTrade.strategy_name 에 기록되는 값. +# ⚠️ mdd_manager.SWING_STRATEGY_NAMES 에 이 값이 추가되어야 MDD 축소가 반영된다 +# (본 작업 범위 밖 — 보고서 후속 작업 항목 참조). +RANGE_TRADE_STRATEGY_NAME = "RANGE_TRADE" + + +@dataclass +class RangeBox: + """성립이 검증된 박스권.""" + high: float # 박스 상단 (lookback 구간 최고가) + low: float # 박스 하단 (lookback 구간 최저가) + mid: float # 박스 중앙 + width_pct: float # 박스 폭 / 중앙 × 100 + top_touches: int # 상단 접촉 봉 수 + bottom_touches: int # 하단 접촉 봉 수 + alternations: int # 상단↔하단 접촉 교대 횟수 (왕복 이력) + lookback: int # 판정에 사용한 봉 수 + position_pct: float # 현재가의 박스 내 위치 (0=하단, 100=상단) + + +@dataclass +class RangeExitSignal: + """청산 신호. mean_reversion_strategy.ExitSignal 과 동일한 필드 구성 + (action/reason/sell_pct) — swing/exit_service 의 소비 코드와 duck-typing 호환.""" + action: str # "SELL_PARTIAL" | "SELL_ALL" + reason: str + sell_pct: float # 0.5 = 50%, 1.0 = 전량 + + +def _cfg(name: str, override: Optional[float], default: float) -> float: + """설정값 조회: 명시 오버라이드 > settings.RANGE_TRADE_* > 하드 폴백. + + 하드 폴백은 config.py 가 아직 해당 키를 갖지 않는 경우(구버전 이미지)에만 쓰인다. + """ + if override is not None: + return float(override) + try: + from app.core.config import settings + val = getattr(settings, name, None) + if val is not None: + return float(val) + except Exception: + pass + return float(default) + + +# ═══════════════════════════════════════════════════════════════════════════ +# 1. 박스권 성립 검증 +# ═══════════════════════════════════════════════════════════════════════════ + +def detect_range_box( + highs: List[float], + lows: List[float], + closes: List[float], + lookback: Optional[int] = None, + min_width_pct: Optional[float] = None, + max_width_pct: Optional[float] = None, + touch_tolerance_pct: Optional[float] = None, + min_touches: Optional[int] = None, + min_alternations: Optional[int] = None, + box_break_pct: Optional[float] = None, +) -> Optional[RangeBox]: + """최근 lookback 봉이 '박스권'인지 검증하고 박스를 반환. 실패 시 None. + + 검증 조건 (모두 AND): + ① 데이터 충분: len ≥ lookback + ② 박스 폭: min_width_pct ≤ (high-low)/mid×100 ≤ max_width_pct + - 너무 좁으면 왕복 이익이 거래비용(왕복 ~0.5%)을 못 넘고, + 너무 넓으면 '박스'가 아니라 추세/고변동 구간이다. + ③ 상단·하단 접촉 각각 min_touches 회 이상 (tolerance 이내 접근을 접촉으로 간주) + ④ 접촉 순서 교대 min_alternations 회 이상 → 레인지 안에서 실제로 왕복한 이력 + (모든 하단 접촉이 앞쪽에만 몰려 있으면 하락 후 횡보일 뿐 박스가 아니다) + ⑤ 현재 종가가 박스를 이탈하지 않았음 (상·하단 ±box_break_pct 이내) + """ + _lookback = int(_cfg("RANGE_TRADE_LOOKBACK_DAYS", lookback, 20)) + _min_w = _cfg("RANGE_TRADE_MIN_WIDTH_PCT", min_width_pct, 6.0) + _max_w = _cfg("RANGE_TRADE_MAX_WIDTH_PCT", max_width_pct, 20.0) + _tol = _cfg("RANGE_TRADE_TOUCH_TOLERANCE_PCT", touch_tolerance_pct, 2.0) + _min_touch = int(_cfg("RANGE_TRADE_MIN_TOUCHES", min_touches, 2)) + _min_alt = int(_cfg("RANGE_TRADE_MIN_ALTERNATIONS", min_alternations, 2)) + _break = _cfg("RANGE_TRADE_BOX_BREAK_PCT", box_break_pct, 1.0) + + if _lookback <= 0: + return None + if len(highs) < _lookback or len(lows) < _lookback or len(closes) < 1: + return None + + win_h = highs[-_lookback:] + win_l = lows[-_lookback:] + box_high = max(win_h) + box_low = min(win_l) + if box_high <= 0 or box_low <= 0 or box_high <= box_low: + return None + + mid = (box_high + box_low) / 2.0 + if mid <= 0: + return None + width_pct = (box_high - box_low) / mid * 100.0 + if width_pct < _min_w or width_pct > _max_w: + logger.debug( + "[RangeBox] 폭 조건 미충족: width=%.2f%% (허용 %.1f~%.1f%%)", + width_pct, _min_w, _max_w, + ) + return None + + # ③④ 접촉 및 교대 판정 (시간 순서 유지) + top_line = box_high * (1.0 - _tol / 100.0) + bottom_line = box_low * (1.0 + _tol / 100.0) + events: List[str] = [] + top_touches = 0 + bottom_touches = 0 + for h, l in zip(win_h, win_l): + is_top = h >= top_line + is_bottom = l <= bottom_line + if is_top and not is_bottom: + top_touches += 1 + events.append("T") + elif is_bottom and not is_top: + bottom_touches += 1 + events.append("B") + elif is_top and is_bottom: + # 한 봉이 박스 전체를 커버 = 변동성 급증, 박스 판정 무효 + logger.debug("[RangeBox] 단일 봉이 박스 전폭 커버 — 박스 무효") + return None + + if top_touches < _min_touch or bottom_touches < _min_touch: + logger.debug( + "[RangeBox] 접촉 부족: top=%d, bottom=%d (각 %d회 이상 필요)", + top_touches, bottom_touches, _min_touch, + ) + return None + + alternations = 0 + prev = None + for e in events: + if prev is not None and e != prev: + alternations += 1 + prev = e + if alternations < _min_alt: + logger.debug( + "[RangeBox] 왕복 이력 부족: alternations=%d (%d회 이상 필요) events=%s", + alternations, _min_alt, "".join(events), + ) + return None + + # ⑤ 현재 종가의 박스 이탈 여부 + close = closes[-1] + if close < box_low * (1.0 - _break / 100.0): + logger.debug("[RangeBox] 하단 이탈 상태 (close=%.1f < low=%.1f) — 박스 무효", close, box_low) + return None + if close > box_high * (1.0 + _break / 100.0): + logger.debug("[RangeBox] 상단 이탈 상태 (close=%.1f > high=%.1f) — 박스 무효", close, box_high) + return None + + position_pct = (close - box_low) / (box_high - box_low) * 100.0 + return RangeBox( + high=box_high, + low=box_low, + mid=mid, + width_pct=width_pct, + top_touches=top_touches, + bottom_touches=bottom_touches, + alternations=alternations, + lookback=_lookback, + position_pct=position_pct, + ) + + +# ═══════════════════════════════════════════════════════════════════════════ +# 2. 진입 판정 +# ═══════════════════════════════════════════════════════════════════════════ + +def check_range_buy_signal( + highs: List[float], + lows: List[float], + closes: List[float], + volumes: List[int], + current_close: float, + current_volume: int, + rsi: float, + bb_upper: float, + bb_middle: float, + bb_lower: float, + adx: Optional[float], + rsi_prev: Optional[float] = None, + opens: Optional[List[float]] = None, + box: Optional[RangeBox] = None, + rsi_entry: Optional[float] = None, + bb_pct_b_max: Optional[float] = None, + entry_zone_pct: Optional[float] = None, + max_adx: Optional[float] = None, + volume_ratio: Optional[float] = None, +) -> Optional[Dict]: + """박스권 하단 지지 반등 매수 신호. 조건 미충족 시 None. + + 반환 dict 키는 mean_reversion_strategy.check_buy_signal() 과 동일한 관례를 따른다 + (signal/strategy/description) + 레인지 전용 필드(box_high/box_low/target_price). + + Args: + adx: 일봉 ADX(14). **None 이면 진입 거부**(추세장 배제 판정 불가 = fail-closed). + rsi_prev: N봉 전 RSI. RSI 상승 전환 확인용. + opens: 시가 리스트. 양봉 확인용. rsi_prev·opens 둘 다 없으면 반등 확인 불가 → 거부. + """ + _rsi_entry = _cfg("RANGE_TRADE_RSI_ENTRY", rsi_entry, 40.0) + _pct_b_max = _cfg("RANGE_TRADE_BB_PCT_B_MAX", bb_pct_b_max, 0.25) + _zone = _cfg("RANGE_TRADE_ENTRY_ZONE_PCT", entry_zone_pct, 25.0) + _max_adx = _cfg("RANGE_TRADE_MAX_ADX", max_adx, 20.0) + _vol_ratio = _cfg("RANGE_TRADE_VOLUME_RATIO", volume_ratio, 1.0) + + if current_close <= 0 or rsi is None: + return None + + # ── ① 추세장 배제 (ADX 상한) ────────────────────────────────────────── + if adx is None: + logger.debug("[RangeBuy] ADX 없음 — 추세 판정 불가로 진입 거부(fail-closed)") + return None + if adx >= _max_adx: + logger.debug("[RangeBuy] ADX(%.1f) >= %.1f — 추세장, 레인지 진입 거부", adx, _max_adx) + return None + + # ── ② 박스 성립 검증 ──────────────────────────────────────────────── + _box = box if box is not None else detect_range_box(highs, lows, closes) + if _box is None: + return None + + # ── ③ 박스 하단 zone 안인지 ────────────────────────────────────────── + if _box.position_pct > _zone: + logger.debug( + "[RangeBuy] 박스 내 위치 %.1f%% > %.1f%% — 하단 지지대 아님", + _box.position_pct, _zone, + ) + return None + + # ── ④ BB 하단 부근 (%B) ───────────────────────────────────────────── + bb_width = bb_upper - bb_lower + pct_b = (current_close - bb_lower) / bb_width if bb_width > 0 else 0.5 + if pct_b > _pct_b_max: + logger.debug("[RangeBuy] %%B(%.2f) > %.2f — BB 하단 부근 아님", pct_b, _pct_b_max) + return None + + # ── ⑤ RSI 과매도 ──────────────────────────────────────────────────── + if rsi >= _rsi_entry: + logger.debug("[RangeBuy] RSI(%.1f) >= %.1f — 과매도 아님", rsi, _rsi_entry) + return None + + # ── ⑥ 반등 확인 필수 (RSI 상승 전환 OR 양봉) ───────────────────────── + rsi_turning_up = rsi_prev is not None and rsi > rsi_prev + bullish_candle = False + if opens and len(opens) >= 1 and len(closes) >= 1: + bullish_candle = closes[-1] > opens[-1] + if not (rsi_turning_up or bullish_candle): + logger.debug( + "[RangeBuy] 반등 미확인 — 진입 거부 (rsi=%.1f, rsi_prev=%s, 양봉=%s)", + rsi, f"{rsi_prev:.1f}" if rsi_prev is not None else "N/A", bullish_candle, + ) + return None + + # ── ⑦ 거래량 확인 ─────────────────────────────────────────────────── + if volumes and len(volumes) >= 20: + avg_vol_20 = sum(volumes[-20:]) / 20.0 + if avg_vol_20 > 0 and current_volume < avg_vol_20 * _vol_ratio: + logger.debug( + "[RangeBuy] 거래량 부족: %d < %.0f×%.2f", + current_volume, avg_vol_20, _vol_ratio, + ) + return None + elif current_volume <= 0: + return None + + # 목표가: 박스 상단과 BB 상단 중 보수적(낮은) 쪽 + target_price = min(_box.high, bb_upper) if bb_upper > 0 else _box.high + reversal_desc = [] + if rsi_turning_up: + reversal_desc.append(f"RSI반등({rsi_prev:.1f}→{rsi:.1f})") + if bullish_candle: + reversal_desc.append("양봉") + + return { + "signal": "BUY", + "strategy": RANGE_TRADE_STRATEGY_NAME, + "confidence": 0.55, # 미검증 전략 — 보수적 고정값 (신뢰도 상향은 백테스트 후) + "box_high": _box.high, + "box_low": _box.low, + "box_mid": _box.mid, + "box_width_pct": _box.width_pct, + "position_pct": _box.position_pct, + "target_price": target_price, + "description": ( + f"박스권 하단 지지 반등: 박스[{_box.low:.0f}~{_box.high:.0f}] " + f"폭{_box.width_pct:.1f}% 접촉(T{_box.top_touches}/B{_box.bottom_touches}) " + f"왕복{_box.alternations} 위치{_box.position_pct:.0f}% " + f"RSI({rsi:.1f})<{_rsi_entry:.0f} %B({pct_b:.2f}) ADX({adx:.1f})<{_max_adx:.0f} " + f"{'+'.join(reversal_desc)}" + ), + } + + +# ═══════════════════════════════════════════════════════════════════════════ +# 3. 손절가 계산 (CLAUDE.md §5.2) +# ═══════════════════════════════════════════════════════════════════════════ + +def calculate_range_stop_loss( + entry_price: float, + atr: float, + box_low: float, + atr_multiplier: Optional[float] = None, + stop_loss_percent: Optional[float] = None, + box_break_pct: Optional[float] = None, +) -> Dict[str, float]: + """레인지 전략 손절가. + + CLAUDE.md §5.2: 기본 SL 과 ATR SL 중 **타이트한 쪽**. + 가격 기준 비교에서는 더 **높은** 손절가가 타이트하므로 `max(atr_stop, pct_stop)`. + (수익률 기준으로 비교할 때의 `min()` 과 방향이 반대인 점 주의) + + 박스 하단 이탈 손절(box_break_stop)은 별개의 OR 트리거로 반환한다 — + max() 에 섞으면 박스가 넓을 때 손절이 과도하게 느슨해지거나(하단이 낮음), + 박스가 좁을 때 정상 노이즈에서 조기 손절(하단이 높음)될 수 있다. + """ + _atr_mult = _cfg("RANGE_TRADE_ATR_STOP_MULTIPLIER", atr_multiplier, 2.0) + _sl_pct_raw = _cfg("RANGE_TRADE_STOP_LOSS_PERCENT", stop_loss_percent, 5.0) + _break = _cfg("RANGE_TRADE_BOX_BREAK_PCT", box_break_pct, 1.0) + + stop_loss_pct = -abs(_sl_pct_raw) # 부호 가드 — 양수 입력 시 전종목 매도 방지 + atr_stop = entry_price - _atr_mult * max(0.0, float(atr or 0.0)) + pct_stop = entry_price * (1.0 + stop_loss_pct / 100.0) + stop_price = max(atr_stop, pct_stop) # 높은 값 = 타이트한 손절 + box_break_stop = box_low * (1.0 - _break / 100.0) if box_low > 0 else 0.0 + + return { + "stop_price": stop_price, + "atr_stop": atr_stop, + "pct_stop": pct_stop, + "stop_loss_pct": stop_loss_pct, + "box_break_stop": box_break_stop, + } + + +# ═══════════════════════════════════════════════════════════════════════════ +# 4. 청산 판정 +# ═══════════════════════════════════════════════════════════════════════════ + +def check_range_exit_signal( + rsi: float, + close: float, + bb_upper: float, + bb_middle: float, + bb_lower: float, + entry_price: float, + atr: float, + box_high: float, + box_low: float, + remaining_pct: float = 1.0, + trailing_high: Optional[float] = None, + rsi_partial_exit: Optional[float] = None, + rsi_full_exit: Optional[float] = None, + exit_zone_pct: Optional[float] = None, + trailing_atr_mult: Optional[float] = None, + min_profit_pct: Optional[float] = None, +) -> Optional[RangeExitSignal]: + """레인지 청산 판정. 우선순위대로 평가한다. + + 1. 박스 하단 하향 이탈 → 즉시 전량 (레인지 전략의 핵심 리스크) + 2. 일반 손절 max(atr_stop, pct_stop) → 전량 + 3. 박스 상단 zone / BB 상단(99%) / RSI 전량임계 / 트레일링 → 전량 + 4. 박스 중앙 상회 또는 RSI 부분임계 → 50% 부분 익절 (수수료 가드 통과 시) + """ + _rsi_partial = _cfg("RANGE_TRADE_RSI_PARTIAL_EXIT", rsi_partial_exit, 55.0) + _rsi_full = _cfg("RANGE_TRADE_RSI_FULL_EXIT", rsi_full_exit, 65.0) + _exit_zone = _cfg("RANGE_TRADE_EXIT_ZONE_PCT", exit_zone_pct, 80.0) + _trail_mult = _cfg("RANGE_TRADE_TRAILING_ATR_MULT", trailing_atr_mult, 1.5) + _min_profit = _cfg("RANGE_TRADE_MIN_PROFIT_PCT", min_profit_pct, 0.5) + + if close <= 0 or entry_price <= 0: + return None + + stops = calculate_range_stop_loss(entry_price, atr, box_low) + + # ── 1. 박스 하단 이탈 → 즉시 전량 손절 ────────────────────────────── + if stops["box_break_stop"] > 0 and close <= stops["box_break_stop"]: + return RangeExitSignal( + action="SELL_ALL", + reason=( + f"박스 이탈 손절 (close={close:.0f} <= 박스하단이탈선" + f"={stops['box_break_stop']:.0f}, box_low={box_low:.0f})" + ), + sell_pct=1.0, + ) + + # ── 2. 일반 손절 (기본%·ATR 중 타이트한 쪽) ───────────────────────── + if close <= stops["stop_price"]: + return RangeExitSignal( + action="SELL_ALL", + reason=( + f"손절 (close={close:.0f} <= stop={stops['stop_price']:.0f}, " + f"ATR기준={stops['atr_stop']:.0f}, {stops['stop_loss_pct']:.1f}%기준" + f"={stops['pct_stop']:.0f})" + ), + sell_pct=1.0, + ) + + profit_pct = (close - entry_price) / entry_price * 100.0 + + # ── 3. 전량 익절 조건 ────────────────────────────────────────────── + full_reasons: List[str] = [] + if box_high > box_low: + pos_pct = (close - box_low) / (box_high - box_low) * 100.0 + if pos_pct >= _exit_zone: + full_reasons.append(f"박스상단zone(위치{pos_pct:.0f}%>={_exit_zone:.0f}%)") + if bb_upper > 0 and close >= bb_upper * 0.99: + full_reasons.append(f"BB상단({bb_upper:.0f})도달") + if rsi is not None and rsi >= _rsi_full: + full_reasons.append(f"RSI({rsi:.1f})>={_rsi_full:.0f}") + if trailing_high is not None and trailing_high > entry_price and atr > 0: + trail_stop = trailing_high - _trail_mult * atr + if close < trail_stop: + full_reasons.append(f"트레일링(close={close:.0f}<{trail_stop:.0f})") + + # 익절은 수수료(왕복 ~0.5%)를 커버할 때만. 트레일링은 손실 보호이므로 예외. + _has_trailing = any(r.startswith("트레일링") for r in full_reasons) + if full_reasons and (profit_pct >= _min_profit or _has_trailing): + return RangeExitSignal( + action="SELL_ALL", + reason=f"전량청산: {' OR '.join(full_reasons)}", + sell_pct=1.0, + ) + + # ── 4. 부분 익절 (50%) ───────────────────────────────────────────── + if remaining_pct > 0.50 and profit_pct >= _min_profit: + partial_reasons: List[str] = [] + if rsi is not None and rsi >= _rsi_partial: + partial_reasons.append(f"RSI({rsi:.1f})>={_rsi_partial:.0f}") + if bb_middle > 0 and close > bb_middle: + partial_reasons.append(f"BB중앙({bb_middle:.0f})상회") + if box_high > box_low and close > (box_high + box_low) / 2.0: + partial_reasons.append("박스중앙상회") + if partial_reasons: + return RangeExitSignal( + action="SELL_PARTIAL", + reason=f"1차 익절(50%): {' OR '.join(partial_reasons)}", + sell_pct=0.50, + ) + + return None diff --git a/backend/app/services/sector_mapper.py b/backend/app/services/sector_mapper.py new file mode 100644 index 0000000..84197c8 --- /dev/null +++ b/backend/app/services/sector_mapper.py @@ -0,0 +1,515 @@ +"""섹터 매핑 단일 소스 (sector_mapper) + +배경 (CLAUDE.md §8.5 미해결 항목 8 — 섹터 매핑 커버리지 44/760): + `correlation_filter._STOCK_CODE_SECTOR_MAP` 은 약 44종목만 덮는데 유니버스는 + 네이버 시가총액 상위 ~760종목이라, §5.1 "섹터당 최대 3종목" 규칙이 후보의 94%에 + 적용되지 않았다. 이 모듈은 섹터 정보를 **실제 데이터 소스**에서 가져와 파일 캐시에 + 적재하고, 실시간 진입 판정 경로에는 **캐시 읽기만** 노출한다. + +데이터 소스 (2026-09-27 실측): + ① 업종 그룹 목록: GET https://m.stock.naver.com/api/stocks/industry?page=1&pageSize=100 + → {"groups":[{"no":278,"name":"반도체와반도체장비","totalCount":171}, ...]} (79 groups) + ② 업종별 구성종목: GET https://m.stock.naver.com/api/stocks/industry/{no}?page=N&pageSize=100 + → {"stocks":[{"itemCode":"005930","stockName":"삼성전자", ...}], "totalCount":...} + 실측 결과: 79개 업종 중 no=25 "기타"(1,542종목, ETF/ETN/우선주/스팩 등)를 제외하면 + 2,870종목이 실업종에 매핑되고, 시총 상위 유니버스(831종목, ETF 키워드 제외 후) + 기준 커버리지 810/831 = 97.5% (미매핑 21종목은 전부 ETF/ETN). + +아키텍처 제약 (CLAUDE.md §3.1 — 가장 중요): + - **실시간 계층(진입 판정, 콜백, 스케줄러 tick)은 네트워크·DB I/O 금지.** + → 조회 API `get_sector()` / `get_industry_name()` 는 순수 동기 함수이며 + 프로세스 메모리 + (스로틀된) 로컬 파일 읽기만 수행한다. 네트워크 호출 없음. + - 캐시 채우기(`refresh_sector_map()`)는 배치/명시적 호출 전용 (collect_sector_map.py). + +조회 순서 (get_sector): + ① 프로세스 메모리 (파일 캐시 로드 결과) + ② 큐레이션 정적 표 오버라이드 (`correlation_filter._STOCK_CODE_SECTOR_MAP`) + — 44종목은 의도적 그룹핑(예: LG화학/삼성SDI/에코프로 → battery)이라 외부 업종명보다 + 우선한다. 정적 표는 삭제하지 않고 폴백 겸 오버라이드로 유지. + ③ 파일 캐시(외부 소스 결과) + ④ None (미매핑) + * 외부 소스 호출은 ①~④ 어디에도 들어가지 않는다 (refresh 경로 전용). + +캐시 위치·형식: + ~/.cache/auto-stock/sector_map.json (korea_investment_api 토큰 캐시와 동일 규약, /tmp 금지) + {"version":1,"updated_at":"2026-09-27T12:00:00+09:00","source":"naver_industry", + "stats":{"groups":79,"stocks":2870,"skipped_groups":[25]}, + "industries":{"005930":"반도체와반도체장비", ...}, + "sectors":{"005930":"semiconductor", ...}} +""" + +from __future__ import annotations + +import asyncio +import logging +import os +import time +from datetime import datetime +from pathlib import Path +from typing import Dict, List, Optional, Tuple + +import httpx + +from app.core.json_helpers import _dumps + +logger = logging.getLogger(__name__) + +# ─ 캐시 파일 위치 ───────────────────────────────────────────────────────────── +# korea_investment_api.TOKEN_CACHE_FILE 과 동일한 규약(~/.cache/auto-stock/). /tmp 금지. +_DEFAULT_CACHE_PATH = Path.home() / ".cache" / "auto-stock" / "sector_map.json" + + +def get_cache_path() -> Path: + """섹터 캐시 파일 경로. `AUTOSTOCK_SECTOR_CACHE_PATH` 로 덮어쓸 수 있다.""" + override = os.environ.get("AUTOSTOCK_SECTOR_CACHE_PATH") + return Path(override) if override else _DEFAULT_CACHE_PATH + + +# ─ 외부 소스 (네이버 업종) ──────────────────────────────────────────────────── +_NAVER_GROUPS_URL = "https://m.stock.naver.com/api/stocks/industry" +_NAVER_GROUP_MEMBERS_URL = "https://m.stock.naver.com/api/stocks/industry/{no}" +_NAVER_PAGE_SIZE = 100 # 실측 최대값 (200은 HTTP 400) +_HEADERS = {"User-Agent": "Mozilla/5.0 (compatible; autostock-batch/1.0)"} + +# no=25 "기타" 는 ETF/ETN/우선주/스팩/리츠가 뒤섞인 1,500+ 종목 쓰레기통이다. +# 이걸 하나의 섹터로 받으면 "기타" 거대 버킷이 생겨 섹터캡이 사실상 총보유 상한으로 +# 바뀐다(프로젝트 메모리 "filters-killing-trades" 의 실패 모드). → 수집 단계에서 제외. +_SKIP_INDUSTRY_NOS = frozenset({25}) + +# ─ 섹터 분류 체계 통일 ──────────────────────────────────────────────────────── +# 정적 표(_STOCK_CODE_SECTOR_MAP)가 쓰는 라벨을 정본(canonical)으로 삼고, 네이버 업종명 +# 79개를 20개 상위 섹터로 묶는다. 묶음 규칙: +# - 상관관계가 실제로 높은 밸류체인끼리만 합친다 (반도체+디스플레이장비 등). +# - 어떤 버킷도 유니버스의 과반을 먹지 않도록 granularity 를 20개 수준으로 유지. +# - 표에 없는 신규 업종은 "기타" 로 뭉치지 않고 `naver_<업종번호>` 개별 버킷으로 둔다. +CANONICAL_SECTORS: Tuple[str, ...] = ( + "semiconductor", "it_software", "it_hardware", "telecom", "auto", + "bio_pharma", "healthcare", "chemicals", "battery", "energy", + "materials", "industrial", "construction", "transport", "finance", + "consumer_staples", "consumer_discretionary", "retail", "media", "education", +) + +_NAVER_INDUSTRY_TO_SECTOR: Dict[str, str] = { + # 반도체 밸류체인 + "반도체와반도체장비": "semiconductor", + "디스플레이장비및부품": "semiconductor", + "디스플레이패널": "semiconductor", + # 소프트웨어·인터넷·게임 + "IT서비스": "it_software", + "소프트웨어": "it_software", + "게임엔터테인먼트": "it_software", + "양방향미디어와서비스": "it_software", + "인터넷과카탈로그소매": "it_software", + # IT 하드웨어 + "전자장비와기기": "it_hardware", + "컴퓨터와주변기기": "it_hardware", + "전자제품": "it_hardware", + "사무용전자제품": "it_hardware", + "핸드셋": "it_hardware", + # 통신 + "통신장비": "telecom", + "무선통신서비스": "telecom", + "다각화된통신서비스": "telecom", + # 자동차 + "자동차": "auto", + "자동차부품": "auto", + # 제약·바이오 + "제약": "bio_pharma", + "생물공학": "bio_pharma", + "생명과학도구및서비스": "bio_pharma", + # 의료기기·서비스 (제약과 분리 — 주가 동인이 다름) + "건강관리장비와용품": "healthcare", + "건강관리업체및서비스": "healthcare", + "건강관리기술": "healthcare", + # 2차전지 — 네이버 "전기제품"(no=283) 구성종목 실측: LG에너지솔루션/에코프로비엠/ + # 엘앤에프/엔켐/SK아이이테크놀로지/WCP 등 배터리 밸류체인이 대부분이라 battery 로 묶는다 + # (정적 표의 battery 라벨과 동일 버킷 → 라벨 체계 일관). + "전기제품": "battery", + # 화학·소재 + "화학": "chemicals", + "포장재": "chemicals", + # 에너지·유틸리티 + "석유와가스": "energy", + "에너지장비및서비스": "energy", + "전기유틸리티": "energy", + "가스유틸리티": "energy", + "복합유틸리티": "energy", + # 금속·건자재 + "철강": "materials", + "비철금속": "materials", + "종이와목재": "materials", + "건축자재": "materials", + "건축제품": "materials", + # 산업재·기계 + "기계": "industrial", + "전기장비": "industrial", + "조선": "industrial", + "우주항공과국방": "industrial", + "상업서비스와공급품": "industrial", + "복합기업": "industrial", + "무역회사와판매업체": "industrial", + # 건설·부동산 + "건설": "construction", + "부동산": "construction", + # 운송 + "항공사": "transport", + "해운사": "transport", + "항공화물운송과물류": "transport", + "도로와철도운송": "transport", + "운송인프라": "transport", + # 금융 + "은행": "finance", + "증권": "finance", + "생명보험": "finance", + "손해보험": "finance", + "기타금융": "finance", + "카드": "finance", + "창업투자": "finance", + # 필수소비재 + "식품": "consumer_staples", + "음료": "consumer_staples", + "담배": "consumer_staples", + "식품과기본식료품소매": "consumer_staples", + "가정용품": "consumer_staples", + # 경기소비재 + "화장품": "consumer_discretionary", + "섬유,의류,신발,호화품": "consumer_discretionary", + "레저용장비와제품": "consumer_discretionary", + "가구": "consumer_discretionary", + "가정용기기와용품": "consumer_discretionary", + "호텔,레스토랑,레저": "consumer_discretionary", + "다각화된소비자서비스": "consumer_discretionary", + "문구류": "consumer_discretionary", + # 유통 + "백화점과일반상점": "retail", + "판매업체": "retail", + "전문소매": "retail", + # 미디어 + "방송과엔터테인먼트": "media", + "광고": "media", + "출판": "media", + # 교육 + "교육서비스": "education", +} + + +def canonical_sector_for(industry_name: Optional[str], industry_no: Optional[int] = None) -> Optional[str]: + """네이버 업종명 → 정본 섹터 라벨. + + - 매핑 표에 있으면 해당 상위 섹터. + - 표에 없는(신규 상장 업종 등) 경우 `naver_<번호>` 개별 버킷 → "기타" 거대 버킷 방지. + - 업종명·번호 모두 없으면 None. + """ + if industry_name: + mapped = _NAVER_INDUSTRY_TO_SECTOR.get(industry_name.strip()) + if mapped: + return mapped + if industry_no is not None: + if int(industry_no) in _SKIP_INDUSTRY_NOS: + return None + return f"naver_{int(industry_no)}" + return None + + +# ─ 프로세스 메모리 캐시 (파일 로드 결과) ────────────────────────────────────── +_cache_payload: Dict = {} +_cache_sectors: Dict[str, str] = {} +_cache_industries: Dict[str, str] = {} +_cache_mtime: float = -1.0 +_cache_last_stat: float = 0.0 +_CACHE_STAT_INTERVAL = 300.0 # 파일 변경 감지 stat 간격(초) — 실시간 경로 디스크 I/O 스로틀 + + +def _curated_overrides() -> Dict[str, str]: + """`correlation_filter._STOCK_CODE_SECTOR_MAP` (순환 임포트 회피용 지연 임포트). + + 큐레이션 44종목은 의도적 그룹핑이므로 외부 업종보다 우선한다. + 임포트 실패 시 빈 dict → 파일 캐시만 사용. + """ + try: + from app.services.correlation_filter import _STOCK_CODE_SECTOR_MAP + return _STOCK_CODE_SECTOR_MAP + except Exception: # pragma: no cover - 순환 임포트/부팅 순서 방어 + return {} + + +def load_cache(force: bool = False) -> Dict: + """파일 캐시를 프로세스 메모리로 로드 (mtime 변경 시에만 재파싱). + + 네트워크·DB 접근 없음. `force=False` 이면 `_CACHE_STAT_INTERVAL` 마다 한 번만 stat. + """ + global _cache_payload, _cache_sectors, _cache_industries, _cache_mtime, _cache_last_stat + + now = time.monotonic() + if not force and _cache_mtime >= 0 and (now - _cache_last_stat) < _CACHE_STAT_INTERVAL: + return _cache_payload + + _cache_last_stat = now + path = get_cache_path() + try: + mtime = path.stat().st_mtime + except OSError: + if _cache_mtime < 0: + _cache_mtime = 0.0 # 파일 없음 — 반복 stat 은 스로틀에 맡긴다 + return _cache_payload + + if not force and mtime == _cache_mtime: + return _cache_payload + + try: + import json # 읽기 전용 파싱 (쓰기는 _dumps 사용) + with open(path, "r", encoding="utf-8") as fp: + payload = json.load(fp) + sectors = {str(k): str(v) for k, v in (payload.get("sectors") or {}).items() if v} + industries = {str(k): str(v) for k, v in (payload.get("industries") or {}).items() if v} + _cache_payload = payload + _cache_sectors = sectors + _cache_industries = industries + _cache_mtime = mtime + logger.info( + "[SECTOR_MAP] 캐시 로드: %d종목 (updated_at=%s, path=%s)", + len(sectors), payload.get("updated_at"), path, + ) + except Exception as exc: + logger.warning("[SECTOR_MAP] 캐시 로드 실패 (%s): %s", path, exc) + return _cache_payload + + +def get_sector(stock_code: str) -> Optional[str]: + """종목의 정본 섹터 라벨 (동기, 네트워크·DB 호출 없음). + + 조회 순서: 큐레이션 정적 표 → 파일 캐시(외부 소스) → None. + 캐시 미스는 None(미매핑) — 실시간 경로에서 외부 조회로 채우지 않는다. + """ + if not stock_code: + return None + code = str(stock_code).strip() + + curated = _curated_overrides().get(code) + if curated: + return curated + + load_cache() + return _cache_sectors.get(code) + + +def get_industry_name(stock_code: str) -> Optional[str]: + """원본 네이버 업종명 (동기, 네트워크 호출 없음). 진단·리포팅용.""" + if not stock_code: + return None + load_cache() + return _cache_industries.get(str(stock_code).strip()) + + +def get_coverage(universe_codes: Optional[List[str]] = None) -> Dict: + """섹터 커버리지 스냅샷 (읽기 전용, 네트워크·DB 호출 없음). + + Args: + universe_codes: 비교 대상 유니버스 종목코드. None 이면 캐시 자체 통계만. + + Returns: + {"cache_path","updated_at","source","cached_stocks","curated_stocks","total_mapped", + "sector_histogram","universe_size","universe_covered","universe_coverage_pct", + "universe_unmapped_sample"} + """ + payload = load_cache(force=True) + curated = _curated_overrides() + merged = dict(_cache_sectors) + merged.update(curated) # 큐레이션 우선 (get_sector 와 동일 규칙) + + histogram: Dict[str, int] = {} + for sector in merged.values(): + histogram[sector] = histogram.get(sector, 0) + 1 + + result: Dict = { + "cache_path": str(get_cache_path()), + "updated_at": payload.get("updated_at"), + "source": payload.get("source"), + "cached_stocks": len(_cache_sectors), + "curated_stocks": len(curated), + "total_mapped": len(merged), + "sector_histogram": dict(sorted(histogram.items(), key=lambda kv: -kv[1])), + } + + if universe_codes: + uni = [str(c).strip() for c in universe_codes if c] + covered = [c for c in uni if c in merged] + unmapped = [c for c in uni if c not in merged] + result.update({ + "universe_size": len(uni), + "universe_covered": len(covered), + "universe_coverage_pct": round(100.0 * len(covered) / len(uni), 2) if uni else 0.0, + "universe_unmapped_sample": unmapped[:30], + }) + return result + + +# ─ 캐시 채우기 (배치/명시적 호출 전용 — 실시간 경로에서 호출 금지) ──────────── + +async def _fetch_json(client: httpx.AsyncClient, url: str, params: Dict, retries: int = 3) -> Optional[Dict]: + """GET + JSON 파싱 (지수 백오프 재시도). verify=True 는 클라이언트 생성 시 보장.""" + delay = 1.0 + for attempt in range(1, retries + 1): + try: + resp = await client.get(url, params=params, headers=_HEADERS, timeout=10.0) + resp.raise_for_status() + return resp.json() + except Exception as exc: + if attempt >= retries: + logger.warning("[SECTOR_MAP] 조회 실패 %s %s: %s", url, params, exc) + return None + await asyncio.sleep(delay) + delay *= 2 + return None + + +async def fetch_industry_groups(client: httpx.AsyncClient) -> List[Dict]: + """네이버 업종 그룹 목록 [{"no":278,"name":"반도체와반도체장비","totalCount":171}, ...].""" + groups: List[Dict] = [] + page = 1 + while page <= 20: # 안전 상한 (실측 79그룹 = 1페이지) + data = await _fetch_json(client, _NAVER_GROUPS_URL, {"page": page, "pageSize": _NAVER_PAGE_SIZE}) + if not data: + break + rows = data.get("groups") or [] + if not rows: + break + groups.extend(rows) + if len(rows) < _NAVER_PAGE_SIZE: + break + page += 1 + await asyncio.sleep(0.15) + return groups + + +async def fetch_industry_members(client: httpx.AsyncClient, industry_no: int) -> List[str]: + """특정 업종의 구성 종목코드 목록.""" + codes: List[str] = [] + page = 1 + while page <= 50: # 안전 상한 (최대 업종 175종목 = 2페이지) + data = await _fetch_json( + client, + _NAVER_GROUP_MEMBERS_URL.format(no=int(industry_no)), + {"page": page, "pageSize": _NAVER_PAGE_SIZE}, + ) + if not data: + break + rows = data.get("stocks") or [] + for item in rows: + code = (item.get("itemCode") or "").strip() + if code: + codes.append(code) + if len(rows) < _NAVER_PAGE_SIZE: + break + page += 1 + await asyncio.sleep(0.15) + return codes + + +async def refresh_sector_map( + write_cache: bool = True, + request_interval: float = 0.15, +) -> Dict: + """외부 소스(네이버 업종)에서 전체 섹터 맵을 수집해 파일 캐시에 기록. + + ⚠ 네트워크 호출 — **배치/스케줄러/명시적 호출에서만** 사용한다. + 진입 판정 등 실시간 경로에서 호출 금지 (§3.1). + + 실패 시 기존 캐시 파일을 덮어쓰지 않는다(정적 표 + 기존 캐시로 폴백 유지). + + Returns: + {"ok":bool,"groups":int,"stocks":int,"skipped_groups":[...], + "unmapped_industries":[...],"cache_path":str,"updated_at":str} + """ + sectors: Dict[str, str] = {} + industries: Dict[str, str] = {} + unmapped_industries: List[str] = [] + group_count = 0 + + # verify=True 유지 (CLAUDE.md §4) + async with httpx.AsyncClient(verify=True, timeout=10.0) as client: + groups = await fetch_industry_groups(client) + if not groups: + logger.error("[SECTOR_MAP] 업종 그룹 조회 실패 — 캐시 갱신 취소 (기존 캐시/정적 표 유지)") + return {"ok": False, "groups": 0, "stocks": 0, "reason": "groups_fetch_failed"} + + for group in groups: + try: + no = int(group.get("no")) + except (TypeError, ValueError): + continue + name = (group.get("name") or "").strip() + if no in _SKIP_INDUSTRY_NOS: + continue + + sector = canonical_sector_for(name, no) + if not sector: + continue + if name and name not in _NAVER_INDUSTRY_TO_SECTOR: + unmapped_industries.append(f"{no}:{name}->{sector}") + + codes = await fetch_industry_members(client, no) + for code in codes: + sectors[code] = sector + if name: + industries[code] = name + group_count += 1 + await asyncio.sleep(request_interval) + + if not sectors: + logger.error("[SECTOR_MAP] 수집 종목 0건 — 캐시 갱신 취소") + return {"ok": False, "groups": group_count, "stocks": 0, "reason": "empty_result"} + + updated_at = datetime.now().astimezone().isoformat() + payload = { + "version": 1, + "updated_at": updated_at, + "source": "naver_industry", + "stats": { + "groups": group_count, + "stocks": len(sectors), + "skipped_groups": sorted(_SKIP_INDUSTRY_NOS), + "unmapped_industries": unmapped_industries, + }, + "industries": industries, + "sectors": sectors, + } + + result = { + "ok": True, + "groups": group_count, + "stocks": len(sectors), + "skipped_groups": sorted(_SKIP_INDUSTRY_NOS), + "unmapped_industries": unmapped_industries, + "cache_path": str(get_cache_path()), + "updated_at": updated_at, + } + + if write_cache: + try: + await asyncio.to_thread(_write_cache_file, payload) + load_cache(force=True) + except Exception as exc: + logger.error("[SECTOR_MAP] 캐시 파일 기록 실패: %s", exc) + result["ok"] = False + result["reason"] = f"write_failed: {exc}" + + if unmapped_industries: + logger.warning( + "[SECTOR_MAP] 매핑 표에 없는 업종 %d건 → naver_ 개별 버킷 처리: %s", + len(unmapped_industries), ", ".join(unmapped_industries[:10]), + ) + logger.info( + "[SECTOR_MAP] 갱신 완료: %d업종 / %d종목 → %s", group_count, len(sectors), get_cache_path() + ) + return result + + +def _write_cache_file(payload: Dict) -> None: + """캐시 파일 원자적 기록 (동기 — asyncio.to_thread 내부에서만 호출).""" + path = get_cache_path() + path.parent.mkdir(parents=True, exist_ok=True) + tmp = path.with_suffix(".json.tmp") + with open(tmp, "w", encoding="utf-8") as fp: + fp.write(_dumps(payload)) + os.replace(tmp, path) diff --git a/backend/app/services/short_term_strategy.py b/backend/app/services/short_term_strategy.py deleted file mode 100644 index e6d5be2..0000000 --- a/backend/app/services/short_term_strategy.py +++ /dev/null @@ -1,291 +0,0 @@ -""" -15분봉 단기 전략 (CLAUDE.md 섹션 8.2 — 승률 최적화) - -전체 자본의 최대 25% 이내에서 운용. -일봉 RSI ≤ 40 관심 종목에 한해 15분봉 신호를 감지한다. - -[.env 설정] - SHORT_TERM_MIN_ATR_RATIO (거래비용 커버용 ATR 비율 ≥ 0.8%) - SHORT_TERM_RSI_LOOKBACK (RSI 반등 확인 과거 봉 수) - SHORT_TERM_VOLUME_MULTIPLIER (거래량 배수) - DAY_TRADE_ENABLED (단기 전략 활성화 여부) -""" -import logging -from dataclasses import dataclass, field -from datetime import time as dtime -from enum import Enum -from typing import List, Optional - -from app.core.config import settings -from .mean_reversion_strategy import calculate_rsi, calculate_bollinger - -logger = logging.getLogger(__name__) - - -# 비용 가정 — 백테스트 시 왕복으로 적용 -COMMISSION_RATE = 0.003 -SLIPPAGE_RATE = 0.002 -TOTAL_COST_RATE = COMMISSION_RATE + SLIPPAGE_RATE - -# 장 운영 시간 필터 (동적 로드) -def _get_skip_open_until(): - return dtime(settings.SHORT_TERM_SKIP_OPEN_HOUR, settings.SHORT_TERM_SKIP_OPEN_MIN) - -def _get_skip_close_from(): - return dtime(settings.SHORT_TERM_SKIP_CLOSE_HOUR, settings.SHORT_TERM_SKIP_CLOSE_MIN) - -_SKIP_OPEN_UNTIL = _get_skip_open_until() # 장 초반 30분 제외 (개장 직후 변동성 과다) -_SKIP_CLOSE_FROM = _get_skip_close_from() # 마감 15분 전 제외 (종가 청산 압력) - - -class TimeFrame(str, Enum): - M15 = "15m" - H1 = "60m" - - -@dataclass -class EntrySignal: - ticker: str - timeframe: TimeFrame - reason: str - rsi: float - close: float - atr_ratio: float = 0.0 # ATR/close — 수익 가능성 지표 - - -@dataclass -class ExitSignal: - action: str # "SELL_ALL" - reason: str - sell_pct: float # 1.0 = 전량 - - -class ShortTermStrategy: - """ - 15분봉 단기 평균회귀 전략 (승률 우선 최적화) - - 진입 조건 (모두 충족 필요): - 1. 일봉 RSI(14) ≤ 40 — 관심 종목 사전 필터 - 2. 15분봉 RSI(14) ≤ 30 — 과매도 - 3. 15분봉 RSI 반등 중 — RSI_now > RSI_3봉전 (바닥 확인) - 4. 15분봉 종가 ≤ BB 하단 × 1.01 - 5. 15분봉 종가 > MA5 — Fake-out 방지 - 6. 15분봉 거래량 > MA20 × 1.3 — CLAUDE.md 7.2 - 7. ATR/close ≥ 0.8% — 거래비용 커버 가능한 변동성 - 8. 반전 캔들 (optional) — 양봉 or 핵머 캔들 - 9. 시간 필터 (optional) — 09:30~15:15만 허용 - - 청산 조건 (우선순위 순): - 1. 손절: close ≤ entry - 1.0×ATR [승률 핵심 — 기존 코드에 누락] - 2. RSI(14) ≥ 55 회복 - 3. 수익률 ≥ 1.5 × ATR_15m_pct - """ - - TIMEFRAME = TimeFrame.M15 - DAILY_RSI_THRESHOLD = 40.0 - M15_RSI_ENTRY = 30.0 - M15_RSI_EXIT = 55.0 - - def __init__(self, max_capital_ratio: float = None): - # .env에서 DAY_TRADE_CAPITAL_RATIO 읽기 (없으면 기본값 0.25) - self.max_capital_ratio = max_capital_ratio or settings.DAY_TRADE_CAPITAL_RATIO - - # ------------------------------------------------------------------ - # Public API - # ------------------------------------------------------------------ - - def entry_signal( - self, - ticker: str, - daily_rsi: float, - m15_closes: List[float], - m15_volumes: List[int], - current_volume: int, - atr_15m: Optional[float] = None, - m15_opens: Optional[List[float]] = None, - m15_lows: Optional[List[float]] = None, - trade_time: Optional[dtime] = None, - ) -> Optional[EntrySignal]: - """ - 15분봉 진입 신호 감지 - - Args: - ticker: 종목코드 - daily_rsi: 일봉 RSI(14) - m15_closes: 15분봉 종가 리스트 (최소 28개) - m15_volumes: 15분봉 거래량 리스트 (최소 20개) - current_volume: 현재 봉 거래량 - atr_15m: 15분봉 ATR — None이면 ATR 비율 필터 스킵 - m15_opens: 15분봉 시가 — None이면 반전 캔들 스킵 - m15_lows: 15분봉 저가 — None이면 반전 캔들 스킵 - trade_time: 현재 시각 — None이면 시간 필터 스킵 - - Returns: - EntrySignal if 조건 충족, else None - """ - # 조건 1: 일봉 사전 필터 - if daily_rsi > self.DAILY_RSI_THRESHOLD: - return None - - # 조건 9: 시간 필터 (제공된 경우) - if trade_time is not None: - if not (_SKIP_OPEN_UNTIL <= trade_time < _SKIP_CLOSE_FROM): - return None - - min_len = 25 + settings.SHORT_TERM_RSI_LOOKBACK - if len(m15_closes) < min_len or len(m15_volumes) < 20: - return None - - close = m15_closes[-1] - - # 조건 2: 15분봉 RSI 과매도 - rsi_15m = calculate_rsi(m15_closes, 14) - if rsi_15m is None or rsi_15m > self.M15_RSI_ENTRY: - return None - - # 조건 3: RSI 반등 방향 확인 — 바닥에서 회복 중인지 확인 (승률 핵심) - rsi_prev = calculate_rsi(m15_closes[:-settings.SHORT_TERM_RSI_LOOKBACK], 14) - if rsi_prev is not None and rsi_15m <= rsi_prev: - # RSI가 여전히 하락 중 → 변곡점 전이므로 진입 금지 - return None - - # 조건 4: BB 하단 근처 - bb = calculate_bollinger(m15_closes, 20) - if bb is None: - return None - _, _, bb_lower = bb - if close > bb_lower * settings.SHORT_TERM_BB_LOWER_BUFFER: - return None - - # 조건 5: MA5 상향돌파 (CLAUDE.md 7.2: 5MA 상향돌파 필수) - # [2026-03-15 CLAUDE.md 정합성 수정] BB 하단 근처 완화 조건 제거 - ma5 = sum(m15_closes[-5:]) / 5 - if close < ma5: - return None - - # 조건 6: 거래량 급증 (CLAUDE.md 7.2: 1.3배) - vol_ma20 = sum(m15_volumes[-20:]) / 20 - if vol_ma20 == 0 or current_volume <= vol_ma20 * settings.SHORT_TERM_VOLUME_MULTIPLIER: - return None - - # 조건 7: ATR 비율 필터 — 5,000원 필터 대체 - atr_ratio = 0.0 - if atr_15m is not None: - atr_ratio = atr_15m / close if close > 0 else 0.0 - if atr_ratio < settings.SHORT_TERM_MIN_ATR_RATIO: - # 변동성 부족 → 1.5×ATR 익절 목표가 왕복 비용에 도달안함 - return None - - # 조건 8: 반전 캔들 (데이터 제공 시) - if m15_opens is not None and m15_lows is not None: - if not _is_reversal_candle(m15_opens, m15_lows, m15_closes): - return None - - return EntrySignal( - ticker=ticker, - timeframe=self.TIMEFRAME, - reason=( - f"RSI({rsi_15m:.1f})≤30↑ + BB하단 + MA5돌파" - f" + 거래량({current_volume / vol_ma20:.1f}x)" - + (f" + ATR비율({atr_ratio * 100:.1f}%)" if atr_15m else "") - ), - rsi=round(rsi_15m, 2), - close=close, - atr_ratio=round(atr_ratio, 4), - ) - - def exit_signal( - self, - ticker: str, - m15_closes: List[float], - entry_price: float, - atr_15m: Optional[float] = None, - ) -> Optional[ExitSignal]: - """ - 15분봉 청산 신호 - - 청산 우선순위: - 1. 손절 : close ≤ max(entry - 1.0×ATR, entry × 0.98) [핵심 추가] - 2. RSI ≥ 55 회복 - 3. 수익률 ≥ 1.5 × ATR_15m_pct - """ - if len(m15_closes) < 15: - return None - - close = m15_closes[-1] - rsi_15m = calculate_rsi(m15_closes, 14) - - # 청산 우선순위 1: 손절 [기존 코드에서 누락된 핸심 로직] - if entry_price > 0: - if atr_15m: - atr_stop = entry_price - 1.0 * atr_15m - hard_stop = entry_price * (1 - settings.SHORT_TERM_HARD_STOP_PCT) # -2% 하드 스탑 - stop_price = max(atr_stop, hard_stop) # [FIX] 타이트한 쪽 = 진입가에 가까운 = 높은 가격 (CLAUDE.md 7.2) - else: - stop_price = entry_price * (1 - settings.SHORT_TERM_HARD_STOP_PCT) # ATR 없으면 -2% 고정 - - if close <= stop_price: - loss_pct = (close - entry_price) / entry_price * 100 - return ExitSignal( - action="SELL_ALL", - reason=f"손절 close({close:.0f}) ≤ stop({stop_price:.0f}) | {loss_pct:.1f}%", - sell_pct=1.0, - ) - - # 청산 우선순위 2: RSI ≥ 55 회복 - if rsi_15m is not None and rsi_15m >= self.M15_RSI_EXIT: - return ExitSignal( - action="SELL_ALL", - reason=f"15분봉 RSI({rsi_15m:.1f})≥55 회복", - sell_pct=1.0, - ) - - # 청산 우선순위 3: 수익률 ≥ 1.5 × ATR_15m_pct - if atr_15m and entry_price > 0: - profit_pct = (close - entry_price) / entry_price - atr_pct = atr_15m / entry_price - if profit_pct >= settings.SHORT_TERM_ATR_PROFIT_MULTIPLIER * atr_pct: - return ExitSignal( - action="SELL_ALL", - reason=( - f"익절 수익률({profit_pct * 100:.1f}%)" - f" ≥ {settings.SHORT_TERM_ATR_PROFIT_MULTIPLIER}×ATR({atr_pct * 100:.1f}%)" - ), - sell_pct=1.0, - ) - - return None - - def is_capital_available( - self, total_assets: float, current_short_term_allocation: float - ) -> bool: - """CLAUDE.md 8.3: 단기 전략 자본 한도 충족 여부""" - return current_short_term_allocation < total_assets * self.max_capital_ratio - - @staticmethod - def net_profit_pct(gross_profit_pct: float) -> float: - """수수료 + 슬리피지 차감 후 순수익률 (왕복 기준)""" - return gross_profit_pct - TOTAL_COST_RATE * 2 - - -# ------------------------------------------------------------------ -# Internal helpers -# ------------------------------------------------------------------ - -def _is_reversal_candle( - opens: List[float], - lows: List[float], - closes: List[float], -) -> bool: - """반전 캔들 확인 — 양봉 또는 핸머(카드) 여부 - - 조건 (둘 중 하나): - - 양봉: close > open - - 해머: 하단 꼬리 > 몸통의 2배 (핀바 / 행장형) - """ - if not opens or not lows or not closes: - return False - o, l, c = opens[-1], lows[-1], closes[-1] - body = abs(c - o) - lower_shadow = min(o, c) - l - return (c > o) or (lower_shadow > max(body * 2.0, 1e-6)) diff --git a/backend/app/services/signal_types.py b/backend/app/services/signal_types.py index 3089222..0aef012 100644 --- a/backend/app/services/signal_types.py +++ b/backend/app/services/signal_types.py @@ -2,6 +2,19 @@ technical_indicators.py 의 description 문자열과 1:1 매핑. trade_executor.py 에서 하드코딩 리스트 대신 사용. + +[2026-09-27] 매핑 규약 확장 + technical_indicators.py 가 유일한 신호 생산자가 아니다. 스윙 듀얼 전략 + 스캐너(swing/entry_service.py)와 레인지 전략도 이 Enum 값을 그대로 + stock["signals"] 에 채워 trade_executor 의 키워드 판정을 통과시킨다. + 따라서 규약은 다음과 같이 확장된다: + · technical_indicators.py 가 만드는 description 은 **반드시** 여기의 + 어떤 값을 부분 문자열로 포함해야 한다(기존 1:1 매핑 유지). + · 반대로 여기의 모든 값이 technical_indicators.py 에 대응 description 을 + 가져야 하는 것은 **아니다**. 전략 스캐너 전용 신호(예: 신고가돌파)는 + technical_indicators.py 에 대응물이 없다. + 값 추가 시 주의: 판정은 substring 매칭(`k in text_signals`)이므로 새 값이 + SELL/BLOCK 키워드의 부분 문자열이 되지 않도록 한다. """ from enum import Enum @@ -18,6 +31,12 @@ class BuySignal(str, Enum): 볼린저하한선 = "볼린저 하한선 터치" 횡보장평균회귀 = "횡보장 — 평균회귀 유리" 거래량증가상승 = "거래량 증가와 상승" + # [2026-09-27] 모멘텀 돌파(swing/entry_service.scan_dual_strategy_signals) 전용. + # mean_reversion_strategy.check_momentum_breakout_signal 의 진입 근거 + # "20일 신고가 돌파 + 거래량 2배 + RSI 50~70" 을 정확히 표현한다. + # 기존 값 중 이 성격을 표현하는 것이 없었다(거래량증가상승은 돌파를 뜻하지 않고, + # 볼린저하한선/RSI바닥탈출 은 평균회귀 신호로 의미가 정반대다). + 신고가돌파 = "20일 신고가 돌파" class SellSignal(str, Enum): @@ -36,6 +55,10 @@ class StrongBuySignal(str, Enum): 볼린저하한선 = "볼린저 하한선 터치" 눌림목진입 = "눌림목 진입 매수타점" 정배열눌림목 = "정배열 눌림목 매수" + # [2026-09-27] 20일 신고가 돌파는 그 자체로 단독 진입 근거(CLAUDE.md §8.3 모멘텀 돌파). + # 세 조건(신고가·거래량 2배·RSI 50~70)이 동시 충족돼야만 생성되는 복합 신호이므로 + # trade_executor 의 비-rule 강신호 요건(단독 매수 허용)에 부합한다. + 신고가돌파 = "20일 신고가 돌파" class BlockSignal(str, Enum): diff --git a/backend/app/services/sizing_core.py b/backend/app/services/sizing_core.py new file mode 100644 index 0000000..a711aec --- /dev/null +++ b/backend/app/services/sizing_core.py @@ -0,0 +1,200 @@ +"""포지션 사이징 코어 (순수 계산 — I/O·DB·네트워크·설정 의존성 없음) + +CLAUDE.md §8.5-2 "포지션 사이징 4벌 중복" 통합용 단일 출처(single source of truth). + +### 설계 원칙 +1. **순수 함수.** `settings` / DB / 네트워크 / `await` 를 참조하지 않는다. + 비율·상한·기준값은 모두 **인자로 받는다**(호출부가 `settings` 에서 읽어 전달). +2. **금액 동일성 보존이 목적.** 이 모듈은 통합 전 4벌 구현의 반환 금액을 + 부동소수점 단위로 재현한다. "더 나은 사이징"으로의 개선은 백테스트를 동반한 + 별도 작업이다(CLAUDE.md §8.1-4: 백테스트 없이 실전 사이징 변경 금지). +3. **억지 통합 금지.** 전략 고유 로직(Kelly 통계, RSI/ADX 가중, regime 분기, + 적응형 cap, 글로벌 자본 가드, 스윙 자본 캡, 패턴 메모리)은 각 전략 파일에 남기고 + 이 모듈은 **공통 원시 연산**만 제공한다. +4. **`floor` 유무를 함수로 구분한다.** `max(0.0, ...)` 가 있는 구현과 없는 구현이 + 섞여 있었으므로 `budget_by_ratios`(floor 있음) / `clamp_by_ratios`(floor 없음)로 + 분리해 기존 동작을 그대로 보존한다. + +### 대응 관계 (통합 전 구현 → 이 모듈) +| 통합 전 | 이 모듈 | +|---------|---------| +| `mean_reversion_strategy.calculate_position_size` 3중 min | `budget_by_ratios` | +| `mean_reversion_strategy.calculate_position_size_v2` 배율 후 재클램프 | `clamp_by_ratios` | +| `day_trading_strategy.calculate_day_trade_position_size` 3중 min | `budget_by_ratios` | +| `us_stock_strategy.calculate_us_position_size` 3중 min | `budget_by_ratios` | +| `trade_executor._calculate_kelly_position` Kelly 수식 | `half_kelly_fraction` + `clamp_by_ratios` | +| `trade_executor` 투자금액→주수 (Decimal) | `amount_to_quantity` | +| `trade_executor` 소액계좌 최소주문 하향 | `effective_min_buy_amount` | +| `daytrading` confidence 스케일 `min(1.2, conf/0.7)` | `apply_confidence_scale` | + +### 한도 **숫자**는 이 모듈에 없다 +4% / 6% / 5% / 30% / 70% / 95% / 15만원 등은 전부 호출부(`settings` 또는 모듈 상수)가 +소유한다. 이 모듈은 비율을 해석만 한다. +""" +from decimal import Decimal +from typing import List, Optional, Sequence + +__all__ = [ + "cap_by_ratio", + "remaining_strategy_room", + "budget_by_ratios", + "clamp_by_ratios", + "apply_confidence_scale", + "amount_to_quantity", + "half_kelly_fraction", + "effective_min_buy_amount", +] + + +# ── 비율 → 금액 ────────────────────────────────────────────────────────────── + +def cap_by_ratio(total_assets: float, ratio: float) -> float: + """총자산 × 비율. 음수 total_assets 를 보정하지 않는다(상위 min/floor 가 처리).""" + return total_assets * ratio + + +def remaining_strategy_room( + total_assets: float, + strategy_ratio: float, + current_allocation: float, +) -> float: + """전략(스윙/DT/미국) 잔여 예산 = 총자산×비율 − 현재 해당 전략 배분액. + + **floor 없음** — 한도를 이미 초과했으면 음수를 반환한다. 통합 전 구현과 동일하며, + 음수 처리는 `budget_by_ratios` 의 `max(0.0, ...)` 이 담당한다. + """ + return total_assets * strategy_ratio - current_allocation + + +def budget_by_ratios( + total_assets: float, + available_cash: float, + current_allocation: float, + max_per_position: float, + max_strategy_ratio: float, +) -> float: + """3중 한도 교집합 = `max(0, min(종목당 한도, 전략 잔여, 가용현금))`. + + 통합 전 4벌 중 3벌(국내 스윙 / 데이트레이딩 / 미국)이 **완전히 동일한** 이 식을 + 각자 복제하고 있었다. + + Args: + total_assets: 총자산(통화 단위는 호출부 기준. 미국은 KRW 환산값). + available_cash: 가용 현금. + current_allocation: 해당 전략에 이미 배분된 금액. + max_per_position: 종목당 최대 비중(예: 0.04). + max_strategy_ratio: 전략 전체 최대 비중(예: BULL 0.70 / DT 0.30). + + Returns: + 매수 가능 금액. 0 이면 매수 불가. + """ + max_by_position = cap_by_ratio(total_assets, max_per_position) + remaining = remaining_strategy_room(total_assets, max_strategy_ratio, current_allocation) + return max(0.0, min(max_by_position, remaining, available_cash)) + + +def clamp_by_ratios( + amount: float, + total_assets: float, + ratios: Sequence[float], + available_cash: float, +) -> float: + """배율 적용 **후** 재클램프 = `min(amount, total×r₁, …, available_cash)`. + + **floor(`max(0, …)`) 없음** — 통합 전 `calculate_position_size_v2` 마지막 줄 + (`min(base*mult, max_allowed, available_cash)`) 및 Kelly 반환식과 동일하게 유지한다. + `ratios` 가 비면 `min(amount, available_cash)`. + """ + caps = [amount, available_cash] + caps.extend(cap_by_ratio(total_assets, r) for r in ratios) + return min(caps) + + +# ── 신뢰도 스케일 ──────────────────────────────────────────────────────────── + +def apply_confidence_scale( + amount: float, + confidence: float, + base: float = 0.7, + cap: float = 1.2, +) -> float: + """신뢰도 비례 조정 = `amount × min(cap, confidence / base)`. + + 데이트레이딩 진입부(`daytrading/entry_service`, `api/endpoints/day_trading`)가 쓰는 + 식과 동일하다(0.6→86%, 0.7→100%, 0.85→121%). 증액 시에는 **호출부에서** 반드시 + `clamp_by_ratios` 로 재클램프해야 한다(한도 초과 방지). + + 주의: 이 함수만으로는 한도를 보장하지 않는다. `base <= 0` 이면 조정하지 않는다. + """ + if base <= 0: + return amount + return amount * min(cap, confidence / base) + + +# ── 금액 → 주수 ────────────────────────────────────────────────────────────── + +def amount_to_quantity(amount: float, price: float) -> int: + """예산 → 주수 (내림). `Decimal(str(...))` 경로로 부동소수점 오차를 피한다. + + 규약: + - `price <= 0` → 0 (0 나눗셈 예외 대신 0). 호출부에 이미 가격 유효성 가드가 있다. + - `amount <= 0` → 0. **`max(1, …)` 를 쓰지 않는다** — 예산 0에 1주를 매수하는 + 버그였다. (반면 "보유수량 × 분할비율"에서의 `max(1, …)` 는 정당하므로 + 이 함수의 대상이 아니다.) + """ + if price <= 0 or amount <= 0: + return 0 + return int(Decimal(str(amount)) / Decimal(str(price))) + + +# ── Kelly (통계 부분만 — DB 조회는 호출부) ────────────────────────────────── + +def half_kelly_fraction( + profit_loss_rates: List[float], + min_trades: int = 5, +) -> Optional[float]: + """Half-Kelly 비중 f*/2 계산 (순수 통계). DB 조회는 호출부가 담당한다. + + f* = (p·b − (1−p)) / b, p=승률, b=평균이익/평균손실(Payoff Ratio) + + Returns: + `max(0.0, f*/2)`. 아래 경우는 **None**(호출부는 0.0 반환 후 fallback): + - 거래 수 < `min_trades` + - 전승(손실 0건) 또는 전패(이익 0건) → b 계산 불가 + """ + rates = [float(r) for r in profit_loss_rates] + if len(rates) < min_trades: + return None + wins = [r for r in rates if r > 0] + losses = [abs(r) for r in rates if r < 0] + if not wins or not losses: + return None + p = len(wins) / len(rates) + b = (sum(wins) / len(wins)) / (sum(losses) / len(losses)) + kelly_f = (p * b - (1 - p)) / b + return max(0.0, kelly_f / 2) + + +# ── 최소 주문금액 ──────────────────────────────────────────────────────────── + +def effective_min_buy_amount( + config_min_amount: float, + total_assets: float, + position_cap_ratio: float, + factor: float = 0.9, +) -> float: + """실효 최소 주문금액 = `min(config값, 총자산 × 종목당상한 × factor)`. + + **의도된 설계(변경 금지)**: 총자산이 작으면 종목당 예산(총자산×4%) 자체가 + config 최소액(15만원) 미만이 되어 모든 종목이 영구 차단된다 + (2026-09-23 하루 141건 차단 사례). 큰 계좌에서는 항상 config값이 더 작게 선택되므로 + 문서(§5.1 "15만원")와 동일하게 동작한다. + + `total_assets <= 0` 또는 `position_cap_ratio <= 0` 이면 config값을 그대로 반환. + """ + if total_assets <= 0 or position_cap_ratio <= 0: + return config_min_amount + small_account_cap = total_assets * position_cap_ratio * factor + if small_account_cap <= 0: + return config_min_amount + return min(config_min_amount, small_account_cap) diff --git a/backend/app/services/strategy_config_service.py b/backend/app/services/strategy_config_service.py index 45e48b6..09b2015 100644 --- a/backend/app/services/strategy_config_service.py +++ b/backend/app/services/strategy_config_service.py @@ -57,7 +57,9 @@ DEFAULT_PARAMS: Dict[str, tuple] = { "MAX_POSITION_SIZE": ("RISK", "종목당 최대 비중", "1종목당 총자산 대비 최대 비율 (0.04 = 4%)", "float", "0.04", "0.01", "0.20"), "MAX_DAILY_TRADES": ("RISK", "일일 최대 매매 횟수", "하루 최대 신규 매수 횟수", "int", "5", "1", "50"), "MAX_HOLDINGS_COUNT": ("RISK", "최대 동시 보유 종목", "동시에 보유 가능한 최대 종목 수", "int", "20", "1", "100"), - "STOP_LOSS_PERCENT": ("RISK", "기본 손절 비율", "저변동 종목 손절선 (%)", "float", "-6.0", "-20", "-1"), + # [2026-09-27] config.py 정합: 기본값 -6.0 → -7.0 (config.py STOP_LOSS_PERCENT=7.0, _META는 음수 규약 유지) + "STOP_LOSS_PERCENT": ("RISK", "기본 손절 비율", "저변동 종목 손절선 (%)", "float", "-7.0", "-20", "-1"), + # [2026-09-27] config.py 정합 검토: config.py STOP_LOSS_HIGH_VOL=10.0(양수 저장) vs 여기 -10.0 — 크기 동일, 단순 부호 규약 차이이므로 변경하지 않음 "STOP_LOSS_HIGH_VOL": ("RISK", "고변동 손절 비율", "고변동성 종목 손절선 (%)", "float", "-10.0", "-30", "-1"), "TAKE_PROFIT_PERCENT": ("RISK", "기본 익절 비율", "익절 기본값 (%)", "float", "5.0", "1", "50"), "ATR_STOP_MULTIPLIER": ("RISK", "ATR 손절 배수", "ATR × 배수 = 손절선", "float", "2.5", "1.0", "5.0"), @@ -67,7 +69,7 @@ DEFAULT_PARAMS: Dict[str, tuple] = { "MIN_PRICE_KRW": ("RISK", "국내 최소 주가 (원)", "이 가격 미만 종목 매수 금지", "int", "5000", "100", "50000"), "MIN_PRICE_USD": ("RISK", "해외 최소 주가 (달러)", "이 가격 미만 종목 매수 금지", "float", "4.0", "0.1", "100"), "CONSECUTIVE_LOSS_BAN_COUNT":("RISK", "연속 손실 금지 횟수", "N회 연속 손실 시 해당 종목 진입 금지", "int", "3", "1", "10"), - "CONSECUTIVE_LOSS_BAN_DAYS": ("RISK", "연속 손실 금지 기간(일)", "진입 금지 유지 거래일 수", "int", "30", "1", "180"), + "CONSECUTIVE_LOSS_BAN_DAYS": ("RISK", "연속 손실 금지 기간(일)", "진입 금지 유지 캘린더일 수 (30거래일 ≈ 42)", "int", "42", "1", "180"), "MEAN_REVERSION_MAX_CAPITAL":("RISK", "평균회귀 최대 자본 비율", "평균회귀 전략에 투입 가능한 총자본 비율", "float", "0.30", "0.05", "1.0"), # ── FILTER ──────────────────────────────────────────────────────────────── @@ -90,25 +92,32 @@ DEFAULT_PARAMS: Dict[str, tuple] = { "WATCHLIST_EXIT_SCORE": ("FILTER", "관심종목 제거 점수", "이 점수 이하이면 관심종목에서 제거", "int", "20", "0", "60"), # ── MEAN_REVERSION ──────────────────────────────────────────────────────── - "MEAN_REVERSION_RSI_THRESHOLD": ("MEAN_REVERSION", "평균회귀 RSI 과매도 기준", "RSI 이 값 이하면 과매도로 진입 검토", "float", "30.0", "10", "50"), + # [2026-09-27] config.py 정합: 기본값 30.0 → 46.0 (CLAUDE.md §8.2 BULL 진입 RSI<46), max 50 → 60 + "MEAN_REVERSION_RSI_THRESHOLD": ("MEAN_REVERSION", "평균회귀 RSI 과매도 기준", "RSI 이 값 이하면 과매도로 진입 검토", "float", "46.0", "10", "60"), "MEAN_REVERSION_MULTIPLIER_STRONG": ("MEAN_REVERSION", "강한 신호 포지션 배수", "강한 신호 시 기본 포지션 대비 배수", "float", "1.50", "1.0", "3.0"), "MEAN_REVERSION_MULTIPLIER_NORMAL": ("MEAN_REVERSION", "중간 신호 포지션 배수", "중간 신호 시 기본 포지션 대비 배수", "float", "1.25", "1.0", "2.0"), "MEAN_REVERSION_MULTIPLIER_WEAK": ("MEAN_REVERSION", "약한 신호 포지션 배수", "약한 신호 시 기본 포지션 대비 배수", "float", "1.00", "0.5", "1.5"), # ── DAY_TRADE ───────────────────────────────────────────────────────────── "DAY_TRADE_ENABLED": ("DAY_TRADE", "데이트레이딩 활성화", "데이트레이딩 전략 On/Off", "bool", "True", None, None), - "DAY_TRADE_MAX_POSITIONS": ("DAY_TRADE", "동시 최대 종목 수", "데이트레이딩 동시 보유 최대 종목 수", "int", "3", "1", "20"), - "DAY_TRADE_CAPITAL_RATIO": ("DAY_TRADE", "전체 자본 비율 상한", "데이트레이딩에 투입 가능한 총자본 비율", "float", "0.25", "0.05","1.0"), - "DAY_TRADE_PER_STOCK_RATIO": ("DAY_TRADE", "종목당 자본 비율", "1종목당 최대 투입 자본 비율", "float", "0.10", "0.01","0.5"), + # [2026-09-27] config.py 정합: 기본값 3 → 5 + "DAY_TRADE_MAX_POSITIONS": ("DAY_TRADE", "동시 최대 종목 수", "데이트레이딩 동시 보유 최대 종목 수", "int", "5", "1", "20"), + # [2026-09-27] config.py 정합: 기본값 0.25 → 0.30 (스윙:DT = 70:30) + "DAY_TRADE_CAPITAL_RATIO": ("DAY_TRADE", "전체 자본 비율 상한", "데이트레이딩에 투입 가능한 총자본 비율", "float", "0.30", "0.05","1.0"), + # [2026-09-27] config.py 정합: 기본값 0.10 → 0.06 (min 0.01 이므로 범위 조정 불필요) + "DAY_TRADE_PER_STOCK_RATIO": ("DAY_TRADE", "종목당 자본 비율", "1종목당 최대 투입 자본 비율", "float", "0.06", "0.01","0.5"), "DAY_TRADE_STOP_LOSS_PCT": ("DAY_TRADE", "데이트레이딩 손절 비율", "종목 손절선 (%)", "float", "0.02", "0.005","0.1"), "DAY_TRADE_DAILY_LOSS_LIMIT": ("DAY_TRADE", "일일 손실 한도", "일일 손실 한도 초과 시 데이트레이딩 중단 (%)", "float", "-0.03", "-0.2", "-0.005"), "DAY_TRADE_EOD_FLAT_HOUR": ("DAY_TRADE", "EOD 강제 청산 시 (시)", "장 마감 전 강제 청산 시작 시각 (시)", "int", "15", "13", "15"), "DAY_TRADE_EOD_FLAT_MIN": ("DAY_TRADE", "EOD 강제 청산 시 (분)", "장 마감 전 강제 청산 시작 시각 (분)", "int", "20", "0", "59"), "DAY_TRADE_ORB_VOL_MULT": ("DAY_TRADE", "ORB 거래량 배수", "ORB 돌파 확인용 최소 거래량 배수", "float", "1.5", "1.0", "5.0"), - "DAY_TRADE_TRAILING_TRIGGER": ("DAY_TRADE", "트레일링 발동 수익률", "이 수익률 초과 시 트레일링 스탑 발동", "float", "0.015", "0.005","0.1"), + # [2026-09-27] config.py 정합: 기본값 0.015 → 0.01 (min 0.005 이므로 범위 조정 불필요) + "DAY_TRADE_TRAILING_TRIGGER": ("DAY_TRADE", "트레일링 발동 수익률", "이 수익률 초과 시 트레일링 스탑 발동", "float", "0.01", "0.005","0.1"), "DAY_TRADE_RSI_BREAKEVEN_THRESHOLD": ("DAY_TRADE", "RSI_BB 진입 기준", "5분봉 RSI 이 값 이하이면 RSI_BB 진입", "float", "40.0", "20", "60"), - "DAY_TRADE_ORB_STOP_LOSS_PCT": ("DAY_TRADE", "ORB 손절 비율", "ORB 전략 손절선", "float", "0.02", "0.005","0.1"), - "DAY_TRADE_REVERSION_STOP_LOSS_PCT": ("DAY_TRADE", "VWAP/RSI_BB 손절 비율", "VWAP/RSI_BB 전략 손절선", "float", "0.025", "0.005","0.1"), + # [2026-09-27] config.py 정합: 기본값 0.02 → 0.03 (노이즈 손절 방지) + "DAY_TRADE_ORB_STOP_LOSS_PCT": ("DAY_TRADE", "ORB 손절 비율", "ORB 전략 손절선", "float", "0.03", "0.005","0.1"), + # [2026-09-27] config.py 정합: 기본값 0.025 → 0.035 (반등 여유 확보) + "DAY_TRADE_REVERSION_STOP_LOSS_PCT": ("DAY_TRADE", "VWAP/RSI_BB 손절 비율", "VWAP/RSI_BB 전략 손절선", "float", "0.035", "0.005","0.1"), "DAY_TRADE_TRAILING_ATR_MULT": ("DAY_TRADE", "트레일링 ATR 배수", "트레일링 스탑 계산 ATR 배수 (whipsaw 방지 1.5)", "float", "1.5", "0.5", "3.0"), "DAY_TRADE_ORB_BREAKOUT_MARGIN": ("DAY_TRADE", "ORB 돌파 여유 비율", "ORB 돌파 판정 최소 여유율", "float", "0.002", "0", "0.02"), "DAY_TRADE_ENTRY_CUTOFF_HOUR": ("DAY_TRADE", "신규 진입 마감 시 (시)", "이 시각 이후 신규 진입 불가 (시)", "int", "15", "9", "15"), @@ -117,7 +126,8 @@ DEFAULT_PARAMS: Dict[str, tuple] = { "DAY_TRADE_EOD_MIN": ("DAY_TRADE", "EOD 청산 시각 (분)", "EOD 강제 청산 시작 시각 (분)", "int", "20", "0", "59"), # ── GENERAL ─────────────────────────────────────────────────────────────── - "ANALYSIS_STOCK_COUNT": ("GENERAL", "배치 분석 종목 수", "아침 배치에서 분석할 최대 종목 수", "int", "150", "10", "1000"), + # [2026-09-27] config.py 정합: 기본값 150 → 300 + "ANALYSIS_STOCK_COUNT": ("GENERAL", "배치 분석 종목 수", "아침 배치에서 분석할 최대 종목 수", "int", "300", "10", "1000"), "AUTO_TRADE_TOP_N_PER_RULE": ("GENERAL", "규칙당 자동매매 종목 수", "매매 규칙 1개당 자동 실행 최대 종목 수", "int", "2", "1", "10"), "ML_ENSEMBLE_ENABLED": ("GENERAL", "ML 앙상블 활성화", "LightGBM + LSTM + RF 앙상블 모델 사용", "bool", "True", None, None), "ML_ENSEMBLE_RULE_WEIGHT": ("GENERAL", "룰베이스 스코어 비중", "앙상블 스코어에서 룰베이스 비중", "float", "0.3", "0", "1.0"), @@ -128,7 +138,8 @@ DEFAULT_PARAMS: Dict[str, tuple] = { "MCP_CONFIDENCE_THRESHOLD": ("GENERAL", "MCP AI 최소 신뢰도", "MCP AI 신뢰도 최솟값 (0~1)", "float", "0.5", "0", "1.0"), "SHORT_TERM_MIN_ATR_RATIO": ("GENERAL", "단기 전략 최소 ATR 비율", "단기 전략 진입 조건: ATR/close 최솟값", "float", "0.008", "0", "0.1"), "SHORT_TERM_RSI_LOOKBACK": ("GENERAL", "단기 전략 RSI 반등 확인 봉", "RSI 반등 확인용 과거 봉 수", "int", "3", "1", "10"), - "SHORT_TERM_VOLUME_MULTIPLIER": ("GENERAL", "단기 전략 거래량 배수", "단기 전략 진입 조건: 거래량 배수", "float", "1.5", "1.0", "5.0"), + # [2026-09-27] config.py 정합: 기본값 1.5 → 1.3 + "SHORT_TERM_VOLUME_MULTIPLIER": ("GENERAL", "단기 전략 거래량 배수", "단기 전략 진입 조건: 거래량 배수", "float", "1.3", "1.0", "5.0"), } diff --git a/backend/app/services/strategy_optimizer.py b/backend/app/services/strategy_optimizer.py index 90e480f..387ac6f 100644 --- a/backend/app/services/strategy_optimizer.py +++ b/backend/app/services/strategy_optimizer.py @@ -26,6 +26,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from app.core.database import AsyncSessionLocal from app.models.models import StrategyOptimizationLog, StockDailyPrice from app.services.strategy_config_service import strategy_cfg +from app.core.config import settings from app.services.backtest_engine import BacktestEngine, BacktestConfig from app.core.timeutil import now_kst @@ -123,12 +124,13 @@ async def _get_current_params() -> Dict[str, Any]: """현재 strategy_cfg 값들을 읽어 반환.""" await strategy_cfg._refresh() return { - "RSI_OVERSOLD_LEVEL": strategy_cfg.get("RSI_OVERSOLD_LEVEL", 30.0), - "STOP_LOSS_PERCENT": strategy_cfg.get("STOP_LOSS_PERCENT", -6.0), - "ATR_STOP_MULTIPLIER": strategy_cfg.get("ATR_STOP_MULTIPLIER", 2.5), - "BOLLINGER_STDDEV": strategy_cfg.get("BOLLINGER_STDDEV", 2.0), - "MAX_POSITION_SIZE": strategy_cfg.get("MAX_POSITION_SIZE", 0.04), - "MAX_HOLDINGS_COUNT": strategy_cfg.get("MAX_HOLDINGS_COUNT", 20), + # [2026-09-27] 하드코딩 폴백 → config.py 정합 (우선순위: strategy_cfg(DB) > settings) + "RSI_OVERSOLD_LEVEL": strategy_cfg.get("RSI_OVERSOLD_LEVEL", settings.RSI_OVERSOLD_LEVEL), # 30.0 동일 + "STOP_LOSS_PERCENT": strategy_cfg.get("STOP_LOSS_PERCENT", -abs(settings.STOP_LOSS_PERCENT)), # 옛 폴백 -6.0 → -7.0 + "ATR_STOP_MULTIPLIER": strategy_cfg.get("ATR_STOP_MULTIPLIER", settings.ATR_STOP_MULTIPLIER), # 2.5 동일 + "BOLLINGER_STDDEV": strategy_cfg.get("BOLLINGER_STDDEV", settings.BOLLINGER_STDDEV), # 2.0 동일 + "MAX_POSITION_SIZE": strategy_cfg.get("MAX_POSITION_SIZE", settings.MAX_POSITION_SIZE), # 0.04 동일 + "MAX_HOLDINGS_COUNT": strategy_cfg.get("MAX_HOLDINGS_COUNT", settings.MAX_HOLDINGS_COUNT), # 20 동일 } @@ -155,8 +157,9 @@ async def _evaluate_params( """ try: backtest_params = { - "MEAN_REVERSION_RSI_THRESHOLD": params.get("RSI_OVERSOLD_LEVEL", 30.0), - "MEAN_REVERSION_STOP_LOSS_PCT": params.get("STOP_LOSS_PERCENT", -6.0), + # [2026-09-27] 폴백 config.py 정합 (옛값: 30.0 / -6.0) + "MEAN_REVERSION_RSI_THRESHOLD": params.get("RSI_OVERSOLD_LEVEL", settings.RSI_OVERSOLD_LEVEL), + "MEAN_REVERSION_STOP_LOSS_PCT": params.get("STOP_LOSS_PERCENT", -abs(settings.STOP_LOSS_PERCENT)), "MAX_TOTAL_POSITIONS": min(5, len(stock_codes)), } config = BacktestConfig( diff --git a/backend/app/services/swing/entry_service.py b/backend/app/services/swing/entry_service.py index 8b23bcc..ff17e3f 100644 --- a/backend/app/services/swing/entry_service.py +++ b/backend/app/services/swing/entry_service.py @@ -132,9 +132,28 @@ async def execute_swing_entries(recommendations_response: dict, db: AsyncSession _inject_strategy_type(rec, "swing") # 기존 execute_auto_trade_background 재활용 (시그니처 변경 없음) + # [2026-09-27] execute_auto_trade_background() 는 항상 list 를 반환한다(과거엔 + # 암묵적 None). 이 list 에는 성공한 BUY 뿐 아니라 주문 실패(success=False)한 + # BUY/SELL, 청산 우선 로직으로 섞인 SELL 도 함께 담기므로 len(result) 를 그대로 + # "진입 n건"으로 로깅하지 않고 action/success 기준으로 분리 집계한다. try: result = await execute_auto_trade_background(rec, db) if isinstance(result, list): + buy_success = sum( + 1 for r in result + if isinstance(r, dict) and r.get("action") == "BUY" and r.get("success") + ) + sell_count = sum( + 1 for r in result if isinstance(r, dict) and r.get("action") == "SELL" + ) + fail_count = sum( + 1 for r in result if isinstance(r, dict) and not r.get("success") + ) + if buy_success or sell_count or fail_count: + logger.info( + "[SwingEntry] 진입 %d건 / 청산 %d건 / 주문실패 %d건", + buy_success, sell_count, fail_count, + ) return result except Exception as e: logger.error(f"[SwingEntry] 진입 실행 오류: {e}", exc_info=True) @@ -185,6 +204,7 @@ async def scan_dual_strategy_signals(db: AsyncSession) -> list[dict]: check_pullback_buy_signal, calculate_rsi, ) + from app.services.signal_types import BuySignal if not is_market_open(): return [] @@ -284,6 +304,16 @@ async def scan_dual_strategy_signals(db: AsyncSession) -> list[dict]: "stock_name": stock.stock_name, "current_price": int(current_close), "score": 0.8, + # [2026-09-27 FIX] signals(복수) 누락으로 trade_executor 의 + # should_buy 판정(:1906)이 항상 False 였다 → ML 앙상블이 독립적으로 + # BUY 를 낼 때만 진입되던 버그. 진입 근거와 **의미가 일치하는** + # 키워드만 채운다(거짓 신호는 저널·ML 학습 데이터를 오염시킴). + # · 신고가돌파 ← current_close > max(highs[-20:]) (StrongBuySignal) + # · 거래량증가상승 ← vol_ratio >= 2.0 & 상승 돌파 + "signals": [ + BuySignal.신고가돌파.value, + BuySignal.거래량증가상승.value, + ], "signal": momentum_signal["description"], "strategy_type": "swing", # ActiveTrade.strategy_name 으로 저장돼 swing/exit_service 의 모멘텀 전용 @@ -308,6 +338,16 @@ async def scan_dual_strategy_signals(db: AsyncSession) -> list[dict]: "stock_name": stock.stock_name, "current_price": int(current_close), "score": 0.6, + # [2026-09-27 FIX] 모멘텀 돌파와 동일 사유. check_pullback_buy_signal 의 + # 진입 근거와 1:1 대응하는 키워드만 채운다. + # · 정배열눌림목 ← MA20>MA60(정배열) & MA20 ±3% 조정 (StrongBuySignal) + # · 눌림목진입 ← RSI 40~55 반등 + 거래량 ≥1.2×20MA (StrongBuySignal) + # · MA20상향 ← ma20 > ma60 ("20일선 > 60일선") + "signals": [ + BuySignal.정배열눌림목.value, + BuySignal.눌림목진입.value, + BuySignal.MA20상향.value, + ], "signal": pullback_signal["description"], "strategy_type": "swing", "strategy_name": "PULLBACK_BUY", @@ -338,12 +378,344 @@ async def scan_dual_strategy_signals(db: AsyncSession) -> list[dict]: }] } + # [관측성] execute_auto_trade_background() 는 [2026-09-27 수정]으로 내부 + # trade_results 를 항상 list 로 반환한다(과거엔 암묵적 None 을 돌려줘 아래 + # isinstance(result, list) 가 항상 False 였다 — 지금은 정상 동작). + # 다만 이 list 에는 성공한 BUY 뿐 아니라 **주문 실패(success=False)한 + # BUY/SELL 과, 청산 우선 로직으로 섞여 들어온 SELL 도 함께 담긴다.** + # 따라서 단순 len(result) 는 "진입 n건"으로 오인시킬 수 있어, 아래에서 + # action/success 기준으로 분리 집계한다. 위임 직전 후보 로그는 관측성 + # 확보를 위해 계속 남긴다. + logger.info( + "[DualStrategy] %d건 시그널 → 매매 파이프라인 위임: %s", + len(dual_stocks), + ", ".join( + f"{s['stock_code']}({s.get('strategy_name')})" for s in dual_stocks + ), + ) + try: result = await execute_auto_trade_background(rec, db) if isinstance(result, list): - logger.info(f"[DualStrategy] {len(result)}건 진입 실행 완료") + buy_success = sum( + 1 for r in result + if isinstance(r, dict) and r.get("action") == "BUY" and r.get("success") + ) + sell_count = sum( + 1 for r in result if isinstance(r, dict) and r.get("action") == "SELL" + ) + fail_count = sum( + 1 for r in result if isinstance(r, dict) and not r.get("success") + ) + logger.info( + "[DualStrategy] 진입 %d건 / 청산 %d건 / 주문실패 %d건", + buy_success, sell_count, fail_count, + ) return result + # [2026-09-27] execute_auto_trade_background() 는 이제 항상 list 를 반환하므로 + # 이 분기는 정상 경로에서 도달하지 않는다. 다만 향후 시그니처 회귀(반환 타입 + # 변경) 시 조용히 무시되지 않도록 isinstance 방어는 유지하고 경고로 남긴다. + logger.warning( + "[DualStrategy] 예상치 못한 반환 타입(list 아님, %r) — 방어 로직 발동", + type(result), + ) except Exception as e: logger.error(f"[DualStrategy] 진입 실행 오류: {e}", exc_info=True) return [] + + +async def scan_range_trading_signals(db: AsyncSession) -> list[dict]: + """박스권(SIDEWAYS) 레인지 매매 시그널 스캔 및 진입 실행. + + CLAUDE.md §8.5 항목 9 — SIDEWAYS 국면 담당 전략이 없어 코스피가 BULL 이 아닌 + 구간에서 거래가 전무한 문제를 메우기 위한 **신규 신호원**. + + ⚠️ **백테스트 미검증.** `settings.RANGE_TRADE_ENABLED` 가 False(기본값)이면 + 함수 진입 즉시 [] 를 반환한다 — 어떤 조회·판정·주문도 수행하지 않으므로 + 플래그가 꺼져 있는 동안 기존 동작은 완전히 불변이다. + + ┌─ SIDEWAYS 차단 우회에 관한 명시적 설계 결정 ────────────────────────────┐ + │ 이 전략은 **SIDEWAYS 국면 전용**이므로, CLAUDE.md §6.1 의 "SIDEWAYS = │ + │ 모든 진입 무시" 전면 차단을 의도적으로 우회한다. │ + │ · market_analyzer.check_market_regime() 은 sideways 에서 │ + │ position_multiplier=0, max_trades=0 을 반환한다. │ + │ → execute_auto_trade_background() 는 max_trades==0 에서 즉시 []을 │ + │ 반환하므로(2026-09-27 수정 이후에도 동일 — 반환 타입만 list로 정정 │ + │ 됐을 뿐 조기 종료 자체는 그대로) 사용할 수 없다. 종목별로 │ + │ execute_auto_trade() 를 직접 호출하고 │ + │ _position_multiplier 를 RANGE_TRADE_POSITION_MULTIPLIER 로 주입한다. │ + │ · trade_executor._execute_buy 는 _regime_str=="sideways" 를 하드 차단 │ + │ 한다(방어코드). 따라서 자본 한도가 가장 보수적인 "bear" 라벨을 주입해 │ + │ BEAR 전략 자본 상한(30%) 아래에서 동작시킨다. │ + │ · 우회는 **RANGE_TRADE_ENABLED 뒤에서만** 일어난다. market_analyzer 와 │ + │ mean_reversion_strategy 는 수정하지 않았으므로 다른 전략(평균회귀· │ + │ 모멘텀·눌림목)의 SIDEWAYS 차단은 그대로 유지된다. │ + └──────────────────────────────────────────────────────────────────────────┘ + + 리스크 게이트 (scan_dual_strategy_signals 와 동일 + 레인지 전용 추가): + 1. RANGE_TRADE_ENABLED 플래그 + 2. 장 오픈 여부 (정규장만) + 3. 시장 국면 == SIDEWAYS (조회 실패 시 진입 중단 — fail-closed) + 4. 스윙 포지션 한도 (MAX_SWING_POSITIONS) + 5. 레인지 전용 포지션 한도 (RANGE_TRADE_MAX_POSITIONS ∩ 자본 비율 유도 상한) + 6. MDD (await mdd_manager.can_open_new_position()) + 7. 적응형 Crisis 모드 (조회 실패 시 fail-closed) + 8. 플레이북 이벤트 차단 + 9. 보유 종목 중복 진입 방지 + 10. 연속손실 밴 (loss_tracker.is_banned_safe) + 11. 가격 필터 (MIN_PRICE_KRW) + 12. 이후 execute_auto_trade() 내부에서 가격필터·MDD·밴·보유수·당일매도쿨다운, + _execute_buy 내부에서 correlation_filter·레버리지ETF한도·뉴스필터· + MIN_BUY_AMOUNT_KRW 가 다시 적용된다. + + Returns: + list[dict]: 실행된 매수 결과 목록 + """ + # ── 게이트 1: 기능 플래그 (백테스트 미검증 — 기본 False) ────────────── + if not settings.RANGE_TRADE_ENABLED: + return [] + + from app.core.market_schedule import is_market_open + from app.models.models import ActiveTrade + from app.services.korea_investment_api import korea_investment_api + from app.services.loss_tracker import loss_tracker + from app.services.mean_reversion_strategy import ( + calculate_adx, + calculate_atr, + calculate_bollinger, + calculate_rsi, + ) + from app.services.range_trading_strategy import ( + RANGE_TRADE_STRATEGY_NAME, + calculate_range_stop_loss, + check_range_buy_signal, + ) + from app.services.signal_types import BuySignal + + # ── 게이트 2: 정규장만 ──────────────────────────────────────────────── + if not is_market_open(): + return [] + + # ── 게이트 3: SIDEWAYS 국면에서만 동작 (fail-closed) ────────────────── + try: + regime = await check_market_regime() + except Exception as e: + logger.warning(f"[RangeTrade] 시장 국면 조회 실패 — 진입 중단(fail-closed): {e}") + return [] + regime_str = regime.get("regime_200d", "") if isinstance(regime, dict) else "" + if regime_str != "sideways": + logger.debug("[RangeTrade] SIDEWAYS 아님 (%s) — 레인지 전략 스킵", regime_str or "unknown") + return [] + + # ── 게이트 4: 스윙 전체 포지션 한도 ─────────────────────────────────── + current_count = await count_swing_positions(db) + remaining_slots = MAX_SWING_POSITIONS - current_count + if remaining_slots <= 0: + logger.info("[RangeTrade] 스윙 포지션 한도 도달 (%d) — 스킵", current_count) + return [] + + # ── 게이트 5: 레인지 전용 포지션/자본 한도 ──────────────────────────── + # 자본 배분: 새 자본 몫을 만들지 않고 스윙 예산(SWING_MAX_CAPITAL_RATIO=0.70)을 + # 공유한다. 레인지 몫은 스윙 몫 **내부** 비율(RANGE_TRADE_MAX_CAPITAL_RATIO). + # 실효 상한 = 총자본 × 0.70 × 0.10 = 7% + # 종목당 = MAX_POSITION_SIZE(4%) × RANGE_TRADE_POSITION_MULTIPLIER(0.5) = 2% + # → 허용 종목 수 = floor(7% / 2%) = 3 + _per_position_ratio = settings.MAX_POSITION_SIZE * settings.RANGE_TRADE_POSITION_MULTIPLIER + _range_capital_ratio = settings.SWING_MAX_CAPITAL_RATIO * settings.RANGE_TRADE_MAX_CAPITAL_RATIO + _ratio_cap = int(_range_capital_ratio / _per_position_ratio) if _per_position_ratio > 0 else 0 + max_range_positions = max(0, min(settings.RANGE_TRADE_MAX_POSITIONS, _ratio_cap)) + if max_range_positions <= 0: + logger.info("[RangeTrade] 자본 배분 상한이 0 — 스킵") + return [] + + _range_count_result = await db.execute( + select(ActiveTrade.stock_code).where( + ActiveTrade.status == "active", + ActiveTrade.strategy_name == RANGE_TRADE_STRATEGY_NAME, + ) + ) + _range_held = {row[0] for row in _range_count_result.fetchall()} + range_slots = max_range_positions - len(_range_held) + if range_slots <= 0: + logger.info( + "[RangeTrade] 레인지 포지션 한도 도달 (%d/%d) — 스킵", + len(_range_held), max_range_positions, + ) + return [] + remaining_slots = min(remaining_slots, range_slots) + + # ── 게이트 6: MDD ──────────────────────────────────────────────────── + if not await mdd_manager.can_open_new_position(): + logger.warning("[RangeTrade] MDD 초과 — 신규 진입 차단") + return [] + + # ── 게이트 7: 적응형 Crisis 모드 (fail-closed) ──────────────────────── + try: + from app.services.adaptive_strategy import get_current_adaptive_params + _adp = get_current_adaptive_params() + _adp_crisis = (not _adp.fallback_used) and _adp.trading_mode == "crisis" + except Exception as e: + logger.warning(f"[RangeTrade] 적응형 파라미터 조회 실패 — 진입 중단(fail-closed): {e}") + return [] + if _adp_crisis: + logger.warning("[RangeTrade] 적응형 CRISIS 모드 — 레인지 진입 차단") + return [] + + # ── 게이트 8: 플레이북 ─────────────────────────────────────────────── + if not is_entry_allowed_by_playbook(): + logger.info("[RangeTrade] 플레이북 차단 — 신규 진입 비활성화") + return [] + + # ── 후보 조회 ──────────────────────────────────────────────────────── + held_result = await db.execute(select(Portfolio.stock_code).where(Portfolio.quantity > 0)) + held_codes = {row[0] for row in held_result.fetchall()} + + analysis_query = ( + select(StockAnalysis) + .where(StockAnalysis.analysis_date >= date.today()) + .order_by(desc(StockAnalysis.score)) + .limit(30) + ) + analysis_result = await db.execute(analysis_query) + candidates = analysis_result.scalars().all() + if not candidates: + logger.debug("[RangeTrade] 분석 데이터 없음 — 스킵") + return [] + + range_stocks: list[dict] = [] + scan_count = 0 + + for stock in candidates: + if scan_count >= settings.RANGE_TRADE_SCAN_LIMIT: + break + if len(range_stocks) >= remaining_slots: + break + # 게이트 9: 중복 진입 방지 + if stock.stock_code in held_codes: + continue + # 게이트 11: 가격 필터 + if float(stock.current_price or 0) <= settings.MIN_PRICE_KRW: + continue + # 게이트 10: 연속손실 밴 (try 밖 — 실패를 삼키지 않는다) + if await loss_tracker.is_banned_safe(stock.stock_code): + logger.debug("[RangeTrade] %s 연속손실 밴 — 스킵", stock.stock_code) + continue + + try: + chart = await korea_investment_api.get_daily_chart(stock.stock_code, limit=60) + output2 = chart.get("output2", []) + if not output2 or len(output2) < 40: + continue + + bars = list(reversed(output2)) # 최신이 앞 → 오래된 순으로 + opens = [float(b.get("stck_oprc", 0)) for b in bars] + closes = [float(b.get("stck_clpr", 0)) for b in bars] + highs = [float(b.get("stck_hgpr", 0)) for b in bars] + lows = [float(b.get("stck_lwpr", 0)) for b in bars] + volumes = [int(b.get("acml_vol", 0)) for b in bars] + + if not closes or closes[-1] <= 0: + continue + scan_count += 1 + + current_close = closes[-1] + current_volume = volumes[-1] if volumes else 0 + + rsi = calculate_rsi(closes, period=14) + if rsi is None: + continue + rsi_prev = calculate_rsi(closes[:-RSI_DIRECTION_LOOKBACK], period=14) + bb = calculate_bollinger(closes, period=20) + if bb is None: + continue + bb_upper, bb_middle, bb_lower = bb + adx = calculate_adx(highs, lows, closes, period=14) + atr = calculate_atr(highs, lows, closes, period=14) or 0.0 + + signal = check_range_buy_signal( + highs=highs, lows=lows, closes=closes, volumes=volumes, + current_close=current_close, current_volume=current_volume, + rsi=rsi, bb_upper=bb_upper, bb_middle=bb_middle, bb_lower=bb_lower, + adx=adx, rsi_prev=rsi_prev, opens=opens, + ) + if not signal: + await asyncio.sleep(0.3) # KIS API 레이트 리밋 + continue + + stops = calculate_range_stop_loss( + entry_price=current_close, atr=atr, box_low=signal["box_low"], + ) + range_stocks.append({ + "stock_code": stock.stock_code, + "stock_name": stock.stock_name, + "current_price": int(current_close), + "score": 5.0, + # signals: trade_executor 의 BUY_KEYWORDS/STRONG_BUY_KEYWORDS 매칭용. + # 레인지 진입은 'BB 하단 터치 + RSI 바닥 탈출 반등' 과 동일 성격이다. + "signals": [ + BuySignal.볼린저하한선.value, + BuySignal.RSI바닥탈출.value, + BuySignal.횡보장평균회귀.value, + ], + "signal": signal["description"], + "strategy_type": "swing", # 스윙 예산/청산 파이프라인 공유 + "strategy_name": RANGE_TRADE_STRATEGY_NAME, # ActiveTrade.strategy_name + "entry_reason": f"[{RANGE_TRADE_STRATEGY_NAME}] {signal['description']}", + "stop_loss": stops["stop_loss_pct"], # 음수 보장 (-abs) + "take_profit": max( + 0.0, + (signal["target_price"] - current_close) / current_close * 100.0, + ), + "atr": atr, + "closes": closes, + "rsi": rsi, + "bb": (bb_upper, bb_middle, bb_lower), + }) + logger.info( + "[RangeTrade] 박스권 진입 시그널: %s %s — %s", + stock.stock_code, stock.stock_name, signal["description"], + ) + await asyncio.sleep(0.3) + + except Exception as e: + logger.debug(f"[RangeTrade] {stock.stock_code} 스캔 실패: {e}") + continue + + if not range_stocks: + logger.debug("[RangeTrade] 시그널 없음") + return [] + + range_stocks = range_stocks[:remaining_slots] + + # ── 진입 실행 ──────────────────────────────────────────────────────── + # execute_auto_trade_background() 는 SIDEWAYS 의 max_trades=0 에서 즉시 종료하므로 + # 종목별 execute_auto_trade() 를 직접 호출한다(상단 설계 결정 주석 참조). + # _regime_str="bear": _execute_buy 의 sideways 하드 차단을 피하면서 가장 보수적인 + # 자본 한도(BEAR 30%)를 적용받는다. _position_multiplier 로 종목당 비중을 2%로 축소. + results: list[dict] = [] + for stock_dict in range_stocks: + stock_dict["_regime_str"] = "bear" + stock_dict["_position_multiplier"] = float(settings.RANGE_TRADE_POSITION_MULTIPLIER) + try: + result = await execute_auto_trade(stock_dict, db) + except Exception as e: + logger.error( + f"[RangeTrade] {stock_dict.get('stock_code')} 진입 실행 오류: {e}", + exc_info=True, + ) + continue + if isinstance(result, dict) and result.get("action") == "BUY": + results.append(result) + else: + logger.info( + "[RangeTrade] %s 진입 보류: action=%s reason=%s", + stock_dict.get("stock_code"), + (result or {}).get("action") if isinstance(result, dict) else "?", + (result or {}).get("reason", "미상") if isinstance(result, dict) else "?", + ) + await asyncio.sleep(0.5) # KIS API 레이트 리밋 + + if results: + logger.info(f"[RangeTrade] {len(results)}건 진입 실행 완료") + return results diff --git a/backend/app/services/swing/exit_service.py b/backend/app/services/swing/exit_service.py index 35439d8..79d1ab1 100644 --- a/backend/app/services/swing/exit_service.py +++ b/backend/app/services/swing/exit_service.py @@ -183,7 +183,30 @@ async def check_and_execute_exit_v2(db: AsyncSession) -> list[dict]: try: stock_code = holding.stock_code stock_name = holding.stock_name or stock_code - quantity = holding.quantity + # [H2 2026-09-27] Portfolio 행은 stock_code UNIQUE 라 같은 종목의 + # 스윙+데이트레이딩 수량이 합산돼 있다. 행 전체를 청산 대상으로 잡으면 + # DT 보유분까지 팔려 DT 인메모리와 불일치(주문거부/과다매도)가 생긴다. + # trade_executor._execute_sell 과 동일하게 DT 인메모리 수량을 차감한 + # '스윙 보유분'만 대상으로 한다. + total_qty = int(holding.quantity or 0) + try: + from app.services.daytrading.position_manager import day_trading_state as _dts + _dt_pos = _dts.positions.get(holding.stock_code) + _dt_qty = int((_dt_pos or {}).get("quantity", 0) or 0) + except Exception: + _dt_qty = 0 + quantity = max(0, total_qty - _dt_qty) + if quantity <= 0: + logger.info( + f"[SwingExit] {stock_code}: 보유 {total_qty}주 중 DT {_dt_qty}주 " + f"— 스윙 보유분 없음, 청산 스킵" + ) + continue + if _dt_qty > 0: + logger.info( + f"[SwingExit] {stock_code}: 보유 {total_qty}주 중 DT {_dt_qty}주 제외 " + f"→ 스윙 {quantity}주 대상" + ) avg_price = float(holding.average_price or 0) market = getattr(holding, "market", None) or ( "KRX" if _is_domestic(stock_code) else "NASD" @@ -208,7 +231,15 @@ async def check_and_execute_exit_v2(db: AsyncSession) -> list[dict]: active_trade = await get_swing_active_trade(db, stock_code) # ActiveTrade upsert/close 시 항상 전략명을 명시해야 같은 종목의 DT 행을 # 잘못 건드리지 않는다(db_worker 는 전략명이 없으면 최신 행을 매칭). + # [2026-09-27 후속] strategy_name 이 NULL 인 legacy/동기화 유입 행을 + # 'swing' 으로 폴백하면 DB 에 'swing' 행이 없어 매칭 0건 → + # 유령 active 행이 영구 잔존한다. trade_executor 와 동일하게 + # payload 의 strategy_name_not=DT_STRATEGY_NAMES 로 '비-DT 최신 행'을 + # 직접 겨냥한다(전략명이 있으면 기존 동작 그대로). _at_strategy = (active_trade.strategy_name if active_trade else None) or "swing" + _at_filter_not_dt = bool( + active_trade is not None and not active_trade.strategy_name + ) remaining_pct = float(active_trade.remaining_pct) if active_trade and active_trade.remaining_pct is not None else 1.0 trailing_high = float(active_trade.highest_since_entry) if active_trade and active_trade.highest_since_entry else None @@ -216,11 +247,16 @@ async def check_and_execute_exit_v2(db: AsyncSession) -> list[dict]: if active_trade and latest_price > (trailing_high or 0): trailing_high = latest_price from app.services.db_worker import enqueue_active_trade_upsert - await enqueue_active_trade_upsert({ - "stock_code": stock_code, - "strategy_name": _at_strategy, # DT 행 오염 방지 - "highest_since_entry": trailing_high, - }) + _th_payload = {"stock_code": stock_code} + if _at_filter_not_dt: + # 전략명 NULL 인 비-DT 행 대상 — 이름 동등 매칭 불가. + # strategy_name 과 동시에 넣으면 우선순위상 필터가 무력화된다. + from app.services.db_worker import DT_STRATEGY_NAMES as _DTN_TH + _th_payload["strategy_name_not"] = list(_DTN_TH) + else: + _th_payload["strategy_name"] = _at_strategy # DT 행 오염 방지 + _th_payload["highest_since_entry"] = trailing_high + await enqueue_active_trade_upsert(_th_payload) # ── 전략별 청산 분기 ──────────────────────────────────── exit_signal = None @@ -303,13 +339,25 @@ async def check_and_execute_exit_v2(db: AsyncSession) -> list[dict]: continue # ── 매도 수량 결정 (소량 분할 방지) ───────────────────── + # [M5 2026-09-27] sell_pct 의 의미 = '진입 시 원본 수량 대비' 비율. + # check_exit_signal / _check_momentum_exit 의 remaining_pct 게이트가 + # 1.0 → 0.70 → 0.40 (원본 대비 누적)을 가정하므로, 매도 수량도 반드시 + # 원본 수량 기준으로 계산해야 remaining_pct 와 실제 잔량이 일치한다. + # (기존: sell_qty 는 '현재 잔량'×sell_pct, remaining_pct 는 '원본' 기준 + # 차감 → 분할이 반복될수록 회계가 벌어졌다.) + # 원본 수량은 ActiveTrade 의 remaining_pct 로 역산한다. sell_pct = getattr(exit_signal, "sell_pct", 1.0) - if sell_pct < 1.0 and quantity <= 3: + original_qty = quantity + if 0 < remaining_pct < 1.0: + original_qty = max(quantity, int(round(quantity / remaining_pct))) + if sell_pct >= 1.0: + sell_qty = quantity # 전량 매도(스윙 보유분 전체) + elif quantity <= 3: # 3주 이하: 분할 매도 비효율 → 전량 매도 sell_qty = quantity logger.info(f"[SwingExit] {stock_code}: {quantity}주 소량 → 전량매도 (분할 비효율)") else: - sell_qty = min(max(int(quantity * sell_pct), 1), quantity) + sell_qty = min(max(int(original_qty * sell_pct), 1), quantity) if sell_qty <= 0: continue @@ -360,28 +408,59 @@ async def check_and_execute_exit_v2(db: AsyncSession) -> list[dict]: # 포트폴리오 화면 즉시 반영 (Portfolio 수량 차감 + 캐시) from app.services.portfolio_cache_bridge import reflect_sell - await reflect_sell(stock_code, sell_qty, latest_price, remaining_qty=quantity - sell_qty) + # remaining_qty 는 Portfolio 행(스윙+DT 합산) 기준이어야 한다 — + # 스윙 보유분(quantity)만 쓰면 DT 수량이 사라진다. + await reflect_sell( + stock_code, sell_qty, latest_price, remaining_qty=total_qty - sell_qty + ) # 전량 청산이면 ActiveTrade 종료, 분할 매도면 remaining_pct 갱신 if sell_qty >= quantity: # strategy_name 을 비우면 db_worker 가 '가장 최근 active 행'을 닫는다 # → 같은 종목에 DT 행이 있으면 그쪽이 닫히고 스윙 행은 영구 잔존. - await enqueue_close_active_trade( - stock_code, strategy_name=_strategy or "swing" - ) + if _at_filter_not_dt: + # 전략명 NULL 인 비-DT 행은 이름으로 닫을 수 없다 → + # strategy_name_not 필터로 비-DT 최신 행을 직접 겨냥. + from app.services.db_worker import ( + DT_STRATEGY_NAMES as _DTN_CL, + enqueue_db_write, + ) + await enqueue_db_write({ + "type": "close_active_trade", + "data": { + "stock_code": stock_code, + "strategy_name_not": list(_DTN_CL), + }, + }) + else: + await enqueue_close_active_trade( + stock_code, strategy_name=_strategy or "swing" + ) else: # 분할 매도 후 remaining_pct 갱신 (반복 트리거 방지) - new_remaining = max(0.0, remaining_pct - sell_pct) + # [M5] 실제 잔량 기준으로 재계산 — sell_pct 단순 차감은 sell_qty 가 + # 원본 기준일 때만 일치하므로, 반올림 오차까지 흡수하도록 잔량/원본으로 계산. + if original_qty > 0: + new_remaining = max(0.0, min(1.0, (quantity - sell_qty) / original_qty)) + else: + new_remaining = max(0.0, remaining_pct - sell_pct) from app.services.db_worker import enqueue_active_trade_upsert - _at_data = { - "stock_code": stock_code, - "strategy_name": _strategy or "swing", + _at_data = {"stock_code": stock_code} + if _at_filter_not_dt: + # active_trade 가 존재하는데 전략명이 NULL 인 경우에만 True → + # 아래 `active_trade is None` 신규생성 분기와 상호배타적이므로 + # strategy_name 과 strategy_name_not 이 동시에 실리지 않는다. + from app.services.db_worker import DT_STRATEGY_NAMES as _DTN_UP + _at_data["strategy_name_not"] = list(_DTN_UP) + else: + _at_data["strategy_name"] = _strategy or "swing" + _at_data.update({ "remaining_pct": new_remaining, # 잔량도 함께 갱신 — 누락 시 재기동 복원이 원래 수량으로 되살아나 # 보유량보다 많이 매도한다(2026-09-23 DT LG씨엔에스 사례와 동일 패턴) "quantity": quantity - sell_qty, "highest_since_entry": latest_price if latest_price > (trailing_high or 0) else trailing_high, - } + }) if active_trade is None: # [FIX] ActiveTrade 행이 없는 포지션(동기화 유입 등)은 upsert가 # NOT NULL(quantity/entry_price) 위반으로 INSERT 실패 → remaining_pct diff --git a/backend/app/services/swing/position_manager.py b/backend/app/services/swing/position_manager.py index cded6d1..13da75d 100644 --- a/backend/app/services/swing/position_manager.py +++ b/backend/app/services/swing/position_manager.py @@ -17,6 +17,9 @@ from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from app.models.models import Portfolio, ActiveTrade +# [§8.5 항목3] DT 제외 필터는 db_worker 를 정본으로 공유한다. +# (db_worker 는 서비스 모듈을 모듈 수준에서 import 하지 않으므로 순환 참조 없음) +from app.services.db_worker import DT_STRATEGY_NAMES, not_dt_strategy_clause # noqa: F401 logger = logging.getLogger(__name__) @@ -47,9 +50,13 @@ async def get_swing_holdings(db: AsyncSession) -> list: async def get_swing_active_trades(db: AsyncSession) -> list: """활성 스윙 ActiveTrade 목록 반환. - strategy_name != 'day_trade' 조건으로 데이트레이딩 포지션 제외. + DT 계열(DT_STRATEGY_NAMES) 제외 조건으로 데이트레이딩 포지션만 걸러낸다. 분할 청산 진행 상태(remaining_pct), 최고가(highest_since_entry) 포함. + [2026-09-27 §8.5 항목3] 기존 `strategy_name != "day_trade"` 는 + SQL 3값 논리로 strategy_name IS NULL 인 legacy 스윙 행까지 함께 제외했고, + 'daytrading' 전략명은 반대로 통과시켰다 → not_dt_strategy_clause() 로 통일. + Args: db: AsyncSession 인스턴스 @@ -59,7 +66,7 @@ async def get_swing_active_trades(db: AsyncSession) -> list: result = await db.execute( select(ActiveTrade).where( ActiveTrade.status == "active", - ActiveTrade.strategy_name != "day_trade", + not_dt_strategy_clause(ActiveTrade.strategy_name), ) ) return result.scalars().all() @@ -81,12 +88,14 @@ async def get_swing_active_trade(db: AsyncSession, stock_code: str) -> ActiveTra # [2026-09-23] scalar_one_or_none() 은 행이 2개 이상이면 MultipleResultsFound 를 던지고, # 호출측 except 가 이를 삼켜 해당 종목의 청산(손절 포함)이 통째로 스킵됐다. # db_worker legacy 경로와 동일하게 가장 최근 행을 사용한다. + # [2026-09-27 §8.5 항목3] strategy_name IS NULL 인 legacy 스윙 행이 + # `!= "day_trade"` 로 탈락하던 문제 + 'daytrading' 누락을 동시에 수정. result = await db.execute( select(ActiveTrade) .where( ActiveTrade.stock_code == stock_code, ActiveTrade.status == "active", - ActiveTrade.strategy_name != "day_trade", + not_dt_strategy_clause(ActiveTrade.strategy_name), ) .order_by(ActiveTrade.id.desc()) .limit(1) diff --git a/backend/app/services/swing/scheduler.py b/backend/app/services/swing/scheduler.py index fab120b..f402f4c 100644 --- a/backend/app/services/swing/scheduler.py +++ b/backend/app/services/swing/scheduler.py @@ -35,6 +35,27 @@ from app.services.swing.entry_service import execute_swing_entries, scan_dual_st logger = logging.getLogger(__name__) _KST = pytz.timezone("Asia/Seoul") +# [M2] MDD 갱신 실패 감시 — 연속 실패가 이 횟수를 넘으면 error 로 승격 +MDD_UPDATE_FAILURE_ALERT = 5 + +# MDD 갱신 상태 (모니터링/디버깅용 인메모리 상태) +_mdd_health: dict = {"consecutive_failures": 0, "last_error": None, "empty_account": False} + + +def get_mdd_update_health() -> dict: + """MDD 갱신 상태 스냅샷 반환 (연속 실패 횟수/마지막 오류/진입 차단 여부). + + swing_scheduler 의 MDD 갱신이 조용히 실패 중인지 외부에서 확인하기 위한 읽기 전용 헬퍼. + + [항목7 2026-09-27] 헬스의 원본(source of truth)은 mdd_manager 로 이동했다 + (can_open_new_position() 이 순환 import 없이 참조하려면 mdd_manager 가 소유해야 함). + 이 함수는 시그니처를 유지한 읽기 전용 파사드이며, mdd_manager 스냅샷을 병합해 + blind_block_active/threshold 까지 함께 노출한다. + """ + snapshot = dict(_mdd_health) + snapshot.update(mdd_manager.get_update_health()) + return snapshot + def _normalize_recommendations(rec: dict) -> dict: """recommendations_by_rule 키 정규화. @@ -93,6 +114,10 @@ async def swing_scheduler() -> None: from app.core.redis import get_json logger.info("📈 [Swing] Scheduler started (every 60s)") + _mdd_update_failures = 0 + _mdd_last_error: str | None = None + # [M3] '정상 응답이지만 총평가금액 0'(빈/미입금 계좌) 누적 횟수 — 장애 카운터와 분리 + _mdd_empty_account_seen = 0 try: while True: try: @@ -117,22 +142,91 @@ async def swing_scheduler() -> None: # ── Phase 2: MDD 업데이트 + 체크 + 강제 축소 ── # [FIX] mdd_manager.update()를 매 사이클 호출하여 MDD 감지 활성화 + # [M2 2026-09-27] 여기가 mdd_manager.update() 의 유일한 실효 호출부다. + # 실패를 debug 로 삼키면 KIS 장애 시 peak/drawdown 이 갱신되지 않아 + # -10%/-20% 게이트가 무증상으로 영구 미발동한다 → 경고 + 연속 실패 카운트. + # [M3 2026-09-27] "응답 없음/파싱 실패"(진짜 장애)와 "정상 응답 + 총평가금액 + # 0"(빈/미입금 계좌)을 구분한다. 후자는 정상 응답이므로 실패로 계수하지 않고 + # MDD 갱신만 스킵한다 — 그러지 않으면 신규 모의계좌에서 매 사이클 실패가 + # 누적돼 🚨 error 로그가 계속 찍혀 실제 KIS 장애와 구분되지 않는다. + _empty_account = False try: from app.services.korea_investment_api import korea_investment_api balance_resp = await korea_investment_api.get_balance() - if balance_resp: - _total = float((balance_resp.get("output2") or [{}])[0].get("tot_evlu_amt", 0)) - if _total > 0: - mdd_result = await mdd_manager.update(_total) - if mdd_result.get("action") != "NONE": - logger.warning(f"[Swing] MDD 상태 변경: {mdd_result}") + if not balance_resp: + raise ValueError("잔고 조회 응답 없음 (resp=없음)") + _out2 = balance_resp.get("output2") + if not isinstance(_out2, list) or not _out2: + raise ValueError("잔고 응답 파싱 실패 (output2 누락/형식 이상)") + _raw_total = (_out2[0] or {}).get("tot_evlu_amt") + if _raw_total is None or str(_raw_total).strip() == "": + raise ValueError("잔고 응답 파싱 실패 (tot_evlu_amt 누락)") + try: + _total = float(_raw_total) + except (TypeError, ValueError): + raise ValueError(f"잔고 응답 파싱 실패 (tot_evlu_amt={_raw_total!r})") + if _total <= 0: + # 정상 응답이지만 자산 0 — 빈 계좌/미입금. 장애 아님. + _empty_account = True + _mdd_empty_account_seen += 1 + if _mdd_empty_account_seen == 1 or _mdd_empty_account_seen % 60 == 0: + logger.info( + f"[Swing] 잔고 총평가금액 0 (빈/미입금 계좌) — MDD 갱신 스킵 " + f"({_mdd_empty_account_seen}회 누적)" + ) + else: + _mdd_empty_account_seen = 0 + if _empty_account: + # 정상 응답(장애 아님) — 실패 카운터 리셋 + 빈 계좌 표시. + # 빈 계좌는 진입 차단 사유가 아니다(항목7). + await mdd_manager.record_update_success(empty_account=True) + if _total > 0: + mdd_result = await mdd_manager.update(_total) + # [항목7] 갱신 성공 → fail-safe 차단 해제 + await mdd_manager.record_update_success(empty_account=False) + if _mdd_update_failures: + logger.warning( + f"[Swing] MDD 갱신 복구 (연속 실패 {_mdd_update_failures}회 후 정상화)" + ) + _mdd_update_failures = 0 + _mdd_last_error = None + if mdd_result.get("action") != "NONE": + logger.warning(f"[Swing] MDD 상태 변경: {mdd_result}") except Exception as e: - logger.debug(f"[Swing] MDD update 조회 실패 (무시): {e}") + _mdd_update_failures += 1 + _mdd_last_error = str(e) + # [항목7] 실패를 mdd_manager 에 보고 → 임계 도달 시 + # can_open_new_position() 이 모든 진입 경로를 차단한다(청산은 무영향). + await mdd_manager.record_update_failure(_mdd_last_error) + if _mdd_update_failures >= MDD_UPDATE_FAILURE_ALERT: + logger.error( + f"[Swing] 🚨 MDD 갱신 연속 실패 {_mdd_update_failures}회 — " + f"peak/drawdown 미갱신 상태, -10%/-20% 게이트가 작동하지 않을 수 있음: {e}", + exc_info=True, + ) + else: + logger.warning( + f"[Swing] MDD 갱신 실패 ({_mdd_update_failures}회 연속): {e}" + ) + # 상태 노출용 (모니터링/헬스체크에서 참조 가능) + _mdd_health["consecutive_failures"] = _mdd_update_failures + _mdd_health["last_error"] = _mdd_last_error + else: + _mdd_health["consecutive_failures"] = _mdd_update_failures + _mdd_health["last_error"] = _mdd_last_error + _mdd_health["empty_account"] = _empty_account can_open_new = await mdd_manager.can_open_new_position() logger.info(f"[Swing] MDD 체크: can_open={can_open_new}") if not can_open_new: - logger.warning("[Swing] MDD 초과 — 신규 진입 차단") + if mdd_manager.get_update_health().get("blind_block_active"): + logger.error( + f"[Swing] 🚨 MDD 갱신 장애로 신규 진입 차단(fail-safe, 연속 실패 " + f"{_mdd_update_failures}회, 마지막 오류: {_mdd_last_error}) — " + f"청산/축소는 계속 수행" + ) + else: + logger.warning("[Swing] MDD 초과 — 신규 진입 차단") if mdd_manager.reduction_applied: try: reduce_pct = 0.50 if mdd_manager.reduction_applied == "20pct" else 0.30 @@ -245,6 +339,10 @@ async def swing_scheduler() -> None: # 6. 스윙 진입 실행 (별도 세션) async with AsyncSessionLocal() as entry_session: logger.info(f"[Swing] 진입 실행 시작: {top_stocks[:5]}") + # execute_swing_entries() 내부에서 진입/청산/실패 건수를 이미 + # 분리 집계해 로깅하므로(entry_service.py 참조), 여기서는 결과를 + # 그대로 소비하지 않고 반환값(list, 2026-09-27 이후 항상 list)만 + # 확인한다. await execute_swing_entries(rec, entry_session) # 7. 듀얼 전략 시그널 스캔 (모멘텀 돌파 + 눌림목 매수, CLAUDE.md 8.4) @@ -252,7 +350,29 @@ async def swing_scheduler() -> None: async with AsyncSessionLocal() as dual_session: dual_results = await scan_dual_strategy_signals(dual_session) if dual_results: - logger.info(f"[Swing] 듀얼 전략 진입: {len(dual_results)}건") + # [2026-09-27] scan_dual_strategy_signals() 가 위임하는 + # execute_auto_trade_background() 는 항상 list 를 반환하며, + # 그 list 에는 성공 BUY 뿐 아니라 주문 실패(success=False)한 + # BUY/SELL, 청산 우선 로직으로 섞인 SELL 도 포함된다. + # 따라서 len(dual_results) 를 그대로 "진입 n건"으로 쓰지 않고 + # action/success 기준으로 분리 집계한다(상세 집계는 + # entry_service.scan_dual_strategy_signals() 내부 로그 참조). + buy_success = sum( + 1 for r in dual_results + if isinstance(r, dict) and r.get("action") == "BUY" and r.get("success") + ) + sell_count = sum( + 1 for r in dual_results + if isinstance(r, dict) and r.get("action") == "SELL" + ) + fail_count = sum( + 1 for r in dual_results + if isinstance(r, dict) and not r.get("success") + ) + logger.info( + f"[Swing] 듀얼 전략 — 진입 {buy_success}건 / " + f"청산 {sell_count}건 / 주문실패 {fail_count}건" + ) except Exception as e: logger.debug(f"[Swing] 듀얼 전략 스캔 오류: {e}") diff --git a/backend/app/services/technical_indicators.py b/backend/app/services/technical_indicators.py index 3f5555d..63fb999 100644 --- a/backend/app/services/technical_indicators.py +++ b/backend/app/services/technical_indicators.py @@ -12,6 +12,9 @@ from typing import Optional from app.core.config import settings from app.services.mean_reversion_strategy import calculate_adx, calculate_rsi, calculate_atr, calculate_bollinger +# §8.5-1 지표 계산 단일 출처. analyze_* 는 Wilder 가 아닌 SMA 계열을 쓰던 곳이 있어 +# 알고리즘 이름이 명시된 함수를 직접 선택해 호출한다(수치 보존). +from app.services.indicators_core import bollinger_sma, rsi_sma logger = logging.getLogger(__name__) @@ -207,63 +210,39 @@ def analyze_rsi(chart_data: list) -> dict: if len(chart_data) < rsi_period + 1: return {"signal": None, "description": "데이터 부족"} + # [2026-09-27 §8.5-1 중복 통합] 인라인 RSI 계산 → indicators_core.rsi_sma() + # 이 경로는 Wilder 평활이 아닌 "마지막 period개 diff 단순평균" 방식이다. + # 입력을 rsi_period+1개로 먼저 잘라 넘기므로 통합 전 수치와 동일하다. prices = [float(item.get("stck_clpr", 0)) for item in chart_data[-(rsi_period+1):]] - gains = [] - losses = [] - for i in range(1, len(prices)): - change = prices[i] - prices[i-1] - if change > 0: - gains.append(change) - losses.append(0) - else: - gains.append(0) - losses.append(abs(change)) - - avg_gain = sum(gains[-rsi_period:]) / rsi_period - avg_loss = sum(losses[-rsi_period:]) / rsi_period - - if avg_loss == 0: - rsi = 100 - else: - rs = avg_gain / avg_loss - rsi = 100 - (100 / (1 + rs)) - - # 직전 RSI 계산 (반등 확인용) + rsi = rsi_sma(prices, rsi_period) + + # 직전 RSI 계산 (반등 확인용) — 한 봉 앞선 창(window)에 같은 방식 적용 if len(chart_data) >= rsi_period + 2: prev_prices = [float(item.get("stck_clpr", 0)) for item in chart_data[-(rsi_period+2):-(1)]] - prev_gains = [] - prev_losses = [] - for i in range(1, len(prev_prices)): - change = prev_prices[i] - prev_prices[i-1] - if change > 0: - prev_gains.append(change) - prev_losses.append(0) - else: - prev_gains.append(0) - prev_losses.append(abs(change)) - - prev_avg_gain = sum(prev_gains[-rsi_period:]) / rsi_period - prev_avg_loss = sum(prev_losses[-rsi_period:]) / rsi_period - if prev_avg_loss == 0: - rsi_prev = 100 - else: - prev_rs = prev_avg_gain / prev_avg_loss - rsi_prev = 100 - (100 / (1 + prev_rs)) + rsi_prev = rsi_sma(prev_prices, rsi_period) else: rsi_prev = rsi + # 방어적 가드: 위 가드(len >= rsi_period+1)로 인해 도달 불가 경로지만, + # None 비교로 인한 TypeError를 원천 차단한다. + if rsi is None: + return {"signal": None, "description": "데이터 부족"} + if rsi_prev is None: + rsi_prev = rsi + # [2026-03] RSI < 30 구간: 반등 시작 + BB 하단 근처 시 BUY if rsi < 30: rsi_rebounding = rsi > rsi_prev # RSI 바닥 찍고 반등 중 if rsi_rebounding: # 볼린저 하단 근처 확인 (-3% ~ +1.5%) + # [2026-09-27 §8.5-1 중복 통합] 인라인 BB → indicators_core.bollinger_sma() + # 여기는 settings 값이 아닌 20봉·2σ **하드코딩**이 기존 동작이므로 그대로 유지. bb_lower = None if len(chart_data) >= 20: bb_closes = [float(item.get("stck_clpr", 0)) for item in chart_data[-20:]] - bb_mid = sum(bb_closes) / 20 - bb_std = (sum((p - bb_mid) ** 2 for p in bb_closes) / 20) ** 0.5 - bb_lower = bb_mid - 2 * bb_std + _bb = bollinger_sma(bb_closes, 20, 2.0) + bb_lower = _bb[2] if _bb else None close = float(chart_data[-1].get("stck_clpr", 0)) if bb_lower and bb_lower * 0.97 <= close <= bb_lower * 1.015: @@ -303,15 +282,15 @@ def analyze_bollinger(chart_data: list) -> dict: if len(chart_data) < bb_period: return {"signal": None, "description": "데이터 부족"} + # [2026-09-27 §8.5-1 중복 통합] 인라인 BB → indicators_core.bollinger_sma() + # σ배수는 settings.BOLLINGER_STDDEV(기본 2.0)를 그대로 전달 — 기존 동작 보존. prices = [float(item.get("stck_clpr", 0)) for item in chart_data[-bb_period:]] - ma_bb = sum(prices) / len(prices) - variance = sum((p - ma_bb) ** 2 for p in prices) / len(prices) - std_dev = variance ** 0.5 - - upper_band = ma_bb + (bb_stddev * std_dev) - lower_band = ma_bb - (bb_stddev * std_dev) + _bands = bollinger_sma(prices, bb_period, bb_stddev) + if _bands is None: + return {"signal": None, "description": "데이터 부족"} + upper_band, ma_bb, lower_band = _bands current_price = prices[-1] - + if current_price < lower_band: return {"signal": "buy", "description": "볼린저 하한선 터치"} elif current_price > upper_band: diff --git a/backend/app/services/trade_executor.py b/backend/app/services/trade_executor.py index 4209226..3871c52 100644 --- a/backend/app/services/trade_executor.py +++ b/backend/app/services/trade_executor.py @@ -13,7 +13,7 @@ import time from decimal import Decimal from datetime import datetime, timedelta from sqlalchemy.ext.asyncio import AsyncSession -from sqlalchemy import select, desc +from sqlalchemy import select, desc, and_, or_ from app.core.config import settings from app.core.database import AsyncSessionLocal @@ -29,6 +29,10 @@ from app.services.mean_reversion_strategy import ( from app.services.technical_indicators import ( TREND_FILTER_ENABLED, TREND_MA_SHORT, TREND_MA_LONG, TREND_TOLERANCE_PCT, _ma, _ema, ) +# §8.5-2 포지션 사이징 단일 출처 (순수 계산 모듈 — settings/DB/네트워크 없음) +from app.services.sizing_core import ( + amount_to_quantity, clamp_by_ratios, effective_min_buy_amount, half_kelly_fraction, +) from app.services.signal_types import BUY_KEYWORDS, SELL_KEYWORDS, STRONG_BUY_KEYWORDS, BLOCK_KEYWORDS from app.services.correlation_filter import validate_correlation_filter from app.services.exchange_detector import is_us_stock, detect_exchange, get_market_info @@ -42,6 +46,39 @@ from app.services.trade_helpers import ( logger = logging.getLogger(__name__) + +# ── 잔량 추적 해석 (CLAUDE.md §8.5 항목4) ───────────────────────────────────── +def _resolve_remaining_pct_v2(stock: dict, holding=None) -> tuple[float, bool]: + """remaining_pct 와 '잔량 추적 가능 여부' 를 함께 반환. + + 기존 `stock.get("remaining_pct", 1.0) or 1.0` 은 '추적 소스 없음(None)' 과 + '실제로 잔량 100%' 를 구분하지 못했다. ActiveTrade 행이 없는 보유분 + (브로커 잔고 sync 로 생긴 Portfolio 행 / legacy 행)은 매 사이클 잔량 100%로 + 간주되어 SELL_PARTIAL(50%)이 무한 반복됐다. + + Args: + stock: 종목 dict (remaining_pct 주입 대상) + holding: Portfolio ORM (현재 판정에 미사용 — 향후 확장용, 시그니처 고정) + + Returns: + (remaining_pct, is_tracked) + - is_tracked=False → 잔량을 신뢰할 수 없음. 호출측은 부분매도를 스킵하고 + 전량 청산 신호만 채택해야 한다. 값은 기존 동작과 동일한 1.0 을 준다. + - remaining_pct 는 항상 0 < x <= 1.0 범위로 클램프. + """ + raw = stock.get("remaining_pct") if isinstance(stock, dict) else None + if raw is None: + return 1.0, False + try: + val = float(raw) + except (TypeError, ValueError): + return 1.0, False + if not (0.0 < val <= 1.0): + # 0 이하(전량 소진) 또는 1 초과(오염) → 추적 불가로 간주 + return 1.0, False + return val, True + + # ── 종목별 세마포어 — 동일 종목 동시 주문 방지 ──────────────────────────────── _order_semaphores: dict = {} _SEMAPHORE_MAX_SIZE = 200 @@ -464,17 +501,18 @@ async def _calculate_kelly_position( return 0.0 # 데이터 부족 → v2 fallback rates = [float(t.profit_loss_rate) for t in trades] - wins = [r for r in rates if r > 0] # 수익 거래 목록 - losses = [abs(r) for r in rates if r < 0] # 손실 거래 절대값 목록 - if not wins or not losses: - return 0.0 # 전승 또는 전패 → Kelly 계산 불가 + # [§8.5-2 통합 2026-09-27] Kelly 통계 수식은 sizing_core.half_kelly_fraction 로 이동 + # (순수 계산). DB 조회·예외 처리·fallback 판단은 이 함수에 남는다. + # min_trades=5 미만 / 전승 / 전패 → None → 기존과 동일하게 0.0 반환(v2 fallback). + half_kelly = half_kelly_fraction(rates, min_trades=5) + if half_kelly is None: + return 0.0 - p = len(wins) / len(rates) # 승률 - b = (sum(wins) / len(wins)) / (sum(losses) / len(losses)) # Payoff Ratio - kelly_f = (p * b - (1 - p)) / b # Kelly 공식 - half_kelly = max(0.0, kelly_f / 2) # Half-Kelly (보수적) clamped = min(half_kelly, max_per_position) # 종목당 최대 4% 상한 적용 - return min(total_assets * clamped, available_cash, total_assets * max_per_position) + # = min(총자산×clamped, 가용현금, 총자산×max_per_position) — 통합 전과 동일 + return clamp_by_ratios( + total_assets * clamped, total_assets, [max_per_position], available_cash, + ) except Exception as e: logger.debug(f"[kelly] {stock_code} 계산 오류: {type(e).__name__}: {e}") return 0.0 @@ -844,14 +882,21 @@ async def _execute_buy( _strategy_total_alloc += _h_qty * _h_price # ── 7.0. 적응형 전략 파라미터 적용 ───────────────────────────────────── - # position_size 상한을 적응형 모드에 따라 조정 (Crisis=0, Defensive=3% 등) - _adaptive_position_cap = settings.MAX_POSITION_SIZE # 기본 4% + # position_size 상한을 적응형 모드에 따라 조정 (Crisis=1.5%, Defensive=4% 등) + # + # [HIGH-2 수정] settings.MAX_POSITION_SIZE(4%)는 CLAUDE.md §5.1/§8.2가 규정한 + # "종목당 절대 상한(hard ceiling)"이다. adaptive_strategy._MODE_PARAMS 는 문서·백테스트 + # 기준값이므로 그 숫자 자체는 건드리지 않고(Normal 6% / Aggressive 7% 유지), + # 소비 지점인 여기서 min()으로 클램프해 절대 상한을 넘지 못하게 한다. + # → 결과적으로 Normal/Aggressive의 실효 종목당 비중은 4%로 묶이고, + # Defensive(4%)/Crisis(1.5%)처럼 4% 이하인 모드만 적응형 값이 실제로 작동한다. + _adaptive_position_cap = settings.MAX_POSITION_SIZE # 기본 4% = 절대 상한 try: from app.services.adaptive_strategy import get_current_adaptive_params _adp = get_current_adaptive_params() if not _adp.fallback_used: - _adaptive_position_cap = _adp.position_size - # Crisis 모드: max_daily_trades=0이면 신규 진입 차단 (CLAUDE.md 섹션 8.5) + _adaptive_position_cap = min(_adp.position_size, settings.MAX_POSITION_SIZE) + # Crisis 모드: max_daily_trades=0이면 신규 진입 차단 (CLAUDE.md 섹션 8.4) if _adp.trading_mode == "crisis" and _adp.max_daily_trades <= 0: return {"action": "SKIP", "reason": "adaptive_crisis_mode", "stock": stock_name} except Exception: @@ -920,11 +965,25 @@ async def _execute_buy( current_price=current_price, current_volume=float(stock.get("volume", 0) or 0), ) - # CLAUDE.md 5.1: 패턴 조정 후에도 종목당 4% 상한 재적용 - max_by_position = total_assets * settings.MAX_POSITION_SIZE - invest_amount = min(invest_amount, max_by_position, available_cash) except Exception as _pm_err: - logger.debug(f"[PATTERN_MEMORY] {stock_code} 포지션 조정 실패 (무시): {_pm_err}") + # [HIGH-1 수정] 조용한 실패 방지 — debug → warning. + logger.warning(f"[PATTERN_MEMORY] {stock_code} 포지션 조정 실패 (기본 투자금액 유지): {_pm_err}") + + # [HIGH-1 수정] CLAUDE.md §5.1 종목당 4% 절대 상한 재적용. + # 과거에는 이 클램프가 위 try 블록 안에 있어, get_pattern_adjusted_invest_amount()가 + # 예외를 던지면 클램프를 건너뛰고 Kelly/v2가 _adaptive_position_cap(적응형 6~7%)로 + # 산출한 금액이 그대로 통과했다. 예외 발생 여부와 무관하게 항상 적용되도록 try 밖으로 이동. + # total_assets=0(잔고 조회 실패 등)이면 비율 상한을 계산할 수 없으므로 현금 상한만 적용 + # — 0으로 클램프해 전 종목이 quantity_zero로 조용히 탈락하는 것을 방지. + # [§8.5-2 통합 2026-09-27] 클램프 수식만 sizing_core 로 위임 — 클램프 **순서** + # (글로벌 자본 가드 → 스윙 자본 캡 → 패턴 메모리 → 절대 상한)와 total_assets>0 + # 분기는 최근 확정된 동작이므로 그대로 유지한다. + if total_assets > 0: + invest_amount = clamp_by_ratios( + invest_amount, total_assets, [settings.MAX_POSITION_SIZE], available_cash, + ) + else: + invest_amount = min(invest_amount, available_cash) # [BUG FIX] 국면별 배율(_position_multiplier)과 뉴스 배율(_news_position_scale)을 # invest_amount에 반영 — 기존에는 stock dict에 주입만 되고 실제로 사용되지 않았음. @@ -948,7 +1007,9 @@ async def _execute_buy( ) # 투자금액 → 주수 환산 (Decimal 사용으로 부동소수점 오차 방지) - quantity = int(Decimal(str(invest_amount)) / Decimal(str(current_price))) + # [§8.5-2 통합 2026-09-27] sizing_core.amount_to_quantity 위임 — 동일한 + # int(Decimal(str(amount))/Decimal(str(price))) 경로. max(1, ...) 없음(예산 0 → 0주). + quantity = amount_to_quantity(invest_amount, current_price) logger.info( f"[BUY_CALC] {stock_code}/{stock_name}: cash={available_cash:,.0f} assets={total_assets:,.0f} " f"invest={invest_amount:,.0f} qty={quantity} price={current_price:,.0f}" @@ -963,18 +1024,26 @@ async def _execute_buy( logger.info(f"[BUY_SKIP] {stock_code}/{stock_name}: {_qz_reason} (invest={invest_amount:,.0f})") return {"action": "SKIP", "reason": _qz_reason, "stock": stock_name} - # 최소 매수 금액 가드: 수수료 대비 비효율 방지 (기본 config.MIN_BUY_AMOUNT_KRW, 50만원) - # 큰 계좌는 기존과 동일하게 config 하한(기본 50만원)이 그대로 적용된다. - # 소액 계좌(총자산이 작아 6% 예산 자체가 50만원 미만인 경우)는 이 하한 때문에 - # 전종목이 영구 차단되는 문제를 완화하기 위해, "config 하한"과 - # "총자산 × 적응형 포지션상한 × 0.9" 중 더 작은 값을 실효 하한으로 사용한다. - # (총자산이 충분히 크면 항상 config값이 더 작게 선택되어 기존 동작과 동일 — 소액 계좌만 완화됨.) - _MIN_BUY_AMOUNT = settings.MIN_BUY_AMOUNT_KRW + # 최소 매수 금액 가드: 수수료 대비 비효율 방지 (기본 config.MIN_BUY_AMOUNT_KRW, 15만원) + # + # [MEDIUM-5 문서 불일치 — 의도된 설계] + # CLAUDE.md §5.1/§8.2는 최소주문금액을 "15만원 고정"으로 서술하지만, + # 실제 구현은 아래와 같이 소액 계좌에서만 자동 하향된다: + # 실효하한 = min(MIN_BUY_AMOUNT_KRW, 총자산 × 적응형포지션상한 × 0.9) + # 이유: 총자산이 작으면 종목당 예산(총자산×4%) 자체가 15만원 미만이 되어 + # 모든 종목이 min_amount_guard로 영구 차단된다(2026-09-23 하루 141건 차단 사례). + # 큰 계좌에서는 항상 config값(15만원)이 더 작게 선택되므로 문서와 동일하게 동작한다. + # → 동작을 문서에 맞추는 대신 문서(§5.1)를 이 동작에 맞춰 보정할 예정. 코드 변경 금지. + # 예) 총자산 300만원 × 4% × 0.9 = 108,000원이 실효 하한(15만원 아님). + # [§8.5-2 통합 2026-09-27] 계산식만 sizing_core.effective_min_buy_amount 로 위임. + # = min(MIN_BUY_AMOUNT_KRW, 총자산 × 적응형cap × 0.9). 로직·계수 변경 없음. try: - if total_assets > 0 and _adaptive_position_cap > 0: - _small_account_cap = total_assets * _adaptive_position_cap * 0.9 - if _small_account_cap > 0: - _MIN_BUY_AMOUNT = min(_MIN_BUY_AMOUNT, _small_account_cap) + _MIN_BUY_AMOUNT = effective_min_buy_amount( + config_min_amount=settings.MIN_BUY_AMOUNT_KRW, + total_assets=total_assets, + position_cap_ratio=_adaptive_position_cap, + factor=0.9, + ) except Exception: _MIN_BUY_AMOUNT = settings.MIN_BUY_AMOUNT_KRW _actual_amount = quantity * current_price @@ -1194,6 +1263,12 @@ async def _execute_sell( # 수량이 합산돼 있다. 행 전체를 대상으로 잡으면 다른 전략 보유분까지 매도된다 # → 데이트레이딩 엔진이 들고 있는 수량은 제외한 '이 전략의 보유분'만 대상으로 한다. total_qty = holding.quantity + # [M-3 2026-09-27] 변수 기준 구분: + # _row_total_qty = Portfolio 행 수량(스윙+DT 합산) — 포트폴리오 캐시/DB 행 기준 + # total_qty = 이 전략의 보유분(_own_qty, DT 제외) — 주문·잔량 회계 기준 + # 캐시 반영은 반드시 행 합산 기준(_row_total_qty)을 써야 DT 보유분이 사라지지 않는다 + # (swing/exit_service.reflect_sell / portfolio_cache_bridge 와 동일 기준). + _row_total_qty = int(total_qty or 0) _strategy_tag = stock.get("strategy_type", "swing") if _strategy_tag != "daytrading": try: @@ -1212,11 +1287,47 @@ async def _execute_sell( logger.info( f"[SELL] {stock_name}: 보유 {total_qty}주 중 DT {_dt_qty}주 제외 → {_own}주 대상" ) - total_qty = _own + total_qty = _own # = _own_qty (이 전략 보유분) + + # ── 매도 수량 결정 (원본 수량 기준) ────────────────────────────────────── + # [M-1 2026-09-27] sell_pct 의 의미 = '진입 시 원본 수량 대비' 비율. + # check_exit_signal(>0.50) / _check_momentum_exit(>0.75, 0.45~0.75) / + # range_trading_strategy(>0.50) 의 remaining_pct 게이트가 원본 누적 + # (1.0 → 0.70 → 0.40)을 전제하므로 매도 수량도 원본 기준이어야 한다. + # 기존 코드는 수량은 '현재 잔량' 기준, new_remaining 은 '원본' 기준 차감이라 + # 분할이 반복될수록 과소매도 + remaining_pct 오염이 누적됐다. + # → swing/exit_service.py 와 동일하게 원본 수량을 remaining_pct 로 역산한다. + _rp_for_qty, _rp_tracked_for_qty = _resolve_remaining_pct_v2(stock, holding) + _original_qty = total_qty + if _rp_tracked_for_qty and 0.0 < _rp_for_qty < 1.0: + # 0 나눗셈 가드 = 위 조건(0 < rp), original_qty < total_qty 가드 = max() + _original_qty = max(total_qty, int(round(total_qty / _rp_for_qty))) + elif not _rp_tracked_for_qty and sell_pct < 1.0: + # 추적 불가(ActiveTrade active 행 없음/오염) → 역산 불가. + # 상위 execute_auto_trade 가 이 경우 부분매도 신호를 폐기하므로(§8.5 항목4①) + # 정상 경로에서는 도달하지 않는다. 방어적으로 '현재 잔량 = 원본' 으로 두어 + # 기존 동작(현재 잔량 기준 비율 매도)을 유지한다. + logger.warning( + f"[SELL] {stock_code}: remaining_pct 추적 불가 상태에서 부분매도 요청 " + f"(sell_pct={sell_pct*100:.0f}%) — 원본 수량 역산 불가, 현재 잔량 기준 적용" + ) - quantity = max(1, int(total_qty * sell_pct)) # 최소 1주 if sell_pct >= 1.0: - quantity = total_qty # 전량매도 시 잔여분 없도록 + quantity = total_qty # 전량매도 시 잔여분 없도록 (기존 동작 유지) + else: + quantity = min(max(int(_original_qty * sell_pct), 1), total_qty) + + # [M-2 2026-09-27] 계산된 매도 수량이 보유 전량과 같아지면(1~2주 소량 보유 + + # sell_pct=0.5 → max(1,·) 로 전량 체결) 실질적 전량 청산이다. sell_pct<1.0 + # 분기를 그대로 타면 close_active_trade 가 호출되지 않아 quantity=0 유령 active + # 행이 남고, loss_tracker.record_trade_result_safe() 도 스킵돼 §5.1 3연패 밴 + # 카운트에서 이 청산이 빠진다 → 실제 체결 기준으로 전량 청산 처리한다. + _full_exit = sell_pct >= 1.0 or (total_qty > 0 and quantity >= total_qty) + if _full_exit and sell_pct < 1.0: + logger.info( + f"[SELL] {stock_code}: 부분매도 요청({sell_pct*100:.0f}%)이 보유 전량" + f"({total_qty}주)과 동일 → 전량 청산으로 처리 (ActiveTrade 종료 + 손실추적)" + ) _avg = float(holding.average_price or 0) profit_loss_rate = calculate_profit_loss_rate(current_price, _avg, holding.profit_loss_rate) @@ -1231,7 +1342,8 @@ async def _execute_sell( _avg, current_price, atr, stock, base_stop_loss=base_stop_loss ) - stop_loss = atr_stops["stop_loss"] + # [2026-09-27] atr_stops["stop_loss"] 는 _execute_sell 내에서 사용되지 않아 제거 + # (실제 손절 판정은 execute_auto_trade의 ATR 강제 손절/check_exit_signal에서 수행) trailing_stop_price = atr_stops["trailing_stop_price"] exit_reason_detail = atr_stops["exit_reason_detail"] or f"손익률 {profit_loss_rate:.2f}%" @@ -1309,7 +1421,8 @@ async def _execute_sell( if success: # [CLAUDE.md 5.5 / 8.1#6] 연속 손실 추적 — 전량매도 시 loss_tracker 기록 - if sell_pct >= 1.0: + # [M-2] _full_exit = sell_pct>=1.0 또는 '계산 수량 == 보유 전량' + if _full_exit: try: from app.services.loss_tracker import loss_tracker is_loss = profit_loss_rate < 0 @@ -1326,36 +1439,88 @@ async def _execute_sell( # 실제 행의 strategy_name 을 사용한다. strategy_type("swing")으로 닫으면 # 듀얼 전략 행(MOMENTUM_BREAKOUT/PULLBACK_BUY)과 매칭되지 않아 유령 행이 남는다. _at_name = stock.get("strategy_name") + # [§8.5 항목3 2026-09-27] 역추적 쿼리에 DT 제외 조건이 없어, 같은 종목에 + # DT 행이 병존하면 스윙 매도가 DT 행의 strategy_name 을 집어 DT + # ActiveTrade 를 닫아버렸다 → not_dt_strategy_clause 로 통일. + # 전략명이 NULL 인 비-DT 행(legacy/동기화 유입)은 이름으로 매칭할 수 + # 없으므로 payload 에 strategy_name_not 필터를 써서 그 행을 직접 겨냥한다 + # ('swing' 으로 폴백하면 NULL 행과 매칭되지 않아 유령 active 행이 남는다). + _at_filter_not_dt = False if not _at_name: try: from app.models.models import ActiveTrade as _AT + from app.services.db_worker import not_dt_strategy_clause _at_row = (await db.execute( select(_AT) - .where(_AT.stock_code == stock_code, _AT.status == "active") + .where( + _AT.stock_code == stock_code, + _AT.status == "active", + not_dt_strategy_clause(_AT.strategy_name), + ) .order_by(_AT.id.desc()) .limit(1) )).scalars().first() _at_name = _at_row.strategy_name if _at_row else None + if not _at_name: + # 비-DT 행이 없거나(=닫을 대상 없음) 전략명이 NULL 인 경우. + # DT 로 태깅된 보유분은 기존 동작(=DT 행 종료)을 유지한다. + from app.services.db_worker import DT_STRATEGY_NAMES + _st = stock.get("strategy_type") or "swing" + if _st in DT_STRATEGY_NAMES: + _at_name = _st + else: + _at_filter_not_dt = True except Exception: _at_name = None + _at_filter_not_dt = False _at_name = _at_name or stock.get("strategy_type", "swing") - if sell_pct >= 1.0: - from app.services.db_worker import enqueue_close_active_trade - await enqueue_close_active_trade(stock_code, strategy_name=_at_name) + if _full_exit: # [M-2] 실제 체결이 보유 전량이면 active 행을 닫는다 + if _at_filter_not_dt: + from app.services.db_worker import ( + DT_STRATEGY_NAMES as _DTN, + enqueue_db_write, + ) + await enqueue_db_write({ + "type": "close_active_trade", + "data": { + "stock_code": stock_code, + "strategy_name_not": list(_DTN), + }, + }) + else: + from app.services.db_worker import enqueue_close_active_trade + await enqueue_close_active_trade(stock_code, strategy_name=_at_name) else: from app.services.db_worker import enqueue_active_trade_upsert - # [BUG FIX] 기존 remaining_pct에서 sell_pct를 차감해야 누적 분할매도가 올바르게 반영됨 - # 예: 1차 50% 매도 후 remaining=0.5, 2차 50% 매도 시 remaining=0.0 - remaining_pct = float(stock.get("remaining_pct", 1.0) or 1.0) - new_remaining = max(0.0, remaining_pct - sell_pct) - await enqueue_active_trade_upsert({ + # [§8.5 항목4] 추적 여부 확인용 (미추적 시 기존과 동일하게 1.0). + # 잔량 비율 자체는 아래에서 '원본 수량 기준 실제 체결' 로 계산한다. + remaining_pct, _rp_tracked = _resolve_remaining_pct_v2(stock, holding) + if not _rp_tracked: + logger.info( + f"[SELL] {stock_code}: remaining_pct 추적 불가 → 1.0 기준으로 " + f"ActiveTrade 잔량 재설정 (sell_pct={sell_pct*100:.0f}%)" + ) + # [M-1] 원본 수량 기준 실제 잔량 비율. 뺄셈(remaining_pct - sell_pct)은 + # 매도 수량이 현재 잔량 기준일 때와 어긋나 잔량이 오염됐다 → + # 실제 체결 수량으로 계산한다 (예: 원본 100, 잔량 70, 50주 매도 → 0.20). + new_remaining = max(0.0, (total_qty - quantity) / _original_qty) \ + if _original_qty > 0 else 0.0 + _at_payload = { "stock_code": stock_code, - "strategy_name": _at_name, "remaining_pct": new_remaining, "quantity": max(0, total_qty - quantity), # 잔량 갱신(과다매도 방지) "highest_since_entry": current_price, - }) + } + if _at_filter_not_dt: + # 전략명 NULL 인 비-DT 행 대상 (이름 동등 매칭 불가). + # 대상 행이 아예 없으면 db_worker 가 필수 키 누락으로 스킵 — + # 매도 경로에서 새 active 행을 만들지 않는 것이 의도된 동작이다. + from app.services.db_worker import DT_STRATEGY_NAMES as _DTN2 + _at_payload["strategy_name_not"] = list(_DTN2) + else: + _at_payload["strategy_name"] = _at_name + await enqueue_active_trade_upsert(_at_payload) except Exception as _at_err: logger.warning(f"[SELL] ActiveTrade 업데이트 실패 {stock_code}: {_at_err}") @@ -1365,7 +1530,11 @@ async def _execute_sell( avg = float(holding.average_price or current_price) await portfolio_cache.apply_sell(stock_code, quantity, current_price, avg) # [CLAUDE.md 5.2] 분할매도: 잔여 수량이 있으면 캐시에서 제거하지 않음 - remaining_qty = total_qty - quantity + # [M-3 2026-09-27] 캐시/Portfolio 행 기준 잔량은 '행 합산(_row_total_qty)' + # 에서 매도 수량을 뺀 값이어야 한다. 스윙 보유분(total_qty=_own_qty)으로 + # 계산하면 스윙 전량 청산 시 remaining_qty=0 이 되어 DT 보유분까지 캐시에서 + # 삭제됐다 (Portfolio DB 행은 합산 수량으로 잔존 → 캐시/DB 불일치). + remaining_qty = max(0, _row_total_qty - quantity) if remaining_qty <= 0: portfolio_cache.holdings = [ h for h in portfolio_cache.holdings if h.stock_code != stock_code @@ -1464,19 +1633,20 @@ async def execute_auto_trade_background(recommendations_response: dict, db: Asyn """ if not isinstance(recommendations_response, dict): logger.error("[AUTO_TRADE_BG] recommendations_response is not a dict: %r", recommendations_response) - return + return [] if _auto_trade_bg_lock.locked(): logger.info("[AUTO_TRADE_BG] 이전 실행이 진행 중 — 이번 호출 스킵 (중복 실행 방지)") - return + return [] async with _auto_trade_bg_lock: - await _execute_auto_trade_background_locked(recommendations_response, db) + return await _execute_auto_trade_background_locked(recommendations_response, db) async def _execute_auto_trade_background_locked(recommendations_response: dict, db: AsyncSession): + # trade_results 는 except 절에서도 참조하므로 try 밖에서 바인딩한다(항상 list 반환 보장). + trade_results: list = [] try: total_processed = 0 - trade_results = [] # 시장 국면 조회 (BEAR 시 max_trades 축소, position_multiplier 감소) market_regime = None @@ -1509,10 +1679,10 @@ async def _execute_auto_trade_background_locked(recommendations_response: dict, f"[AUTO_TRADE_BG] 일일 매수 한도 도달 (오늘 {_bought_today}종목 / 한도 " f"{min(settings.MAX_DAILY_TRADES, regime_max_trades)}) — 신규 진입 스킵" ) - return + return [] - result = await db.execute(select(Portfolio).where(Portfolio.quantity > 0)) - held_stocks = {p.stock_code for p in result.scalars().all()} # 현재 보유 종목 집합 + # [2026-09-27] held_stocks 집합은 파일 전체에서 미사용이라 전용 SELECT 와 함께 제거. + # 보유 여부는 execute_auto_trade() 내부에서 종목별로 직접 조회한다. # recommendations_by_rule 정규화 (list 또는 dict 모두 처리) rec_groups = recommendations_response.get("recommendations_by_rule", []) @@ -1580,11 +1750,19 @@ async def _execute_auto_trade_background_locked(recommendations_response: dict, logger.error(f"[AUTO_TRADE_BG] {stock.get('stock_code')} 처리 오류: {type(e).__name__}: {e}") continue + # [관측성 2026-09-27] 체결 결과를 호출부로 반환한다. 기존엔 trade_results 를 + # 만들어놓고 return 이 없어 항상 None 이 나갔고, swing/entry_service.py 의 + # isinstance(result, list) 분기가 영구 False → "진입 n건" 로그가 사라졌다. + # 담기는 값은 action 이 BUY/SELL 인 dict 뿐이다(SKIP/HOLD/*_FAILED 는 제외). + return trade_results + except Exception as e: logger.error( f"[AUTO_TRADE_BG] 전체 오류: {type(e).__name__}: {e} — " f"recommendations_response keys={list(recommendations_response.keys()) if isinstance(recommendations_response, dict) else '?'}" ) + # 예외 시점까지 실제로 체결된 건은 유실 없이 보고한다(부분 결과 반환). + return trade_results async def _background_sync_portfolio(): @@ -1661,14 +1839,45 @@ async def execute_auto_trade(stock: dict, db: AsyncSession): if current_price <= settings.MIN_PRICE_USD and not _is_etf: # CLAUDE.md 5.4: "초과" return {"action": "SKIP", "reason": "penny_stock", "stock": stock_name} + # ── 현재 보유 조회 (리스크 가드보다 먼저) ──────────────────────────── + # [2026-09-27 M3] MDD/밴/보유수/쿨다운 가드는 '신규 매수'에만 적용해야 한다. + # 기존에는 즉시 SKIP 반환해 보유 종목의 check_exit_signal·ATR 강제 손절에 + # 도달조차 못했다(MDD -10%·보유 20종목 = 가장 청산이 필요한 국면에서 손절 불가). + # → 보유가 있으면 사유를 _buy_blocked_reason 에 담고 청산 판정까지 진행한다. + result = await db.execute( + select(Portfolio).where( + Portfolio.stock_code == stock_code, + Portfolio.quantity > 0, + ) + ) + holding = result.scalar_one_or_none() # None이면 신규매수, 있으면 추가매수/매도 판단 + + _buy_blocked_reason: str | None = None + # [2026-09-27 H-1] 당일 SELL 체결 여부. 보유 종목의 청산 판정은 계속 진행하되, + # '부분매도'가 사이클마다 반복되는 것(계단식 청산 + 왕복 수수료)을 막기 위한 플래그. + _sold_today = False + + def _guard(reason: str): + """보유 없으면 즉시 SKIP dict 반환, 보유 중이면 매수만 차단하고 계속 진행.""" + nonlocal _buy_blocked_reason + if holding is None: + return {"action": "SKIP", "reason": reason, "stock": stock_name} + if _buy_blocked_reason is None: + _buy_blocked_reason = reason + return None + # ── MDD / 손실추적 / 보유수 ────────────────────────────────────────── if not await mdd_manager.can_open_new_position(): # MDD -10% 이상 낙폭 → 신규 진입 중단 - return {"action": "SKIP", "reason": "mdd_pause", "stock": stock_name} + _r = _guard("mdd_pause") + if _r is not None: + return _r if await loss_tracker.is_banned_safe(stock_code): # 3회 연속 손실 종목 → 30거래일 진입 금지 - return {"action": "SKIP", "reason": "consecutive_loss_ban", "stock": stock_name} + _r = _guard("consecutive_loss_ban") + if _r is not None: + return _r held_result = await db.execute(select(Portfolio).where(Portfolio.quantity > 0)) # 적응형 전략: max_holdings 오버라이드 (실패 시 settings 기본값 fallback) @@ -1682,30 +1891,43 @@ async def execute_auto_trade(stock: dict, db: AsyncSession): pass if len(held_result.scalars().all()) >= _adaptive_max_holdings: # 동시 보유 한도 초과 (적응형: max_holdings) - return {"action": "SKIP", "reason": "max_holdings_reached", "stock": stock_name} + _r = _guard("max_holdings_reached") + if _r is not None: + return _r # ── 당일 매도 후 재매수 쿨다운 ─────────────────────────────────────── # 당일 이미 SELL 체결된 종목은 당일 재매수 금지 (섹션 4.3) + # [2026-09-27 §8.5 항목5] PENDING(접수만 되고 미체결) SELL 주문을 포함한다. + # 근거: models.py:141 status(String), :157 created_at(DateTime, default=now_kst), + # :158 executed_at(DateTime, nullable) — 둘 다 KST naive 저장. + # _execute_sell(trade_executor.py:1303·1329-1330)은 rt_cd=="1" 일 때 + # status="PENDING", executed_at=None 으로 기록하므로 executed_at 날짜 + # 필터만으로는 PENDING 행이 전부 탈락한다 → 지정가 부분매도가 미체결인 + # 동안 _sold_today=False 로 남아 사이클마다 중복 부분매도가 쌓였다. + # PENDING 은 created_at 으로 '당일'만 인정 → 체결·취소 없이 남은 과거 유령 행이 + # 쿨다운을 영구히 걸지 않는다. 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", - TradeHistory.status.in_(["COMPLETED", "PARTIAL"]), - TradeHistory.executed_at >= cutoff_time, + or_( + and_( + TradeHistory.status.in_(["COMPLETED", "PARTIAL"]), + TradeHistory.executed_at >= cutoff_time, + ), + and_( + TradeHistory.status == "PENDING", + TradeHistory.created_at >= cutoff_time, + ), + ), ) ) if recent_sell.first(): - return {"action": "SKIP", "reason": "cooldown_sell_today", "stock": stock_name} - - # ── 현재 보유 조회 ──────────────────────────────────────────────────── - result = await db.execute( - select(Portfolio).where( - Portfolio.stock_code == stock_code, - Portfolio.quantity > 0, - ) - ) - holding = result.scalar_one_or_none() # None이면 신규매수, 있으면 추가매수/매도 판단 + _sold_today = True + _r = _guard("cooldown_sell_today") + if _r is not None: + return _r # [BUG FIX] ActiveTrade.remaining_pct 조회 — 미주입 시 1차 익절 후에도 # remaining_pct가 항상 1.0으로 간주되어 SELL_PARTIAL이 반복 발생하는 문제 방지. @@ -1713,17 +1935,35 @@ async def execute_auto_trade(stock: dict, db: AsyncSession): if holding is not None and stock.get("remaining_pct") is None: try: from app.models.models import ActiveTrade + # [2026-09-27 M6] swing/position_manager.get_swing_active_trade 및 + # db_worker 와 동일 패턴: day_trade 행 제외 + 최신 행 우선. + # (같은 종목에 swing/day_trade 행이 동시에 active 일 때 DT 행의 + # remaining_pct 가 스윙 청산 판단에 주입되는 것을 방지) + # [2026-09-27 §8.5 항목3] `!= "day_trade"` 는 SQL 3값 논리로 + # strategy_name IS NULL 인 legacy 스윙 행까지 탈락시켜, 잔량 추적 + # 소스를 잃고 remaining_pct 가 매 사이클 1.0 으로 뭉개졌다. + # → db_worker.not_dt_strategy_clause() 로 3곳을 동일 표현으로 통일. + from app.services.db_worker import not_dt_strategy_clause _at_result = await db.execute( - select(ActiveTrade).where( + select(ActiveTrade) + .where( ActiveTrade.stock_code == stock_code, ActiveTrade.status == "active", + not_dt_strategy_clause(ActiveTrade.strategy_name), ) + .order_by(ActiveTrade.id.desc()) + .limit(1) ) _active_trade = _at_result.scalars().first() if _active_trade is not None and _active_trade.remaining_pct is not None: stock["remaining_pct"] = float(_active_trade.remaining_pct) except Exception as _at_err: - logger.debug(f"[TRADE] {stock_code}: ActiveTrade remaining_pct 조회 실패: {_at_err}") + # 조회 실패 = 잔량 추적 불가 → _resolve_remaining_pct_v2 가 부분매도를 + # 차단한다. 조용히 넘기면 원인 추적이 불가하므로 최소 warning. + logger.warning( + f"[TRADE] {stock_code}: ActiveTrade remaining_pct 조회 실패 " + f"(부분매도 차단, 전량 청산만 허용): {_at_err}" + ) # ── 신호 분석 (Enum 기반) ───────────────────────────────────────────── text_signals = " ".join(signals) @@ -1774,11 +2014,25 @@ async def execute_auto_trade(stock: dict, db: AsyncSession): logger.debug(f"[ML_WEIGHT] {stock_code} 가중치 계산 오류: {e}") has_rule = stock.get("rule_id") is not None - if has_rule: - # 규칙 기반 종목: BLOCK 신호만 없으면 should_buy = True (규칙 자체가 매수 신호) - if any(b in text_signals for b in BLOCK_KEYWORDS): - return {"action": "HOLD", "stock": stock_name, "reason": "disparity_overheated"} + # ── BLOCK 키워드(과열·이격) 판정 ───────────────────────────────────── + # [2026-09-27 H1-A] 기존에는 BLOCK 감지 시 즉시 HOLD를 return 해 + # 아래 청산 판정(check_exit_signal·ATR 강제 손절)에 도달하지 못했다. + # 보유 종목이 과열이면 오히려 매도해야 하는 국면인데 손절·전량익절·트레일링이 + # 전부 스킵됐다. → 조기 return 대신 should_buy 만 끄고 청산 판정까지 진행한다. + # 판정 임계값·BLOCK_KEYWORDS 목록은 무변경(제어 흐름만 수정). + _block_overheat_reason: str | None = None + if any(b in text_signals for b in BLOCK_KEYWORDS): + _block_overheat_reason = ( + "disparity_overheated" if has_rule else "overheat_no_rule" + ) + should_buy = False + if holding is None: + # 보유 없는 종목은 청산 판정이 무의미 → 기존과 동일하게 즉시 HOLD 반환 + return {"action": "HOLD", "stock": stock_name, "reason": _block_overheat_reason} + + if has_rule and _block_overheat_reason is None: + # 규칙 기반 종목: BLOCK 신호만 없으면 should_buy = True (규칙 자체가 매수 신호) # ML HOLD는 신뢰도 60% 이상일 때만 rule 매수를 차단 # (신뢰도 40% 미만의 약한 HOLD는 rule 판단을 우선) ml_hold_active = ( @@ -1789,10 +2043,6 @@ async def execute_auto_trade(stock: dict, db: AsyncSession): ) if not ml_hold_active: should_buy = True - else: - # 비규칙 종목: BLOCK 신호 있으면 과열 HOLD - if any(b in text_signals for b in BLOCK_KEYWORDS): - return {"action": "HOLD", "stock": stock_name, "reason": "overheat_no_rule"} # ML HOLD이면 매수 억제 — 단, 신뢰도 60% 이상의 '확신 있는' HOLD만 차단. # [FIX] 기존에는 신뢰도 무관하게 차단해, 일봉 데이터 부족으로 ML 피처 추출이 @@ -1839,7 +2089,8 @@ async def execute_auto_trade(stock: dict, db: AsyncSession): if rsi_val is not None and bb_data is not None and atr > 0: bb_upper, bb_middle, bb_lower = bb_data if isinstance(bb_data, (tuple, list)) else (0, 0, 0) # remaining_pct: 현재 보유 비율 (1차 익절 전 = 1.0, 후 = 0.5 등) - remaining_pct = float(stock.get("remaining_pct", 1.0) or 1.0) + # [§8.5 항목4] None = '추적 소스 없음' 을 명시 상태로 분리. + remaining_pct, _remaining_tracked = _resolve_remaining_pct_v2(stock, holding) trailing_high = float(stock.get("high_price") or current_price) lowest_since = float(stock.get("lowest_since_entry") or avg_price) @@ -1856,6 +2107,24 @@ async def execute_auto_trade(stock: dict, db: AsyncSession): remaining_pct=remaining_pct, trailing_high=trailing_high, ) + # [§8.5 항목4①] 잔량 추적 불가(ActiveTrade 행 없음/오염) 상태에서는 + # 부분매도를 채택하지 않는다 — 매 사이클 remaining_pct=1.0 으로 + # 재평가되어 계단식 분할매도 + 왕복 수수료가 무한 반복된다. + # 전량 청산(손절·RSI 전량·트레일링)은 그대로 허용한다. + if ( + _exit_signal is not None + and _exit_signal.sell_pct < 1.0 + and not _remaining_tracked + ): + logger.info( + f"[EXIT_SIGNAL] {stock_code}/{stock_name} 부분매도 차단: " + f"remaining_pct 추적 불가(ActiveTrade active 행 없음 — " + f"브로커 잔고 sync/legacy 보유분) " + f"sell_pct={_exit_signal.sell_pct*100:.0f}%, " + f"reason={_exit_signal.reason} — 전량 청산 신호만 허용" + ) + _exit_signal = None + if _exit_signal: should_sell = True _exit_sell_pct = _exit_signal.sell_pct @@ -1876,7 +2145,9 @@ async def execute_auto_trade(stock: dict, db: AsyncSession): # ── 뉴스 감성 필터 (매수 전) ─────────────────────────────────────── # NEWS_ENABLED 시 종목 뉴스 점수 조회 → 강한 악재 차단 / 약한 악재 포지션 축소 _news_position_scale = 1.0 # 뉴스에 의한 포지션 배율 (기본 100%) - if should_buy and settings.NEWS_ENABLED: + # [2026-09-27 H1] 청산 대상(보유+매도신호)은 뉴스 필터를 타지 않는다 — + # news_negative SKIP 이 손절보다 먼저 반환되면 청산이 또 막힌다. + if should_buy and not (should_sell and holding) and settings.NEWS_ENABLED: try: from app.services.news_sentiment import get_stock_news_score news_score = await get_stock_news_score(stock_code) @@ -1901,11 +2172,31 @@ async def execute_auto_trade(stock: dict, db: AsyncSession): stock["_news_position_scale"] = _news_position_scale # ── 매매 실행 위임 ──────────────────────────────────────────────────── - if should_buy: - logger.info(f"[TRADE] 매수 실행: {stock_code}/{stock_name} @ {current_price:,.0f}") - return await _execute_buy( - stock, db, holding, current_price, signals, score_val, - is_domestic, exchange_code, stock_code, stock_name, + # [2026-09-27 H1] 청산은 항상 매수보다 우선한다. + # 기존에는 should_buy 를 먼저 평가해, 보유 종목이 당일 룰 추천에 포함되면 + # (rule 기반은 BLOCK/ML HOLD 만 없으면 should_buy=True 강제) + # 손절·전량익절·트레일링이 전부 무력화되고 물타기 매수가 나갔다. + # [2026-09-27 H-1] 당일 이미 SELL 체결된 보유 종목의 '부분매도' 반복 차단. + # _guard() 가 보유 종목의 cooldown_sell_today 조기 SKIP 을 제거한 뒤부터, + # ActiveTrade 행이 없는 보유분(브로커 sync·legacy strategy_name IS NULL)은 + # remaining_pct 가 매 사이클 1.0 으로 간주되어 SELL_PARTIAL(0.5)이 무한 반복됐다. + # → 전량 청산(손절·RSI≥70·트레일링)은 그대로 허용하고, 부분매도만 당일 1회로 제한한다. + if _sold_today and should_sell and holding and _exit_sell_pct < 1.0: + logger.info( + f"[TRADE] {stock_code}/{stock_name} 부분매도 차단: cooldown_sell_today " + f"(당일 매도 기록 존재, sell_pct={_exit_sell_pct*100:.0f}%, " + f"reason={(_exit_signal.reason if _exit_signal else ' '.join(signals))}) " + f"— 전량 청산 신호만 허용" + ) + return {"action": "SKIP", "reason": "cooldown_sell_today", "stock": stock_name} + + if should_buy and should_sell and holding: + logger.warning( + f"[TRADE_CONFLICT] {stock_code}/{stock_name}: 동일 사이클에 매수·매도 신호 동시 발생 " + f"→ 청산 우선 실행. has_rule={has_rule} score={score_val} " + f"ml_signal={ml_signal_trade} ml_conf={ml_confidence_trade} " + f"exit={(_exit_signal.reason if _exit_signal else None)} " + f"sell_pct={_exit_sell_pct} signals={signals}" ) if should_sell and holding: @@ -1916,8 +2207,26 @@ async def execute_auto_trade(stock: dict, db: AsyncSession): sell_pct=_exit_sell_pct, # [CLAUDE.md 5.2] 분할매도 비율 전달 ) + if should_buy: + # [M3] 리스크 가드(MDD/밴/보유수/당일매도쿨다운)는 신규 매수만 차단한다 + if _buy_blocked_reason is not None: + logger.info( + f"[TRADE] {stock_code}/{stock_name} 매수 차단: {_buy_blocked_reason} (청산 판정은 수행됨)" + ) + return {"action": "SKIP", "reason": _buy_blocked_reason, "stock": stock_name} + logger.info(f"[TRADE] 매수 실행: {stock_code}/{stock_name} @ {current_price:,.0f}") + return await _execute_buy( + stock, db, holding, current_price, signals, score_val, + is_domestic, exchange_code, stock_code, stock_name, + ) + logger.info(f"[TRADE] ⏸️ HOLD: {stock_code}/{stock_name} (buy={should_buy}, sell={should_sell}, has_rule={has_rule})") + # [2026-09-27 H1-A] BLOCK(과열·이격) 사유는 조기 return 제거 후에도 그대로 노출 — + # 호출측 로깅·집계 호환을 위해 기존 reason 문자열을 유지한다. + if _block_overheat_reason is not None: + return {"action": "HOLD", "stock": stock_name, "reason": _block_overheat_reason} + return {"action": "HOLD", "stock": stock_name, "reason": "no_buy_signal(진입조건 미충족)"} except ValueError as e: diff --git a/backend/app/services/us_backtest_engine.py b/backend/app/services/us_backtest_engine.py index 0cbb506..18b3936 100644 --- a/backend/app/services/us_backtest_engine.py +++ b/backend/app/services/us_backtest_engine.py @@ -12,6 +12,7 @@ from dataclasses import dataclass, asdict import numpy as np +from app.services.indicators_core import bollinger_sma, rsi_wilder from app.services.korea_investment_api import korea_investment_api from app.services.us_stock_strategy import ( check_us_buy_signal, @@ -501,32 +502,25 @@ def _calculate_indicators(daily_data: List[Dict]) -> List[Dict]: def _compute_rsi(closes: np.ndarray, period: int = 14) -> List[Optional[float]]: - """RSI 계산""" - n = len(closes) - rsi = [None] * n + """RSI 계산 (Wilder 평활, **당일 종가 포함** — BB·실전과 동일 기준봉) - if n <= period: - return rsi + [2026-09-27 §8.5-1 수정] 이전 구현은 인덱스 i 의 RSI 를 `closes[:i]` + (= 직전 봉까지)로 계산해 정식 Wilder RSI 를 1봉 지연시켰다. 룩어헤드는 없었지만 + - 같은 엔진의 볼린저 밴드(`_compute_bollinger`)는 `closes[:i + 1]`(당일 포함) + - 실전 경로(us_stock_strategy.scan_us_stocks:336/344 — `calculate_rsi(closes)`, + `calculate_bollinger(closes)`, `closes[-1]` = 당일 종가)도 당일 포함 + 이라 백테스트만 1봉 어긋난 신호로 검증되고 있었다. 이제 BB 와 동일하게 + `closes[:i + 1]` 을 쓴다. - deltas = np.diff(closes) - gains = np.where(deltas > 0, deltas, 0.0) - losses = np.where(deltas < 0, -deltas, 0.0) + ⚠️ 룩어헤드 없음: 인덱스 i 의 값은 `closes[:i + 1]`(= i 번째 봉의 종가까지)만 + 참조하며 i+1 이후 봉은 절대 참조하지 않는다. 이는 실전에서 당일 종가 확정 후 + 스캔이 도는 것과 동일한 정보 집합이다(체결은 다음 봉 시가 — 호출부 참조). - avg_gain = np.mean(gains[:period]) - avg_loss = np.mean(losses[:period]) - - for i in range(period, n - 1): - if avg_loss == 0: - rsi[i + 1] = 100.0 - else: - rs = avg_gain / avg_loss - rsi[i + 1] = 100.0 - (100.0 / (1.0 + rs)) - - if i < len(gains): - avg_gain = (avg_gain * (period - 1) + gains[i]) / period - avg_loss = (avg_loss * (period - 1) + losses[i]) / period - - return rsi + rsi_prev(3봉 전 RSI) 정합성: 호출부는 `indicators[i - 3]["rsi"]` 를 쓰므로 + 이제 `closes[:i - 2]`(= i-3 번째 봉까지) 기준이 되어 실전 + `calculate_rsi(closes[:-3])` 와 정확히 일치한다(수정 전에는 한 봉 더 과거였다). + """ + return [rsi_wilder(list(closes[:i + 1]), period) for i in range(len(closes))] def _compute_bollinger( @@ -534,19 +528,24 @@ def _compute_bollinger( period: int = 20, std_mult: float = 2.0, ) -> tuple: - """볼린저 밴드 계산 -> (middle, upper, lower)""" + """볼린저 밴드 계산 -> (middle, upper, lower) + + [2026-09-27 §8.5-1 중복 통합] 계산 본체는 indicators_core.bollinger_sma() 로 이동. + 원 구현이 `np.std(..., ddof=0)`(모표준편차)를 명시했고 indicators_core 도 ddof=0 + 이므로 수치가 동일하다(pandas `.std()` 기본 ddof=1 과 갈리는 함정은 해당 없음). + 반환 순서는 기존 호출부 호환을 위해 (middle, upper, lower) 를 유지한다 + — indicators_core 는 (upper, middle, lower) 순서임에 주의. + """ n = len(closes) - middle = [None] * n - upper = [None] * n - lower = [None] * n + middle: List[Optional[float]] = [None] * n + upper: List[Optional[float]] = [None] * n + lower: List[Optional[float]] = [None] * n for i in range(period - 1, n): - window = closes[i - period + 1:i + 1] - m = float(np.mean(window)) - s = float(np.std(window, ddof=0)) - middle[i] = m - upper[i] = m + std_mult * s - lower[i] = m - std_mult * s + band = bollinger_sma([float(c) for c in closes[:i + 1]], period, std_mult) + if band is None: + continue + upper[i], middle[i], lower[i] = band return middle, upper, lower diff --git a/backend/app/services/us_market_scheduler.py b/backend/app/services/us_market_scheduler.py index 2e6735e..72419f2 100644 --- a/backend/app/services/us_market_scheduler.py +++ b/backend/app/services/us_market_scheduler.py @@ -24,6 +24,7 @@ from typing import List, Dict, Optional, Any import pytz from app.core.config import settings +from app.services.indicators_core import atr_sma from app.services.us_stock_strategy import ( scan_us_stocks, get_us_watchlist, @@ -51,10 +52,15 @@ _US_MARKET_CLOSE = dtime(16, 0) # 16:00 ET _US_ENTRY_START = dtime(9, 40) # 장 시작 10분 후 (변동성 안정화 대기) _US_ENTRY_CUTOFF = dtime(15, 45) # 장 마감 15분 전 신규 진입 중단 -# 사이클당 최대 신규 진입 종목 수 -_MAX_NEW_ENTRIES_PER_CYCLE = 3 -# 기본 환율 폴백 (USD/KRW) -_DEFAULT_FX_RATE = 1350.0 +# [§8.5-8 상수 출처 단일화 2026-09-27] +# 사이클당 최대 신규 진입 종목 수(3) 와 환율 폴백(1350.0) 은 config 로 편입했다 +# (settings.US_MAX_NEW_ENTRIES_PER_CYCLE / settings.US_DEFAULT_FX_RATE, 값 변경 없음). +# 두 이름은 모듈 외부에서 참조되지 않는 private 상수였으므로 별칭을 남기지 않고 +# 사용 지점에서 settings 를 직접(런타임에) 읽는다. +# 환율 폴백은 KIS 해외잔고로 환율을 역산할 수 없을 때만 쓰이며 — _get_us_account_info() +# 에서 (a) 잔고 조회 예외, (b) USD 평가액+예수금 == 0 또는 KRW 총평가 == 0 — +# 포지션 사이징(KRW예산 ÷ 환율 → 수량)과 TradeHistory.fx_rate 로 흘러가 +# **실제 주문 수량과 기록 금액에 직접 영향**을 준다. def _is_us_market_open() -> bool: @@ -188,7 +194,7 @@ async def _get_us_account_info() -> Dict[str, Any]: if total_usd > 0 and tot_evlu_krw > 0: exchange_rate = tot_evlu_krw / total_usd else: - exchange_rate = _DEFAULT_FX_RATE + exchange_rate = settings.US_DEFAULT_FX_RATE # 역산 불가 → config 폴백 current_us_allocation_krw = frcr_evlu * exchange_rate available_cash_krw = usd_cash * exchange_rate @@ -217,7 +223,7 @@ async def _get_us_account_info() -> Dict[str, Any]: "total_assets_krw": 10_000_000, "available_cash_krw": 0.0, "current_us_allocation_krw": 0.0, - "exchange_rate": _DEFAULT_FX_RATE, + "exchange_rate": settings.US_DEFAULT_FX_RATE, # 잔고 조회 실패 → config 폴백 "holdings": [], } @@ -232,26 +238,25 @@ def _calc_atr(bars: List[Dict[str, Any]], period: int = 14) -> float: Returns: ATR 값. 데이터 부족 시 0.0 + + [2026-09-27 §8.5-1 중복 통합] 계산 본체는 indicators_core.atr_sma() 로 이동. + TR 의 단순평균(Wilder ATR 아님)으로 실전 스윙 경로 + (mean_reversion_strategy.calculate_atr)와 동일하며 수치도 완전히 같다. + 데이터 부족 시 None 대신 0.0 을 반환하는 기존 계약은 유지한다. """ if len(bars) < period + 1: return 0.0 - trs: List[float] = [] - for i in range(1, len(bars)): - h = bars[i]["high"] - l = bars[i]["low"] - pc = bars[i - 1]["close"] - tr = max(h - l, abs(h - pc), abs(l - pc)) - trs.append(tr) + highs = [b["high"] for b in bars] + lows = [b["low"] for b in bars] + closes = [b["close"] for b in bars] - if len(trs) < period: - return 0.0 - - return sum(trs[-period:]) / period + atr = atr_sma(highs, lows, closes, period) + return atr if atr is not None else 0.0 # ── 기존 US 포지션 청산 모니터링 ────────────────────────────────────────────── -async def _monitor_us_exits(exchange_rate: float = _DEFAULT_FX_RATE) -> List[Dict]: +async def _monitor_us_exits(exchange_rate: float = settings.US_DEFAULT_FX_RATE) -> List[Dict]: """보유 중인 미국 주식 포지션의 청산 신호를 체크하고 매도를 실행한다.""" from app.core.database import AsyncSessionLocal from app.models.models import ActiveTrade @@ -500,7 +505,7 @@ async def us_trade_cycle() -> dict: # ── 7. 신호별 주문 처리 ──────────────────────────────────────────── new_entries = 0 for sig in signals: - if new_entries >= _MAX_NEW_ENTRIES_PER_CYCLE: + if new_entries >= settings.US_MAX_NEW_ENTRIES_PER_CYCLE: break ticker = sig["ticker"] @@ -584,8 +589,12 @@ async def us_trade_cycle() -> dict: # ATR 기반 손절가 계산 bars = await adapter.get_us_stock_history(ticker) atr = _calc_atr(bars) if bars else 0.0 + # ATR 배수는 config(settings.US_ATR_STOP_MULT = 2.5) 가 정본. + # 아래 0.93 은 settings.US_STOP_LOSS_PCT(-7.0) 와 같은 뜻이지만 + # 1 + (-7.0/100) = 0.9299999999999999 로 부동소수 비트가 달라져 + # 값 동일성이 깨질 수 있어 리터럴을 그대로 둔다(백테스트 없이 변경 금지). stop_loss = ( - round(buy_price - 2.5 * atr, 2) if atr > 0 + round(buy_price - settings.US_ATR_STOP_MULT * atr, 2) if atr > 0 else round(buy_price * 0.93, 2) ) diff --git a/backend/app/services/us_stock_strategy.py b/backend/app/services/us_stock_strategy.py index 96fc534..50fef7d 100644 --- a/backend/app/services/us_stock_strategy.py +++ b/backend/app/services/us_stock_strategy.py @@ -25,22 +25,45 @@ S&P 500 / NASDAQ 100 기반 평균회귀 + 모멘텀 복합 전략 import logging from typing import Optional, Dict, List +from app.core.config import settings from app.services.mean_reversion_strategy import ExitSignal +# §8.5-2 포지션 사이징 단일 출처 (순수 계산 모듈) +from app.services.sizing_core import budget_by_ratios logger = logging.getLogger(__name__) # ── 상수 ──────────────────────────────────────────────────────────────────────── -US_RSI_BUY_THRESHOLD = 35.0 # RSI 매수 기준 (국내 30 대비 여유) -US_BB_LOWER_MARGIN_LOW = 0.97 # BB 하단 -3% -US_BB_LOWER_MARGIN_HIGH = 1.02 # BB 하단 +2% -US_MIN_PRICE_USD = 4.0 # 페니스탁 필터 -US_STOP_LOSS_PCT = -7.0 # 손절 -7% -US_ATR_STOP_MULT = 2.5 # ATR 손절 배수 -US_FIRST_EXIT_RSI = 55.0 # 1차 익절 RSI 기준 -US_FIRST_EXIT_SELL_PCT = 0.30 # 1차 익절 매도 비율 (30%) -US_TRAILING_STOP_PCT = 0.04 # 트레일링 스탑 -4% -US_MAX_PER_POSITION = 0.05 # 종목당 최대 5% -US_MAX_CAPITAL_RATIO = 0.30 # 미국 주식 총 비중 최대 30% +# [§8.5-8 상수 출처 단일화 2026-09-27] +# **config.py(settings) 가 정본이다.** 과거 이 자리에 있던 모듈 상수 11개는 전부 +# config 로 옮겼고(값 변경 없음), 아래 이름들은 기존 호출부 호환을 위한 *얇은 별칭*이다. +# - 별칭은 모듈 `__getattr__`(PEP 562)로 구현해 **접근 시점에** settings 를 읽는다. +# import 시점에 값을 고정하지 않으므로 런타임 설정 변경(.env/DB 오버라이드)이 반영된다. +# 단 `from ... import US_X` 로 받아 모듈 전역에 묶어두는 호출부 +# (us_backtest_engine, tests)는 그 시점 값으로 고정된다 — 이는 원래부터의 한계. +# - 이 모듈 내부 로직은 별칭을 쓰지 않고 settings 를 직접 읽는다. +# - US_MAX_CAPITAL_RATIO 는 원래 모듈 상수(0.30)와 config 키(0.30)가 완전 중복이었고 +# 값이 같아 config 쪽을 정본으로 채택했다. +_SETTINGS_ALIASES = { + "US_RSI_BUY_THRESHOLD": "US_RSI_BUY_THRESHOLD", + "US_BB_LOWER_MARGIN_LOW": "US_BB_LOWER_MARGIN_LOW", + "US_BB_LOWER_MARGIN_HIGH": "US_BB_LOWER_MARGIN_HIGH", + "US_MIN_PRICE_USD": "US_MIN_PRICE_USD", + "US_STOP_LOSS_PCT": "US_STOP_LOSS_PCT", # 음수 규약(-7.0) 유지 + "US_ATR_STOP_MULT": "US_ATR_STOP_MULT", + "US_FIRST_EXIT_RSI": "US_FIRST_EXIT_RSI", + "US_FIRST_EXIT_SELL_PCT": "US_FIRST_EXIT_SELL_PCT", + "US_TRAILING_STOP_PCT": "US_TRAILING_STOP_PCT", + "US_MAX_PER_POSITION": "US_MAX_POSITION_SIZE", # 모듈 상수명 ≠ config 키명 + "US_MAX_CAPITAL_RATIO": "US_MAX_CAPITAL_RATIO", +} + + +def __getattr__(name: str): + """모듈 상수 별칭 → settings 지연 조회 (PEP 562).""" + key = _SETTINGS_ALIASES.get(name) + if key is None: + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") + return getattr(settings, key) def check_us_buy_signal( @@ -80,10 +103,11 @@ def check_us_buy_signal( # 페니스탁 필터 # [수정 10] trade_executor.py:1102의 `<= settings.MIN_PRICE_USD` 와 경계 조건 통일. # CLAUDE.md 5.4: "$4 초과" → $4.00 이하(=포함)는 진입 불가. - if current_price_usd <= US_MIN_PRICE_USD: + min_price_usd = settings.US_MIN_PRICE_USD + if current_price_usd <= min_price_usd: logger.debug( "US BUY skip: price $%.2f <= $%.2f (penny stock filter)", - current_price_usd, US_MIN_PRICE_USD, + current_price_usd, min_price_usd, ) return None @@ -92,7 +116,8 @@ def check_us_buy_signal( return None # RSI 과매도 확인 - if rsi > US_RSI_BUY_THRESHOLD: + rsi_threshold = settings.US_RSI_BUY_THRESHOLD + if rsi > rsi_threshold: return None # RSI 반등 확인 (rsi_prev 제공 시, RSI 하락 중이면 진입 차단) @@ -104,8 +129,8 @@ def check_us_buy_signal( return None # BB 하단 구간 확인 (-3% ~ +2%) - bb_low_minus = bb_lower * US_BB_LOWER_MARGIN_LOW - bb_low_plus = bb_lower * US_BB_LOWER_MARGIN_HIGH + bb_low_minus = bb_lower * settings.US_BB_LOWER_MARGIN_LOW + bb_low_plus = bb_lower * settings.US_BB_LOWER_MARGIN_HIGH if not (bb_low_minus <= close <= bb_low_plus): return None @@ -119,7 +144,7 @@ def check_us_buy_signal( "market": "US", "split_plan": [0.50, 0.50], # 2회 분할 매수 "description": ( - f"RSI({rsi:.1f})<={US_RSI_BUY_THRESHOLD:.0f} + " + f"RSI({rsi:.1f})<={rsi_threshold:.0f} + " f"BB 하단 -3%~+2% (${current_price_usd:.2f})" f"{reversal_info}" ), @@ -159,8 +184,10 @@ def check_us_exit_signal( ExitSignal 또는 None """ # ── 1. 손절 (최우선) ────────────────────────────────────────────── - atr_stop = entry_price - US_ATR_STOP_MULT * atr - pct_stop = entry_price * (1 + US_STOP_LOSS_PCT / 100) # e.g. entry * 0.93 + # 값·수식 모두 편입 전과 동일(settings 가 정본). SL 은 가격 기준이므로 + # max() 가 타이트한 쪽 — 방향 변경 금지(CLAUDE.md §8.1-5). + atr_stop = entry_price - settings.US_ATR_STOP_MULT * atr + pct_stop = entry_price * (1 + settings.US_STOP_LOSS_PCT / 100) # 음수 규약 → e.g. entry * 0.93 stop_price = max(atr_stop, pct_stop) # 높은 값 = 타이트한 손절 if close <= stop_price: @@ -176,27 +203,29 @@ def check_us_exit_signal( # ── 2. 1차 익절: remaining_pct > 0.7 (아직 1차 안 했을 때) ───────── # 최소 1×ATR 수익 조건 min_profit_price = entry_price + 1.0 * atr + first_exit_rsi = settings.US_FIRST_EXIT_RSI if remaining_pct > 0.7: - if rsi >= US_FIRST_EXIT_RSI and close > bb_middle and close >= min_profit_price: + if rsi >= first_exit_rsi and close > bb_middle and close >= min_profit_price: return ExitSignal( action="SELL_PARTIAL", reason=( - f"US 1차 익절(30%): RSI({rsi:.1f})>={US_FIRST_EXIT_RSI:.0f} + " + f"US 1차 익절(30%): RSI({rsi:.1f})>={first_exit_rsi:.0f} + " f"BB중앙(${bb_middle:.2f}) 상회 + 1×ATR(${atr:.2f}) 수익" ), - sell_pct=US_FIRST_EXIT_SELL_PCT, + sell_pct=settings.US_FIRST_EXIT_SELL_PCT, ) # ── 3. 2차 익절: 트레일링 스탑 -4% ────────────────────────────────── if remaining_pct <= 0.7: if trailing_high is not None and trailing_high > entry_price: - trail_stop = trailing_high * (1 - US_TRAILING_STOP_PCT) + trail_pct = settings.US_TRAILING_STOP_PCT + trail_stop = trailing_high * (1 - trail_pct) if close < trail_stop: return ExitSignal( action="SELL_ALL", reason=( f"US 트레일링 청산: close=${close:.2f} < " - f"최고가×{1 - US_TRAILING_STOP_PCT:.2f}=${trail_stop:.2f} " + f"최고가×{1 - trail_pct:.2f}=${trail_stop:.2f} " f"(최고가=${trailing_high:.2f})" ), sell_pct=1.0, @@ -221,8 +250,11 @@ def calculate_us_position_size( available_cash_krw: float, current_us_allocation_krw: float, exchange_rate: float, - max_per_position: float = US_MAX_PER_POSITION, - max_us_capital: float = US_MAX_CAPITAL_RATIO, + # 기본값은 config 에서 읽는다(값은 편입 전과 동일: 0.05 / 0.30). + # 시그니처 변경 금지 규칙 때문에 기본값만은 import 시점에 고정된다 — 런타임 오버라이드가 + # 필요한 호출부는 인자를 명시적으로 넘길 것. + max_per_position: float = settings.US_MAX_POSITION_SIZE, + max_us_capital: float = settings.US_MAX_CAPITAL_RATIO, ) -> float: """미국 주식 포지션 사이징 (KRW 기준 반환) @@ -242,13 +274,18 @@ def calculate_us_position_size( if total_assets_krw <= 0 or exchange_rate <= 0: return 0.0 - # 종목당 최대 금액 - max_by_position = total_assets_krw * max_per_position - - # 미국 주식 잔여 예산 - us_budget_remaining = (total_assets_krw * max_us_capital) - current_us_allocation_krw - - result = max(0.0, min(max_by_position, us_budget_remaining, available_cash_krw)) + # [§8.5-2 통합 2026-09-27] 3중 min 식을 sizing_core.budget_by_ratios 로 위임. + # = max(0, min(종목당 최대, 미국 잔여 예산, 가용현금)) — 수식·금액 동일. + # (위 total<=0 / exchange_rate<=0 가드는 이 모듈 고유 로직이라 남긴다.) + max_by_position = total_assets_krw * max_per_position # 로그용 + us_budget_remaining = (total_assets_krw * max_us_capital) - current_us_allocation_krw # 로그용 + result = budget_by_ratios( + total_assets=total_assets_krw, + available_cash=available_cash_krw, + current_allocation=current_us_allocation_krw, + max_per_position=max_per_position, + max_strategy_ratio=max_us_capital, + ) logger.debug( "US position_size: total=%.0f KRW, max_pos=%.0f, us_remaining=%.0f, cash=%.0f → %.0f KRW (≈$%.0f)", diff --git a/backend/app/strategy/README.md b/backend/app/strategy/README.md deleted file mode 100644 index 30cffb6..0000000 --- a/backend/app/strategy/README.md +++ /dev/null @@ -1,288 +0,0 @@ -# Strategy Module - -매매 전략을 관리하고 실행하는 핵심 모듈 - -## 모듈 구조 - -``` -strategy/ -├── __init__.py # 모듈 내보내기 -├── base_strategy.py # BaseStrategy 추상 클래스 -├── signal.py # Signal 데이터 클래스 -├── strategy_manager.py # 전략 관리자 (등록/실행) -├── indicators.py # 보조지표 (RSI, BB, MACD 등) -├── scanner.py # 종목 스캔 -├── strategies/ # 구체적 전략 구현 -│ ├── __init__.py -│ └── day_trading.py # 데이트레이딩 전략 -└── README.md -``` - -## 사용 방법 - -### 1. 전략 구현 - -`BaseStrategy`를 상속받아 `analyze()` 메서드 구현: - -```python -from app.strategy import BaseStrategy, Signal, SignalType -from datetime import datetime -import pytz - -class MyStrategy(BaseStrategy): - def __init__(self): - super().__init__(name="MyStrategy", config={"param1": 10}) - - async def analyze(self, stock_code, market_data, portfolio_data=None): - current_price = market_data.get("current_price") - - # 매수 조건 체크 - if self._should_buy(market_data): - return Signal( - stock_code=stock_code, - signal_type=SignalType.BUY, - strategy_name=self.name, - timestamp=datetime.now(pytz.timezone("Asia/Seoul")), - price=current_price, - confidence=0.8, - reason="매수 조건 충족" - ) - - return None - - def _should_buy(self, market_data): - # 실제 로직 구현 - return True -``` - -### 2. 전략 등록 및 실행 - -```python -from app.strategy import strategy_manager -from app.strategy.strategies import DayTradingStrategy - -# 전략 인스턴스 생성 -day_trading = DayTradingStrategy( - name="DayTrading_ORB", - config={ - "max_positions": 3, - "stop_loss_pct": 0.025 - } -) - -# 전략 등록 (priority 낮을수록 먼저 실행) -strategy_manager.register_strategy(day_trading, priority=10) - -# 단일 전략 실행 -signal = await strategy_manager.run_strategy( - "DayTrading_ORB", - stock_code="005930", - market_data={ - "current_price": 75000, - "minute_chart": [...], - } -) - -if signal: - print(f"신호 발생: {signal}") - -# 모든 전략 동시 실행 -signals = await strategy_manager.run_all_strategies( - stock_code="005930", - market_data={...}, - parallel=True # 병렬 실행 -) - -# 신호 충돌 해결 -final_signal = strategy_manager.resolve_conflicts( - signals, - method="highest_confidence" # or "priority", "majority_vote" -) -``` - -### 3. Signal 데이터 구조 - -```python -from app.strategy import Signal, SignalType -from datetime import datetime -import pytz - -signal = Signal( - stock_code="005930", - signal_type=SignalType.BUY, - strategy_name="MyStrategy", - timestamp=datetime.now(pytz.timezone("Asia/Seoul")), - - # 주문 정보 - quantity=10, - price=75000, # None = 시장가 - confidence=0.85, - - # 손익 관리 - stop_loss_price=73000, - take_profit_price=78000, - trailing_stop_pct=0.02, # 2% 트레일링 - - # 메타데이터 - reason="RSI 과매도 반등", - metadata={"rsi": 25, "bb_lower": 74000} -) - -# JSON 변환 -data = signal.to_dict() -``` - -### 4. 보조지표 사용 - -```python -from app.strategy.indicators import rsi, bollinger_bands, sma, macd, atr - -# 종가 리스트 -closes = [75000, 75500, 76000, 74500, 75000, ...] - -# RSI -rsi_value = rsi(closes, period=14) -if rsi_value and rsi_value < 30: - print("과매도 구간") - -# 볼린저 밴드 -bb = bollinger_bands(closes, period=20, std_mult=2.0) -if bb: - upper, middle, lower = bb - if closes[-1] < lower: - print("하단 돌파") - -# SMA -sma_20 = sma(closes, 20) -sma_60 = sma(closes, 60) -if sma_20 and sma_60 and sma_20 > sma_60: - print("골든 크로스 발생") - -# ATR -highs = [76000, 76500, ...] -lows = [74500, 75000, ...] -atr_value = atr(highs, lows, closes, period=14) -``` - -### 5. 종목 스캔 - -```python -from app.strategy.scanner import Scanner, filter_price_range, filter_volume_min - -# 스캀너 생성 -scanner = Scanner() - -# 필터 추가 -scanner.add_filter(filter_price_range(10000, 100000)) # 1만~10만원 -scanner.add_filter(filter_volume_min(100000)) # 최소 거래량 10만주 - -# 커스텀 필터 -def custom_filter(data): - # 등락률 5% 이상 - return data.get("change_rate", 0) >= 5.0 - -scanner.add_filter(custom_filter) - -# 스캔 실행 -kospi_codes = ["005930", "000660", "035420", ...] # KOSPI 종목 리스트 -matched = await scanner.scan(kospi_codes, max_concurrent=10) - -for stock in matched: - print(f"{stock['stock_name']} ({stock['stock_code']}): " - f"{stock['current_price']}원, +{stock['change_rate']}%") -``` - -## 주요 기능 - -### BaseStrategy -- ✅ 전략 추상화 (템플릿 메서드 패턴) -- ✅ `analyze()` 구현 강제 -- ✅ `on_signal_executed()` 콜백 -- ✅ `on_error()` 에러 핸들링 -- ✅ 설정 관리 (config) -- ✅ 활성화/비활성화 - -### StrategyManager -- ✅ 전략 등록/삭제 -- ✅ 우선순위 관리 -- ✅ 단일/복수 전략 실행 -- ✅ 병렬 실행 지원 -- ✅ 신호 충돌 해결 - - `highest_confidence`: 가장 높은 신뢰도 - - `priority`: 전략 우선순위 - - `majority_vote`: 다수결 -- ✅ 신호 이력 관리 -- ✅ 통계 정보 - -### Signal -- ✅ 표준화된 신호 데이터 구조 -- ✅ 6가지 신호 타입 (BUY/SELL/HOLD/CLOSE/STOP_LOSS/TAKE_PROFIT) -- ✅ 손익 관리 필드 -- ✅ 신뢰도 평가 -- ✅ JSON 직렬화 - -### Indicators -- ✅ SMA (Simple Moving Average) -- ✅ EMA (Exponential Moving Average) -- ✅ RSI (Relative Strength Index) -- ✅ Bollinger Bands -- ✅ MACD -- ✅ ATR (Average True Range) -- ✅ VWAP (Volume Weighted Average Price) - -### Scanner -- ✅ 조건 기반 종목 필터링 -- ✅ 동시 다중 종목 스캔 -- ✅ 사전 정의 필터 - - 가격 범위 - - 거래량 최소값 - - 등락률 범위 - - 시가총액 최소값 -- ✅ 커스텀 필터 추가 가능 - -## 기존 코드 마이그레이션 - -기존 `services/` 폴더의 전략 파일들을 새 구조로 변환: - -| 기존 파일 | 새 위치 | -|----------|----------| -| `day_trading_strategy.py` | `strategy/strategies/day_trading.py` | -| `swing_trading_strategy.py` | `strategy/strategies/swing_trading.py` | -| `momentum_strategy.py` | `strategy/strategies/momentum.py` | - -변환 패턴: -```python -# 기존 -def check_signal(stock_code, market_data): - if condition: - return DayTradeSignal(...) - return None - -# 새 구조 -class DayTradingStrategy(BaseStrategy): - async def analyze(self, stock_code, market_data, portfolio_data=None): - if condition: - return Signal(...) - return None -``` - -## Phase 2 완료 체크리스트 - -- [x] `strategy/base_strategy.py` 생성 -- [x] `strategy/signal.py` 생성 -- [x] `strategy/strategy_manager.py` 생성 -- [x] `strategy/indicators.py` 생성 -- [x] `strategy/scanner.py` 생성 -- [x] `strategy/strategies/day_trading.py` 예시 생성 -- [x] README 문서 작성 - -## 다음 단계 (Phase 3) - -- [ ] WebSocket 모듈 분리 (`realtime/`) -- [ ] 실시간 시세 수신 구조 개선 -- [ ] 장 스케줄 관리 - ---- - -**작성일**: 2026-03-06 -**작성자**: 서영우 diff --git a/backend/app/strategy/__init__.py b/backend/app/strategy/__init__.py deleted file mode 100644 index 5714e8f..0000000 --- a/backend/app/strategy/__init__.py +++ /dev/null @@ -1,25 +0,0 @@ -""" -Strategy Engine Module - -매매 전략을 관리하고 실행하는 핵심 모듈 -- 전략 추상화 (BaseStrategy) -- 신호 생성 (Signal) -- 전략 등록·실행·관리 (StrategyManager) -- 보조지표 (Indicators) -- 종목 스캔 (Scanner) -""" - -from app.strategy.base_strategy import BaseStrategy -from app.strategy.signal import Signal, SignalType -from app.strategy.strategy_manager import StrategyManager, strategy_manager - -__all__ = [ - # Core classes - "BaseStrategy", - "Signal", - "SignalType", - "StrategyManager", - - # Singleton - "strategy_manager", -] diff --git a/backend/app/strategy/base_strategy.py b/backend/app/strategy/base_strategy.py deleted file mode 100644 index e567e64..0000000 --- a/backend/app/strategy/base_strategy.py +++ /dev/null @@ -1,101 +0,0 @@ -""" -Base Strategy Abstract Class - -모든 전략이 상속받아야 하는 추상 클래스 -""" -from abc import ABC, abstractmethod -from typing import List, Dict, Any, Optional -from datetime import datetime -import logging - -from app.strategy.signal import Signal - -logger = logging.getLogger(__name__) - - -class BaseStrategy(ABC): - """ - 전략 기본 추상 클래스 - - 모든 전략은 이 클래스를 상속받아 analyze() 메서드를 구현해야 함 - """ - - def __init__(self, name: str, config: Optional[Dict[str, Any]] = None): - """ - Args: - name: 전략 이름 (고유 식별자) - config: 전략 설정 딕셔너리 - """ - self.name = name - self.config = config or {} - self.enabled = True - self.last_signals: Dict[str, Signal] = {} # 종목별 마지막 신호 - - logger.info(f"📊 전략 초기화: {self.name}") - - @abstractmethod - async def analyze( - self, - stock_code: str, - market_data: Dict[str, Any], - portfolio_data: Optional[Dict[str, Any]] = None - ) -> Optional[Signal]: - """ - 시장 데이터를 분석하여 매매 신호 생성 - - Args: - stock_code: 종목코드 - market_data: 시장 데이터 (현재가, 차트, 호가 등) - portfolio_data: 포트폴리오 데이터 (보유 수량, 평단가 등) - - Returns: - Signal 객체 또는 None (신호 없음) - """ - pass - - async def on_signal_executed(self, signal: Signal, result: Dict[str, Any]): - """ - 신호 실행 후 콜백 - - Args: - signal: 실행된 신호 - result: 실행 결과 (주문 응답) - """ - logger.info(f"✅ [{self.name}] 신호 실행 완료: {signal.signal_type.value} {signal.stock_code}") - self.last_signals[signal.stock_code] = signal - - async def on_error(self, error: Exception, context: Dict[str, Any]): - """ - 에러 발생 시 콜백 - - Args: - error: 발생한 예외 - context: 에러 컨텍스트 - """ - logger.error(f"❌ [{self.name}] 에러 발생: {error}", exc_info=True) - - def get_config(self, key: str, default: Any = None) -> Any: - """설정값 조회""" - return self.config.get(key, default) - - def update_config(self, config: Dict[str, Any]): - """설정 업데이트""" - self.config.update(config) - logger.info(f"🔧 [{self.name}] 설정 업데이트: {config}") - - def enable(self): - """전략 활성화""" - self.enabled = True - logger.info(f"✅ [{self.name}] 활성화") - - def disable(self): - """전략 비활성화""" - self.enabled = False - logger.info(f"⏸️ [{self.name}] 비활성화") - - def get_last_signal(self, stock_code: str) -> Optional[Signal]: - """종목의 마지막 신호 조회""" - return self.last_signals.get(stock_code) - - def __repr__(self) -> str: - return f"<{self.__class__.__name__} name={self.name} enabled={self.enabled}>" diff --git a/backend/app/strategy/indicators.py b/backend/app/strategy/indicators.py deleted file mode 100644 index bf22e2d..0000000 --- a/backend/app/strategy/indicators.py +++ /dev/null @@ -1,158 +0,0 @@ -""" -Technical Indicators - -보조지표 계산 함수들 -""" -from typing import List, Optional, Tuple -import logging - -logger = logging.getLogger(__name__) - - -def sma(prices: List[float], period: int) -> Optional[float]: - """단순 이동평균 (Simple Moving Average)""" - if len(prices) < period: - return None - return sum(prices[-period:]) / period - - -def ema(prices: List[float], period: int, prev_ema: Optional[float] = None) -> Optional[float]: - """지수 이동평균 (Exponential Moving Average)""" - if len(prices) < period: - return None - - k = 2 / (period + 1) - if prev_ema is None: - # 초기값은 SMA - prev_ema = sum(prices[-period:]) / period - - return prices[-1] * k + prev_ema * (1 - k) - - -def rsi(prices: List[float], period: int = 14) -> Optional[float]: - """ -RSI (Relative Strength Index) - - Args: - prices: 종가 리스트 - period: 기간 (기본 14) - - Returns: - 0~100 사이 RSI 값 - """ - if len(prices) < period + 1: - return None - - gains, losses = [], [] - for i in range(-period, 0): - delta = prices[i] - prices[i - 1] - gains.append(max(delta, 0)) - losses.append(max(-delta, 0)) - - avg_gain = sum(gains) / period - avg_loss = sum(losses) / period - - if avg_loss == 0: - return 100.0 - - rs = avg_gain / avg_loss - return 100 - (100 / (1 + rs)) - - -def bollinger_bands(prices: List[float], period: int = 20, std_mult: float = 2.0) -> Optional[Tuple[float, float, float]]: - """ - 볼린저 밴드 - - Returns: - (upper, middle, lower) 또는 None - """ - if len(prices) < period: - return None - - window = prices[-period:] - middle = sum(window) / period - variance = sum((p - middle) ** 2 for p in window) / period - std = variance ** 0.5 - - upper = middle + std_mult * std - lower = middle - std_mult * std - - return (upper, middle, lower) - - -def macd(prices: List[float], fast: int = 12, slow: int = 26, signal: int = 9) -> Optional[Tuple[float, float, float]]: - """ - MACD (Moving Average Convergence Divergence) - - Returns: - (macd_line, signal_line, histogram) 또는 None - """ - if len(prices) < slow: - return None - - # EMA 계산 (재귀적으로 계산) - def calc_ema(data: List[float], period: int) -> float: - k = 2 / (period + 1) - ema_val = sum(data[:period]) / period - for p in data[period:]: - ema_val = p * k + ema_val * (1 - k) - return ema_val - - ema_fast = calc_ema(prices, fast) - ema_slow = calc_ema(prices, slow) - macd_line = ema_fast - ema_slow - - # Signal line = MACD의 EMA(9) - # 실제로는 과거 MACD 값들 필요 (간략화) - signal_line = macd_line # TODO: 정확한 구현 필요 - histogram = macd_line - signal_line - - return (macd_line, signal_line, histogram) - - -def atr(highs: List[float], lows: List[float], closes: List[float], period: int = 14) -> Optional[float]: - """ - ATR (Average True Range) - - Args: - highs: 고가 리스트 - lows: 저가 리스트 - closes: 종가 리스트 - period: 기간 - - Returns: - ATR 값 - """ - if len(highs) < period + 1 or len(lows) < period + 1 or len(closes) < period + 1: - return None - - trs = [] - for i in range(1, len(highs)): - h = highs[i] - l = lows[i] - cp = closes[i - 1] - tr = max(h - l, abs(h - cp), abs(l - cp)) - trs.append(tr) - - if len(trs) < period: - return None - - return sum(trs[-period:]) / period - - -def vwap(prices: List[float], volumes: List[float]) -> Optional[float]: - """ - VWAP (Volume Weighted Average Price) - - Returns: - VWAP 값 - """ - if len(prices) != len(volumes) or not prices: - return None - - total_volume = sum(volumes) - if total_volume == 0: - return None - - total_pv = sum(p * v for p, v in zip(prices, volumes)) - return total_pv / total_volume diff --git a/backend/app/strategy/scanner.py b/backend/app/strategy/scanner.py deleted file mode 100644 index 25e1d7f..0000000 --- a/backend/app/strategy/scanner.py +++ /dev/null @@ -1,119 +0,0 @@ -""" -Stock Scanner - -시장에서 특정 조건에 맞는 종목을 스캔하는 모듈 -""" -from typing import List, Dict, Any, Optional, Callable -import logging -import asyncio - -from app.trading import market_data_provider - -logger = logging.getLogger(__name__) - - -class Scanner: - """ - 종목 스캔 엔진 - - 특정 조건을 만족하는 종목을 찾는다. - """ - - def __init__(self): - self.filters: List[Callable] = [] - - def add_filter(self, filter_func: Callable[[Dict[str, Any]], bool]): - """ - 필터 추가 - - Args: - filter_func: market_data를 받아 True/False 반환하는 함수 - """ - self.filters.append(filter_func) - - async def scan( - self, - stock_codes: List[str], - max_concurrent: int = 5 - ) -> List[Dict[str, Any]]: - """ - 종목 목록을 스캔하여 조건에 맞는 종목 반환 - - Args: - stock_codes: 스캔할 종목코드 목록 - max_concurrent: 동시 처리 수 - - Returns: - 조건에 맞는 종목 데이터 목록 - """ - semaphore = asyncio.Semaphore(max_concurrent) - - async def scan_one(stock_code: str) -> Optional[Dict[str, Any]]: - async with semaphore: - try: - # 시세 데이터 수집 - price_data = await market_data_provider.get_current_price(stock_code) - - if not price_data or price_data.get("rt_cd") != "0": - return None - - output = price_data.get("output", {}) - market_data = { - "stock_code": stock_code, - "stock_name": output.get("hts_kor_isnm", ""), - "current_price": float(output.get("stck_prpr", 0)), - "change_rate": float(output.get("prdy_ctrt", 0)), - "volume": int(output.get("acml_vol", 0)), - "market_cap": int(output.get("hts_avls", 0)) * 100000000, # 억 → 원 - } - - # 필터 적용 - for filter_func in self.filters: - if not filter_func(market_data): - return None - - return market_data - - except Exception as e: - logger.warning(f"⚠️ 스캔 실패 {stock_code}: {e}") - return None - - tasks = [scan_one(code) for code in stock_codes] - results = await asyncio.gather(*tasks) - - matched = [r for r in results if r is not None] - logger.info(f"🔍 스캔 완료: {len(matched)}/{len(stock_codes)} 종목 매칭") - - return matched - - -# 사전 정의 필터 - -def filter_price_range(min_price: float, max_price: float) -> Callable: - """가격 범위 필터""" - def _filter(data: Dict[str, Any]) -> bool: - price = data.get("current_price", 0) - return min_price <= price <= max_price - return _filter - - -def filter_volume_min(min_volume: int) -> Callable: - """최소 거래량 필터""" - def _filter(data: Dict[str, Any]) -> bool: - return data.get("volume", 0) >= min_volume - return _filter - - -def filter_change_rate(min_rate: float, max_rate: float) -> Callable: - """등락률 범위 필터""" - def _filter(data: Dict[str, Any]) -> bool: - rate = data.get("change_rate", 0) - return min_rate <= rate <= max_rate - return _filter - - -def filter_market_cap_min(min_cap: int) -> Callable: - """최소 시가총액 필터 (원)""" - def _filter(data: Dict[str, Any]) -> bool: - return data.get("market_cap", 0) >= min_cap - return _filter diff --git a/backend/app/strategy/signal.py b/backend/app/strategy/signal.py deleted file mode 100644 index f799556..0000000 --- a/backend/app/strategy/signal.py +++ /dev/null @@ -1,79 +0,0 @@ -""" -Signal Data Class - -전략이 생성하는 매매 신호 데이터 구조 -""" -from enum import Enum -from dataclasses import dataclass -from typing import Optional, Dict, Any -from datetime import datetime - - -class SignalType(Enum): - """신호 타입""" - BUY = "BUY" # 매수 - SELL = "SELL" # 매도 - HOLD = "HOLD" # 보유 - CLOSE = "CLOSE" # 청산 - STOP_LOSS = "STOP_LOSS" # 손절 - TAKE_PROFIT = "TAKE_PROFIT" # 익절 - - -@dataclass -class Signal: - """ - 매매 신호 데이터 클래스 - - 전략이 생성한 매매 신호를 표준화된 형식으로 전달 - """ - # 필수 필드 - stock_code: str # 종목코드 - signal_type: SignalType # 신호 타입 - strategy_name: str # 전략 이름 - timestamp: datetime # 신호 생성 시각 - - # 주문 정보 - quantity: Optional[int] = None # 수량 - price: Optional[float] = None # 가격 (None = 시장가) - confidence: float = 1.0 # 신뢰도 (0.0 ~ 1.0) - - # 손익 관리 - stop_loss_price: Optional[float] = None # 손절가 - take_profit_price: Optional[float] = None # 익절가 - trailing_stop_pct: Optional[float] = None # 트레일링 스탑 % - - # 메타데이터 - reason: Optional[str] = None # 신호 발생 이유 - metadata: Optional[Dict[str, Any]] = None # 추가 데이터 - - def __post_init__(self): - """유효성 검증""" - if self.confidence < 0.0 or self.confidence > 1.0: - raise ValueError(f"Confidence must be 0.0~1.0, got {self.confidence}") - - if isinstance(self.signal_type, str): - self.signal_type = SignalType(self.signal_type) - - def to_dict(self) -> Dict[str, Any]: - """딕셔너리 변환 (DB 저장용)""" - return { - "stock_code": self.stock_code, - "signal_type": self.signal_type.value, - "strategy_name": self.strategy_name, - "timestamp": self.timestamp.isoformat(), - "quantity": self.quantity, - "price": self.price, - "confidence": self.confidence, - "stop_loss_price": self.stop_loss_price, - "take_profit_price": self.take_profit_price, - "trailing_stop_pct": self.trailing_stop_pct, - "reason": self.reason, - "metadata": self.metadata, - } - - def __repr__(self) -> str: - return ( - f"Signal({self.signal_type.value} {self.stock_code} " - f"@{self.price or 'MARKET'} x{self.quantity} " - f"[{self.strategy_name}] conf={self.confidence:.2f})" - ) diff --git a/backend/app/strategy/strategies/__init__.py b/backend/app/strategy/strategies/__init__.py deleted file mode 100644 index 8a06e69..0000000 --- a/backend/app/strategy/strategies/__init__.py +++ /dev/null @@ -1,11 +0,0 @@ -""" -Trading Strategies - -구체적인 매매 전략 구현체 -""" - -from app.strategy.strategies.day_trading import DayTradingStrategy - -__all__ = [ - "DayTradingStrategy", -] diff --git a/backend/app/strategy/strategies/day_trading.py b/backend/app/strategy/strategies/day_trading.py deleted file mode 100644 index d40b42a..0000000 --- a/backend/app/strategy/strategies/day_trading.py +++ /dev/null @@ -1,181 +0,0 @@ -""" -Day Trading Strategy (Refactored) - -기존 day_trading_strategy.py를 BaseStrategy 구조로 변환 - -전략: - - ORB (Opening Range Breakout) - - VWAP Bounce - - RSI+BB 과매도 반등 -""" -import logging -from typing import Optional, Dict, Any -from datetime import datetime, time -import pytz - -from app.strategy.base_strategy import BaseStrategy -from app.strategy.signal import Signal, SignalType -from app.core.config import settings - -logger = logging.getLogger(__name__) - - -class DayTradingStrategy(BaseStrategy): - """ - 데이트레이딩 전략 (단기 스칼핑) - - 09:00~15:20 간 급등/급락 모멘텀 포착 - """ - - DEFAULT_CONFIG = { - "max_positions": 3, - "max_capital_ratio": 0.1, # 종목당 10% - "orb_breakout_margin": 0.002, # 0.2% 돌파 - "stop_loss_pct": 0.025, # -2.5% - "entry_cutoff": "14:30", # 신규 진입 마감 - "eod_flat": "15:20", # EOD 청산 - } - - def __init__(self, name: str = "DayTrading", config: Optional[Dict[str, Any]] = None): - merged_config = {**self.DEFAULT_CONFIG, **(config or {})} - super().__init__(name, merged_config) - - self.kst = pytz.timezone("Asia/Seoul") - self.entry_cutoff = self._parse_time(self.config["entry_cutoff"]) - self.eod_flat = self._parse_time(self.config["eod_flat"]) - - def _parse_time(self, time_str: str) -> time: - """문자열 "HH:MM" → time 객체""" - h, m = map(int, time_str.split(":")) - return time(h, m) - - async def analyze( - self, - stock_code: str, - market_data: Dict[str, Any], - portfolio_data: Optional[Dict[str, Any]] = None - ) -> Optional[Signal]: - """ - 시장 데이터 분석 - - Args: - market_data: { - "current_price": float, - "minute_chart": List[dict], # 1분봉 - "orderbook": dict, - "stock_name": str, - } - portfolio_data: { - "holding_quantity": int, - "avg_price": float, - } - """ - current_time = datetime.now(self.kst).time() - - # 1. 시간 체크 - if current_time >= self.eod_flat: - # EOD 청산 신호 - if portfolio_data and portfolio_data.get("holding_quantity", 0) > 0: - return Signal( - stock_code=stock_code, - signal_type=SignalType.SELL, - strategy_name=self.name, - timestamp=datetime.now(self.kst), - quantity=portfolio_data["holding_quantity"], - price=None, # 시장가 - confidence=1.0, - reason="EOD 강제 청산" - ) - return None - - if current_time >= self.entry_cutoff: - # 신규 진입 차단 - return None - - # 2. 현재 보유 중이면 청산 조건 체크 - if portfolio_data and portfolio_data.get("holding_quantity", 0) > 0: - exit_signal = self._check_exit( - stock_code, - market_data, - portfolio_data - ) - if exit_signal: - return exit_signal - return None # 보유 유지 - - # 3. 신규 진입 검토 - return self._check_entry(stock_code, market_data) - - def _check_entry(self, stock_code: str, market_data: Dict[str, Any]) -> Optional[Signal]: - """신규 진입 신호 체크""" - current_price = market_data.get("current_price") - minute_chart = market_data.get("minute_chart", []) - - if not current_price or not minute_chart: - return None - - # 예시: ORB 전략 (간단화) - # 실제는 기존 check_orb_signal() 등을 호출 - - # 임시 구현: 단순 예시 - if len(minute_chart) >= 30: # 09:00~09:30 데이터 있음 - orb_high = max(c.get("high", 0) for c in minute_chart[:30]) - - if current_price > orb_high * (1 + self.config["orb_breakout_margin"]): - stop_loss = current_price * (1 - self.config["stop_loss_pct"]) - target = current_price * 1.03 # +3% 목표 - - return Signal( - stock_code=stock_code, - signal_type=SignalType.BUY, - strategy_name=self.name, - timestamp=datetime.now(self.kst), - quantity=None, # OrderManager가 계산 - price=current_price, - confidence=0.75, - stop_loss_price=stop_loss, - take_profit_price=target, - reason=f"ORB 돌파: {orb_high:.0f} → {current_price:.0f}", - metadata={"strategy_type": "ORB"} - ) - - return None - - def _check_exit(self, stock_code: str, market_data: Dict[str, Any], portfolio_data: Dict[str, Any]) -> Optional[Signal]: - """청산 신호 체크""" - current_price = market_data.get("current_price") - avg_price = portfolio_data.get("avg_price") - quantity = portfolio_data.get("holding_quantity") - - if not current_price or not avg_price or not quantity: - return None - - # 손절 체크 - stop_loss = avg_price * (1 - self.config["stop_loss_pct"]) - if current_price <= stop_loss: - return Signal( - stock_code=stock_code, - signal_type=SignalType.STOP_LOSS, - strategy_name=self.name, - timestamp=datetime.now(self.kst), - quantity=quantity, - price=None, # 시장가 - confidence=1.0, - reason=f"손절: {current_price:.0f} ≤ {stop_loss:.0f}" - ) - - # 익절 체크 - target = avg_price * 1.03 - if current_price >= target: - return Signal( - stock_code=stock_code, - signal_type=SignalType.TAKE_PROFIT, - strategy_name=self.name, - timestamp=datetime.now(self.kst), - quantity=quantity, - price=None, - confidence=1.0, - reason=f"목표가 도달: {current_price:.0f} ≥ {target:.0f}" - ) - - return None diff --git a/backend/app/strategy/strategy_manager.py b/backend/app/strategy/strategy_manager.py deleted file mode 100644 index 96baa2d..0000000 --- a/backend/app/strategy/strategy_manager.py +++ /dev/null @@ -1,254 +0,0 @@ -""" -Strategy Manager - -전략 등록·실행·관리를 담당하는 오케스트레이터 -""" -from typing import Dict, List, Optional, Any -from collections import deque -import logging -import asyncio -from datetime import datetime - -from app.strategy.base_strategy import BaseStrategy -from app.strategy.signal import Signal, SignalType - -logger = logging.getLogger(__name__) - - -class StrategyManager: - """ - 전략 관리자 - - - 전략 등록/삭제 - - 복수 전략 동시 실행 - - 신호 충돌 해결 - - 전략 우선순위 관리 - """ - - def __init__(self): - self.strategies: Dict[str, BaseStrategy] = {} # 전략 등록부 - self.priority_order: List[str] = [] # 우선순위 - self.signal_history: deque = deque(maxlen=1000) # 신호 이력 (최대 1000건, 메모리 누수 방지) - - logger.info("🎯 StrategyManager 초기화") - - def register_strategy(self, strategy: BaseStrategy, priority: int = 100): - """ - 전략 등록 - - Args: - strategy: 등록할 전략 인스턴스 - priority: 우선순위 (낮을수록 먼저 실행) - """ - if strategy.name in self.strategies: - logger.warning(f"⚠️ 전략 이미 등록됨: {strategy.name}") - return - - self.strategies[strategy.name] = strategy - - # 우선순위 삽입 - inserted = False - for i, name in enumerate(self.priority_order): - existing_strategy = self.strategies[name] - existing_priority = getattr(existing_strategy, '_priority', 100) - if priority < existing_priority: - self.priority_order.insert(i, strategy.name) - inserted = True - break - - if not inserted: - self.priority_order.append(strategy.name) - - strategy._priority = priority - logger.info(f"✅ 전략 등록: {strategy.name} (priority={priority})") - - def unregister_strategy(self, strategy_name: str): - """전략 등록 해제""" - if strategy_name in self.strategies: - del self.strategies[strategy_name] - self.priority_order.remove(strategy_name) - logger.info(f"❌ 전략 삭제: {strategy_name}") - - def get_strategy(self, strategy_name: str) -> Optional[BaseStrategy]: - """전략 조회""" - return self.strategies.get(strategy_name) - - def list_strategies(self) -> List[str]: - """등록된 전략 목록""" - return list(self.strategies.keys()) - - async def run_strategy( - self, - strategy_name: str, - stock_code: str, - market_data: Dict[str, Any], - portfolio_data: Optional[Dict[str, Any]] = None - ) -> Optional[Signal]: - """ - 특정 전략 단독 실행 - - Args: - strategy_name: 실행할 전략 이름 - stock_code: 종목코드 - market_data: 시장 데이터 - portfolio_data: 포트폴리오 데이터 - - Returns: - Signal 또는 None - """ - strategy = self.strategies.get(strategy_name) - if not strategy: - logger.error(f"❌ 전략 찾을 수 없음: {strategy_name}") - return None - - if not strategy.enabled: - logger.debug(f"⏸️ 전략 비활성화됨: {strategy_name}") - return None - - try: - signal = await strategy.analyze(stock_code, market_data, portfolio_data) - - if signal: - self.signal_history.append(signal) - logger.info(f"📡 신호 생성: {signal}") - - return signal - - except Exception as e: - logger.error(f"❌ [{strategy_name}] 실행 중 오류: {e}", exc_info=True) - await strategy.on_error(e, {"stock_code": stock_code}) - return None - - async def run_all_strategies( - self, - stock_code: str, - market_data: Dict[str, Any], - portfolio_data: Optional[Dict[str, Any]] = None, - parallel: bool = False - ) -> List[Signal]: - """ - 모든 전략 동시 실행 - - Args: - stock_code: 종목코드 - market_data: 시장 데이터 - portfolio_data: 포트폴리오 데이터 - parallel: True = 병렬 실행, False = 순차 실행 - - Returns: - 생성된 Signal 목록 - """ - signals = [] - - if parallel: - # 병렬 실행 - tasks = [ - self.run_strategy(name, stock_code, market_data, portfolio_data) - for name in self.priority_order - ] - results = await asyncio.gather(*tasks, return_exceptions=True) - signals = [s for s in results if isinstance(s, Signal)] - else: - # 순차 실행 (우선순위) - for strategy_name in self.priority_order: - signal = await self.run_strategy( - strategy_name, stock_code, market_data, portfolio_data - ) - if signal: - signals.append(signal) - - return signals - - def resolve_conflicts( - self, - signals: List[Signal], - method: str = "highest_confidence" - ) -> Optional[Signal]: - """ - 신호 충돌 해결 - - Args: - signals: 신호 목록 - method: 해결 방법 - - "highest_confidence": 가장 높은 신뢰도 - - "priority": 전략 우선순위 - - "majority_vote": 다수결 - - Returns: - 최종 선택된 Signal - """ - if not signals: - return None - - if len(signals) == 1: - return signals[0] - - if method == "highest_confidence": - return max(signals, key=lambda s: s.confidence) - - elif method == "priority": - # 우선순위 높은 전략의 신호 선택 - for strategy_name in self.priority_order: - for signal in signals: - if signal.strategy_name == strategy_name: - return signal - return signals[0] - - elif method == "majority_vote": - # 가장 많이 나온 신호 타입 선택 - from collections import Counter - signal_types = [s.signal_type for s in signals] - most_common = Counter(signal_types).most_common(1)[0][0] - - # 해당 타입 중 가장 높은 신뢰도 - matching_signals = [s for s in signals if s.signal_type == most_common] - return max(matching_signals, key=lambda s: s.confidence) - - return signals[0] - - def get_signal_history( - self, - stock_code: Optional[str] = None, - strategy_name: Optional[str] = None, - limit: int = 100 - ) -> List[Signal]: - """ - 신호 이력 조회 - - Args: - stock_code: 종목코드 필터 - strategy_name: 전략 이름 필터 - limit: 최대 개수 - - Returns: - Signal 목록 - """ - history = list(self.signal_history) - - if stock_code: - history = [s for s in history if s.stock_code == stock_code] - - if strategy_name: - history = [s for s in history if s.strategy_name == strategy_name] - - return history[-limit:] - - def get_stats(self) -> Dict[str, Any]: - """전략 통계 정보""" - return { - "total_strategies": len(self.strategies), - "enabled_strategies": sum(1 for s in self.strategies.values() if s.enabled), - "total_signals": len(self.signal_history), - "strategies": { - name: { - "enabled": strategy.enabled, - "priority": getattr(strategy, '_priority', 100), - "signals_count": len([s for s in self.signal_history if s.strategy_name == name]) - } - for name, strategy in self.strategies.items() - } - } - - -# 싱글톤 -strategy_manager = StrategyManager() diff --git a/backend/app/trading/README.md b/backend/app/trading/README.md deleted file mode 100644 index 316ecdf..0000000 --- a/backend/app/trading/README.md +++ /dev/null @@ -1,173 +0,0 @@ -# Trading Module - -한국투자증권 API를 활용한 주식 매매 실행 모듈 - -## 모듈 구조 - -``` -trading/ -├── __init__.py # 모듈 내보내기 -├── kis_client.py # KIS REST API 클라이언트 (토큰 관리) -├── order_manager.py # 주문 실행·취소·조회 -├── market_data.py # 시세·차트 조회 (Redis 캐시) -└── README.md # 문서 -``` - -## 사용 방법 - -### 1. KIS API 클라이언트 - -```python -from app.trading import kis_client - -# 토큰 자동 관리 (파일 캐싱) -token = await kis_client.get_access_token() - -# REST API 요청 (재시도 + 쓰로틀링 자동) -result, headers = await kis_client.request( - "GET", - "/uapi/domestic-stock/v1/quotations/inquire-price", - "FHKST01010100", - params={"FID_INPUT_ISCD": "005930"} -) -``` - -### 2. 주문 관리 - -```python -from app.trading import order_manager - -# 매수 주문 -result = await order_manager.place_order( - stock_code="005930", - order_type="BUY", - quantity=10, - price=75000 # None이면 시장가 -) - -# 주문 취소 -await order_manager.cancel_order( - order_id="0000123456", - quantity=10 -) - -# 계좌 잔고 조회 -balance = await order_manager.get_balance() -print(balance['output2']) # 보유 종목 목록 -``` - -### 3. 시세 데이터 - -```python -from app.trading import market_data_provider - -# 현재가 조회 -price = await market_data_provider.get_current_price("005930") -print(price['output']['stck_prpr']) # 현재가 - -# 일봉 차트 (Redis 캐시 5분) -chart = await market_data_provider.get_daily_chart( - stock_code="005930", - period="D", # D: 일봉, W: 주봉, M: 월봉 - limit=100 -) -for candle in chart['output2']: - print(candle['stck_clpr']) # 종가 - -# 분봉 차트 (Redis 캐시 1분) -minute_chart = await market_data_provider.get_minute_chart( - stock_code="005930", - ref_time="153000", # 15:30:00 기준 - include_past=True -) - -# 호가 조회 -orderbook = await market_data_provider.get_orderbook("005930") -print(orderbook['output1']['askp1']) # 매도 1호가 -print(orderbook['output1']['bidp1']) # 매수 1호가 -``` - -## 기존 코드 호환성 - -기존 `app.services.korea_investment_api` 사용 코드는 **수정 없이 동작**합니다: - -```python -# 기존 방식 (DEPRECATED, 하지만 여전히 동작) -from app.services.korea_investment_api import korea_investment_api - -result = await korea_investment_api.get_current_price("005930") -await korea_investment_api.place_order("005930", "BUY", 10, 75000) - -# 새 방식 (권장) -from app.trading import market_data_provider, order_manager - -result = await market_data_provider.get_current_price("005930") -await order_manager.place_order("005930", "BUY", 10, 75000) -``` - -→ `app.services.korea_investment_api_adapter.py`가 내부적으로 새 모듈로 위임하므로 기존 코드 수정 불필요 - -## 주요 기능 - -### KISClient -- ✅ 토큰 자동 관리 (파일 캐싱 23시간) -- ✅ 재시도 로직 (지수 백오프) -- ✅ 쓰로틀링 (초당 2회 기본) -- ✅ 동시 요청 수 제한 (Semaphore) -- ✅ 타임아웃 처리 - -### OrderManager -- ✅ 국내주식 매수/매도 -- ✅ 해외주식 매수/매도 -- ✅ 주문 취소 -- ✅ 계좌 잔고 조회 -- ✅ 실전/모의 자동 구분 - -### MarketDataProvider -- ✅ 현재가 조회 -- ✅ 일봉/주봉/월봉 차트 (Redis 캐시 5분) -- ✅ 분봉 차트 (Redis 캐시 1분) -- ✅ 호가 조회 -- ✅ 거래량 순위 -- ✅ 해외주식 시세 -- ✅ 페이지네이션 지원 - -## 설정 - -`backend/app/core/config.py`에서 설정 가능: - -```python -class Settings(BaseSettings): - # KIS API 설정 - KOREA_INVESTMENT_API_BASE_URL: str - KOREA_INVESTMENT_APP_KEY: str - KOREA_INVESTMENT_APP_SECRET: str - KOREA_INVESTMENT_ACCOUNT_NUMBER: str - - # 쓰로틀링 설정 - API_MIN_REQUEST_INTERVAL: float = 0.5 # 초당 2회 - API_MAX_CONCURRENT_REQUESTS: int = 5 - API_MAX_RETRIES: int = 3 - API_RETRY_DELAY: float = 1.5 - API_RETRY_BACKOFF: float = 2.0 - API_TIMEOUT: float = 30.0 -``` - -## Phase 1 리팩토링 완료 체크리스트 - -- [x] `trading/kis_client.py` 생성 (토큰 관리 + HTTP 요청) -- [x] `trading/order_manager.py` 생성 (주문 전담) -- [x] `trading/market_data.py` 생성 (시세 전담) -- [x] `services/korea_investment_api_adapter.py` 생성 (호환성) -- [x] 기존 API 엔드포인트 정상 동작 확인 필요 - -## 다음 단계 (Phase 2) - -- [ ] `strategy/` 모듈 구축 -- [ ] `BaseStrategy` 추상 클래스 -- [ ] 기존 전략 리팩토링 - ---- - -**작성일**: 2026-03-06 -**작성자**: 서영우 diff --git a/backend/app/trading/__init__.py b/backend/app/trading/__init__.py deleted file mode 100644 index adc09b4..0000000 --- a/backend/app/trading/__init__.py +++ /dev/null @@ -1,26 +0,0 @@ -""" -Trading Engine Module - -주식 매매 실행을 담당하는 핵심 모듈 -- KIS API 연동 (REST + WebSocket) -- 주문 실행·취소·조회 -- 실시간 시세 수신 -- 장 스케줄 관리 -""" - -from app.trading.kis_client import KISClient, kis_client, real_market_client -from app.trading.order_manager import OrderManager, order_manager -from app.trading.market_data import MarketDataProvider, market_data_provider - -__all__ = [ - # Classes - "KISClient", - "OrderManager", - "MarketDataProvider", - - # Singleton instances - "kis_client", - "real_market_client", - "order_manager", - "market_data_provider", -] diff --git a/backend/app/trading/kis_client.py b/backend/app/trading/kis_client.py deleted file mode 100644 index f237f90..0000000 --- a/backend/app/trading/kis_client.py +++ /dev/null @@ -1,233 +0,0 @@ -""" -KIS REST API Client -한국투자증권 REST API 핵심 클라이언트 (토큰 관리 + HTTP 요청) -""" -import httpx -from typing import Dict, Any, Optional, Tuple -from datetime import datetime, timedelta -import logging -import asyncio -import time -import json -import os -from pathlib import Path - -from app.core.config import settings - -logger = logging.getLogger(__name__) - - -class KISClient: - """한국투자증권 REST API 클라이언트 (토큰 관리 + 안전한 HTTP 요청)""" - - TOKEN_CACHE_FILE = Path.home() / ".cache" / "auto-stock" / "kis_token_cache.json" - - @classmethod - def for_real_market_data(cls) -> "KISClient": - """실계좌 자격증명으로 시세조회 전용 인스턴스 생성""" - if not settings.KIS_REAL_APP_KEY: - raise ValueError("KIS_REAL_APP_KEY가 설정되지 않음") - - instance = cls.__new__(cls) - instance.base_url = settings.KIS_REAL_URL - instance.app_key = settings.KIS_REAL_APP_KEY - instance.app_secret = settings.KIS_REAL_APP_SECRET - instance.account_number = settings.KIS_REAL_ACCOUNT_NO - instance.TOKEN_CACHE_FILE = Path.home() / ".cache" / "auto-stock" / "kis_token_cache_real.json" - instance._init_common() - logger.info(f"🔧 KIS 실계좌 시세조회 API: {instance.base_url}") - return instance - - def __init__(self): - self.base_url = settings.KOREA_INVESTMENT_API_BASE_URL - self.app_key = settings.KOREA_INVESTMENT_APP_KEY - self.app_secret = settings.KOREA_INVESTMENT_APP_SECRET - self.account_number = settings.KOREA_INVESTMENT_ACCOUNT_NUMBER - self._init_common() - logger.info(f"🔧 KIS API 초기화: mode={settings.KIS_MODE}, {self.base_url}") - - def _init_common(self): - """공통 초기화 로직""" - self.access_token: Optional[str] = None - self.token_expires_at: Optional[datetime] = None - self.last_request_time: Optional[float] = None - - # 설정 기반 쓰로틀링 - self.min_request_interval = settings.API_MIN_REQUEST_INTERVAL - self.max_retries = settings.API_MAX_RETRIES - self.retry_delay = settings.API_RETRY_DELAY - self.retry_backoff = settings.API_RETRY_BACKOFF - self.timeout = settings.API_TIMEOUT - - # 동시 요청 제한 - self.semaphore = asyncio.Semaphore(settings.API_MAX_CONCURRENT_REQUESTS) - self._token_lock = asyncio.Lock() - - # 캐시된 토큰 로드 - self._load_token_from_cache() - - def _load_token_from_cache(self): - """파일에서 토큰 로드""" - try: - cache_file = Path(self.TOKEN_CACHE_FILE) - if cache_file.exists(): - with open(cache_file, 'r') as f: - cache = json.load(f) - self.access_token = cache.get('access_token') - expires_str = cache.get('expires_at') - if expires_str: - self.token_expires_at = datetime.fromisoformat(expires_str) - if self.access_token and datetime.now() < self.token_expires_at: - remaining = (self.token_expires_at - datetime.now()).total_seconds() / 3600 - logger.info(f"💾 캐시 토큰 로드 (남은 시간: {remaining:.1f}시간)") - except Exception as e: - logger.warning(f"⚠️ 토큰 캐시 로드 실패: {e}") - - async def _save_token_to_cache(self): - """토큰을 파일에 비동기 저장""" - try: - cache_file = Path(self.TOKEN_CACHE_FILE) - cache_file.parent.mkdir(parents=True, exist_ok=True) - cache = { - 'access_token': self.access_token, - 'expires_at': self.token_expires_at.isoformat() if self.token_expires_at else None, - } - await asyncio.to_thread(self._write_token_file, cache_file, cache) - except Exception as e: - logger.warning(f"⚠️ 토큰 캐시 저장 실패: {e}") - - def _write_token_file(self, cache_file: Path, cache: dict): - """동기 파일 쓰기""" - with open(cache_file, 'w') as f: - json.dump(cache, f) - os.chmod(cache_file, 0o600) - - async def get_access_token(self) -> str: - """접근 토큰 발급 (Lock으로 중복 방지)""" - if self.access_token and self.token_expires_at and datetime.now() < self.token_expires_at: - return self.access_token - - async with self._token_lock: - if self.access_token and self.token_expires_at and datetime.now() < self.token_expires_at: - return self.access_token - - logger.info("🔑 새 토큰 발급 요청") - - async with httpx.AsyncClient() as client: - response = await client.post( - f"{self.base_url}/oauth2/tokenP", - json={ - "grant_type": "client_credentials", - "appkey": self.app_key, - "appsecret": self.app_secret - }, - timeout=30.0 - ) - - if response.status_code == 200: - data = response.json() - self.access_token = data["access_token"] - self.token_expires_at = datetime.now() + timedelta(hours=23) - await self._save_token_to_cache() - logger.info(f"✅ 토큰 발급 완료") - return self.access_token - else: - raise Exception(f"토큰 발급 실패: {response.status_code} - {response.text}") - - async def request( - self, - method: str, - endpoint: str, - tr_id: str, - extra_headers: Optional[Dict[str, str]] = None, - **kwargs - ) -> Tuple[Dict[str, Any], Optional[Any]]: - """API 요청 (재시도 + 쓰로틀링)""" - async with self.semaphore: - return await self._request_with_retry(method, endpoint, tr_id, extra_headers, **kwargs) - - async def _request_with_retry( - self, - method: str, - endpoint: str, - tr_id: str, - extra_headers: Optional[Dict[str, str]], - **kwargs - ) -> Tuple[Dict[str, Any], Optional[Any]]: - """재시도 로직 포함 실제 요청""" - start_time = time.time() - - for attempt in range(self.max_retries + 1): - try: - # 쓰로틀링 - if self.last_request_time: - elapsed = time.time() - self.last_request_time - if elapsed < self.min_request_interval: - await asyncio.sleep(self.min_request_interval - elapsed) - - self.last_request_time = time.time() - - token = await self.get_access_token() - - headers = { - "content-type": "application/json; charset=utf-8", - "authorization": f"Bearer {token}", - "appkey": self.app_key, - "appsecret": self.app_secret, - "tr_id": tr_id, - } - if extra_headers: - headers.update(extra_headers) - - timeout = httpx.Timeout(self.timeout, connect=10.0) - - async with httpx.AsyncClient(timeout=timeout) as client: - response = await client.request( - method, - f"{self.base_url}{endpoint}", - headers=headers, - **kwargs - ) - - if response.status_code == 200: - return response.json(), response.headers - - # 재시도 대상 (429, 5xx) - elif response.status_code in [429, 500, 502, 503, 504]: - if attempt < self.max_retries: - delay = self.retry_delay * (self.retry_backoff ** attempt) - logger.warning(f"⏳ 재시도 {attempt + 1}/{self.max_retries}: {delay:.1f}초 대기") - await asyncio.sleep(delay) - continue - else: - raise Exception(f"최대 재시도 초과: {response.status_code}") - - # 4xx - 재시도 불필요 - elif 400 <= response.status_code < 500: - try: - return response.json(), response.headers - except Exception: - return {"rt_cd": str(response.status_code), "msg1": response.text}, None - - else: - raise Exception(f"API 실패: {response.status_code} - {response.text}") - - except httpx.TimeoutException: - if attempt < self.max_retries: - delay = self.retry_delay * (self.retry_backoff ** attempt) - logger.warning(f"⏳ 타임아웃 재시도 {attempt + 1}/{self.max_retries}") - await asyncio.sleep(delay) - continue - else: - raise - - raise Exception("API 요청 실패") - - -# 싱글톤 인스턴스 -kis_client = KISClient() - -try: - real_market_client = KISClient.for_real_market_data() -except Exception: - real_market_client = None diff --git a/backend/app/trading/market_data.py b/backend/app/trading/market_data.py deleted file mode 100644 index 7bf2491..0000000 --- a/backend/app/trading/market_data.py +++ /dev/null @@ -1,247 +0,0 @@ -""" -Market Data Module -시세·차트 조회 전담 모듈 (Redis 캐시 적용) -""" -from typing import Dict, Any, Optional, List -import logging -import asyncio -from datetime import datetime, timedelta -import pytz - -from app.trading.kis_client import kis_client, real_market_client - -logger = logging.getLogger(__name__) - -# 차트 캐시 TTL 상수 -TTL_DAILY_CHART = 300 -TTL_MINUTE_CHART = 60 -TTL_MINUTE_FULLDAY = 60 -TTL_OVERSEAS_CHART = 300 - - -async def _cache_get(key: str) -> Optional[Any]: - """ -Redis 캐시에서 값 조회 (non-blocking) - """ - try: - from app.core.redis import get_json - return await get_json(key) - except Exception: - return None - - -async def _cache_set(key: str, ttl: int, data: Any) -> None: - """Redis 캐시에 값 저장 (non-blocking)""" - try: - from app.core.redis import setex_json - await setex_json(key, ttl, data) - except Exception: - pass - - -class MarketDataProvider: - """시세 및 차트 데이터 제공자""" - - def __init__(self, client=None, real_client=None): - self.client = client or kis_client - self.real_client = real_client or real_market_client - - async def get_current_price(self, stock_code: str) -> Dict[str, Any]: - """현재가 조회""" - try: - client = self.real_client or self.client - result, _ = await client.request( - "GET", - "/uapi/domestic-stock/v1/quotations/inquire-price", - "FHKST01010100", - params={ - "FID_COND_MRKT_DIV_CODE": "J", - "FID_INPUT_ISCD": stock_code - } - ) - return result - except Exception as e: - logger.error(f"❌ 현재가 조회 실패 {stock_code}: {e}") - raise - - async def get_daily_chart( - self, - stock_code: str, - period: str = "D", - limit: int = 240 - ) -> Dict[str, Any]: - """일봉/주봉/월봉 조회 (Redis 캐시, 페이지네이션)""" - cache_key = f"chart:daily:{stock_code}:{period}:{limit}" - - # 캐시 히트 - cached = await _cache_get(cache_key) - if cached is not None: - logger.debug(f"📦 [캐시 히트] {cache_key}") - return cached - - # KIS API 호출 - all_output2 = [] - params = { - "FID_COND_MRKT_DIV_CODE": "J", - "FID_INPUT_ISCD": stock_code, - "FID_PERIOD_DIV_CODE": period, - "FID_ORG_ADJ_PRC": "0" - } - - try: - client = self.real_client or self.client - result, headers = await client.request( - "GET", - "/uapi/domestic-stock/v1/quotations/inquire-daily-price", - "FHKST01010400", - params=params - ) - - data_key = 'output2' if isinstance(result.get('output2'), list) else 'output' - if not result or result.get('rt_cd') != '0' or not result.get(data_key): - return result - - all_output2.extend(result.get(data_key, [])) - tr_cont = headers.get('tr_cont') - - # 연속 조회 - while tr_cont in ('F', 'M') and len(all_output2) < limit: - await asyncio.sleep(self.client.min_request_interval + 0.1) - extra_headers = {'tr_cont': 'Y'} - - result_cont, headers_cont = await client.request( - "GET", - "/uapi/domestic-stock/v1/quotations/inquire-daily-price", - "FHKST01010400", - params=params, - extra_headers=extra_headers - ) - - if result_cont and result_cont.get('rt_cd') == '0': - cont_key = 'output2' if isinstance(result_cont.get('output2'), list) else 'output' - all_output2.extend(result_cont.get(cont_key, [])) - - tr_cont = headers_cont.get('tr_cont') if headers_cont else None - - final_result = result - final_result['output2'] = all_output2 - logger.info(f"✅ 차트 데이터 {len(all_output2)}개 수집: {stock_code}") - - # 캐시 저장 - await _cache_set(cache_key, TTL_DAILY_CHART, final_result) - return final_result - - except Exception as e: - logger.error(f"❌ 차트 조회 실패 {stock_code}: {e}") - raise - - async def get_minute_chart( - self, - stock_code: str, - ref_time: str = "", - include_past: bool = True, - ) -> Dict[str, Any]: - """국내주식 분봉차트 (Redis 캐시)""" - try: - if not ref_time: - kst = pytz.timezone("Asia/Seoul") - ref_time = datetime.now(kst).strftime("%H%M%S") - - ref_bucket = ref_time[:4] # HHMM - cache_key = f"chart:minute:{stock_code}:{ref_bucket}" - - # 캐시 히트 - cached = await _cache_get(cache_key) - if cached is not None: - logger.debug(f"📦 [캐시 히트] {cache_key}") - return cached - - # KIS API 호출 - client = self.real_client or self.client - params = { - "FID_ETC_CLS_CODE": "", - "FID_INPUT_ISCD": stock_code, - "FID_INPUT_HOUR_1": ref_time, - "FID_PW_DATA_INCU_YN": "Y" if include_past else "N", - } - result, _ = await client.request( - "GET", - "/uapi/domestic-stock/v1/quotations/inquire-time-itemchartprice", - "FHKST03010200", - params=params, - ) - - # 캐시 저장 - await _cache_set(cache_key, TTL_MINUTE_CHART, result) - return result - except Exception as e: - logger.error(f"❌ 분봉 차트 실패 {stock_code}: {e}") - raise - - async def get_overseas_price(self, symbol: str, exchange_code: str = "NASD") -> Dict[str, Any]: - """해외주식 현재가""" - try: - client = self.real_client or self.client - result, _ = await client.request( - "GET", - "/uapi/overseas-price/v1/quotations/price", - "HHDFS00000300", - params={ - "AUTH": "", - "EXCD": exchange_code.upper(), - "SYMB": symbol.upper() - } - ) - return result - except Exception as e: - logger.error(f"❌ 해외주식 시세 실패 {symbol}: {e}") - raise - - async def get_orderbook(self, stock_code: str) -> Dict[str, Any]: - """호가 정보 조회 (10단계)""" - try: - client = self.real_client or self.client - result, _ = await client.request( - "GET", - "/uapi/domestic-stock/v1/quotations/inquire-asking-price-exp-ccn", - "FHKST01010200", - params={ - "FID_COND_MRKT_DIV_CODE": "J", - "FID_INPUT_ISCD": stock_code - } - ) - return result - except Exception as e: - logger.error(f"❌ 호가 조회 실패 {stock_code}: {e}") - raise - - async def get_volume_rank(self, market: str = "J") -> Dict[str, Any]: - """거래량 순위 조회""" - try: - client = self.real_client or self.client - result, _ = await client.request( - "GET", - "/uapi/domestic-stock/v1/quotations/volume-rank", - "FHPST01710000", - params={ - "FID_COND_MRKT_DIV_CODE": market, - "FID_COND_SCR_DIV_CODE": "20171", - "FID_INPUT_ISCD": "0000", - "FID_DIV_CLS_CODE": "0", - "FID_BLNG_CLS_CODE": "0", - "FID_TRGT_CLS_CODE": "111111111", - "FID_TRGT_EXLS_CLS_CODE": "000000", - "FID_INPUT_PRICE_1": "5000", - "FID_INPUT_PRICE_2": "", - "FID_VOL_CNT": "", - "FID_INPUT_DATE_1": "" - } - ) - return result - except Exception as e: - logger.error(f"❌ 거래량 순위 실패: {e}") - raise - - -# 싱글톤 -market_data_provider = MarketDataProvider() diff --git a/backend/app/trading/order_manager.py b/backend/app/trading/order_manager.py deleted file mode 100644 index e1920e9..0000000 --- a/backend/app/trading/order_manager.py +++ /dev/null @@ -1,194 +0,0 @@ -""" -Order Manager -주문 실행·취소·조회 전담 모듈 -""" -from typing import Dict, Any, Optional -import logging - -from app.trading.kis_client import kis_client - -logger = logging.getLogger(__name__) - - -class OrderManager: - """주문 실행 관리자""" - - def __init__(self, client=None): - self.client = client or kis_client - - async def place_order( - self, - stock_code: str, - order_type: str, # "BUY" or "SELL" - quantity: int, - price: Optional[float] = None # None = 시장가 - ) -> Dict[str, Any]: - """주문 실행""" - try: - account_number_prefix = self.client.account_number[:8] - account_number_suffix = self.client.account_number[8:] - - is_virtual = "vts" in self.client.base_url.lower() - - if order_type == "BUY": - tr_id = "VTTC0802U" if is_virtual else "TTTC0802U" - else: - tr_id = "VTTC0801U" if is_virtual else "TTTC0801U" - - order_dvsn = "01" if price is None else "00" - order_price = "0" if price is None else str(int(price)) - - logger.info(f"📤 주문: {order_type} {stock_code} {quantity}주 @{order_price}") - - result, _ = await self.client.request( - "POST", - "/uapi/domestic-stock/v1/trading/order-cash", - tr_id, - json={ - "CANO": account_number_prefix, - "ACNT_PRDT_CD": account_number_suffix, - "PDNO": stock_code, - "ORD_DVSN": order_dvsn, - "ORD_QTY": str(quantity), - "ORD_UNPR": order_price - } - ) - - logger.info(f"📥 주문 응답: rt_cd={result.get('rt_cd')}, odno={result.get('odno')}") - return result - except Exception as e: - logger.error(f"❌ 주문 실패: {e}") - return {"rt_cd": "-1", "msg1": str(e), "odno": None} - - async def cancel_order(self, order_id: str, quantity: int) -> Dict[str, Any]: - """주문 취소""" - try: - account_number_prefix = self.client.account_number[:8] - account_number_suffix = self.client.account_number[8:] - - is_virtual = "vts" in self.client.base_url.lower() - tr_id = "VTTC0803U" if is_virtual else "TTTC0803U" - - result, _ = await self.client.request( - "POST", - "/uapi/domestic-stock/v1/trading/order-rvsecncl", - tr_id, - json={ - "CANO": account_number_prefix, - "ACNT_PRDT_CD": account_number_suffix, - "KRX_FWDG_ORD_ORGNO": "", - "ORGN_ODNO": order_id, - "ORD_DVSN": "00", - "RVSE_CNCL_DVSN_CD": "02", - "ORD_QTY": "0", - "ORD_UNPR": "0", - "QTY_ALL_ORD_YN": "Y" - } - ) - return result - except Exception as e: - logger.error(f"❌ 취소 실패: {e}") - raise - - async def get_balance(self) -> Dict[str, Any]: - """계좌 잔고 조회""" - try: - account_number_prefix = self.client.account_number[:8] - account_number_suffix = self.client.account_number[8:] - - is_virtual = "vts" in self.client.base_url.lower() - tr_id = "VTTC8434R" if is_virtual else "TTTC8434R" - - result, _ = await self.client.request( - "GET", - "/uapi/domestic-stock/v1/trading/inquire-balance", - tr_id, - params={ - "CANO": account_number_prefix, - "ACNT_PRDT_CD": account_number_suffix, - "AFHR_FLPR_YN": "N", - "OFL_YN": "", - "INQR_DVSN": "02", - "UNPR_DVSN": "01", - "FUND_STTL_ICLD_YN": "N", - "FNCG_AMT_AUTO_RDPT_YN": "N", - "PRCS_DVSN": "01", - "CTX_AREA_FK100": "", - "CTX_AREA_NK100": "" - } - ) - return result - except Exception as e: - logger.error(f"❌ 잔고 조회 실패: {e}") - raise - - async def place_overseas_order( - self, - symbol: str, - order_type: str, - quantity: int, - price: float, - exchange_code: str = "NASD" - ) -> Dict[str, Any]: - """해외주식 주문""" - try: - account_number_prefix = self.client.account_number[:8] - account_number_suffix = self.client.account_number[8:] - - is_virtual = "vts" in self.client.base_url.lower() - - if order_type == "BUY": - tr_id = "VTTT1002U" if is_virtual else "TTTT1002U" - else: - tr_id = "VTTT1006U" if is_virtual else "TTTT1006U" - - result, _ = await self.client.request( - "POST", - "/uapi/overseas-stock/v1/trading/order", - tr_id, - json={ - "CANO": account_number_prefix, - "ACNT_PRDT_CD": account_number_suffix, - "OVRS_EXCG_CD": exchange_code.upper(), - "PDNO": symbol.upper(), - "ORD_QTY": str(quantity), - "OVRS_ORD_UNPR": str(price), - "ORD_SVR_DVSN_CD": "0", - "ORD_DVSN": "00" - } - ) - return result - except Exception as e: - logger.error(f"❌ 해외주식 주문 실패: {e}") - raise - - async def get_overseas_balance(self) -> Dict[str, Any]: - """해외주식 잔고 조회""" - try: - account_number_prefix = self.client.account_number[:8] - account_number_suffix = self.client.account_number[8:] - - is_virtual = "vts" in self.client.base_url.lower() - tr_id = "VTTS3012R" if is_virtual else "TTTS3012R" - - result, _ = await self.client.request( - "GET", - "/uapi/overseas-stock/v1/trading/inquire-balance", - tr_id, - params={ - "CANO": account_number_prefix, - "ACNT_PRDT_CD": account_number_suffix, - "OVRS_EXCG_CD": "NASD", - "TR_CRCY_CD": "USD", - "CTX_AREA_FK200": "", - "CTX_AREA_NK200": "", - } - ) - return result - except Exception as e: - logger.error(f"❌ 해외주식 잔고 조회 실패: {e}") - return {} - - -# 싱글톤 -order_manager = OrderManager() diff --git a/backend/collect_sector_map.py b/backend/collect_sector_map.py new file mode 100644 index 0000000..2fee2b9 --- /dev/null +++ b/backend/collect_sector_map.py @@ -0,0 +1,80 @@ +"""섹터 매핑 캐시 수집 배치 (CLAUDE.md §8.5 항목 8) + +네이버 업종 API에서 전 종목 업종 → 정본 섹터 라벨을 수집해 +`~/.cache/auto-stock/sector_map.json` 에 적재한다. 적재된 캐시는 +`sector_mapper.get_sector()`(동기·네트워크 없음)를 통해 correlation_filter +§5.1 섹터캡 판정에 쓰인다. + +사용법: + python collect_sector_map.py # 수집 + 캐시 기록 + 커버리지 리포트 + python collect_sector_map.py --dry-run # 수집만 (캐시 파일 미기록) + python collect_sector_map.py --coverage-only # 기존 캐시로 커버리지만 측정 (네트워크 최소) + +Cron 권장 (상장/업종 변경 반영 — 하루 1회로 충분): + 30 8 * * 1-5 cd /path/to/auto-stock/backend && python collect_sector_map.py + +주의: DB 를 쓰지 않는다(파일 캐시 전용) → db_worker 경유 규칙 대상 아님. +""" + +import argparse +import asyncio +import logging +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent)) + +from app.core.json_helpers import _dumps # noqa: E402 +from app.services import sector_mapper # noqa: E402 + +logger = logging.getLogger(__name__) + + +async def _universe_codes(max_pages: int = 10) -> list: + """커버리지 측정용 유니버스(네이버 시총 상위 KOSPI+KOSDAQ) 종목코드.""" + try: + from app.services.naver_finance import get_naver_stock_list + stocks = await get_naver_stock_list(max_pages_per_market=max_pages) + return [code for code, _name in stocks] + except Exception as exc: + logger.warning("유니버스 조회 실패 (커버리지 측정 생략): %s", exc) + return [] + + +async def main() -> int: + parser = argparse.ArgumentParser(description="섹터 매핑 캐시 수집") + parser.add_argument("--dry-run", action="store_true", help="캐시 파일에 기록하지 않음") + parser.add_argument("--coverage-only", action="store_true", help="수집 없이 커버리지만 측정") + parser.add_argument("--no-coverage", action="store_true", help="커버리지 측정 생략(유니버스 조회 안 함)") + parser.add_argument("--universe-pages", type=int, default=10, help="시장별 유니버스 페이지 수 (기본 10=~500종목/시장)") + args = parser.parse_args() + + if not args.coverage_only: + result = await sector_mapper.refresh_sector_map(write_cache=not args.dry_run) + print(_dumps({"refresh": result})) + if not result.get("ok"): + logger.error("섹터 맵 수집 실패 — 기존 캐시/정적 표 폴백 유지") + return 1 + + if args.no_coverage: + return 0 + + universe = await _universe_codes(args.universe_pages) + coverage = sector_mapper.get_coverage(universe or None) + print(_dumps({"coverage": coverage})) + + pct = coverage.get("universe_coverage_pct") + if pct is not None: + logger.info( + "섹터 커버리지 %.2f%% (%s/%s) — 80%% 이상이면 SECTOR_CAP_UNMAPPED_ENABLED 검토 가능", + pct, coverage.get("universe_covered"), coverage.get("universe_size"), + ) + return 0 + + +if __name__ == "__main__": + logging.basicConfig( + level=logging.INFO, + format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", + ) + sys.exit(asyncio.run(main())) diff --git a/backend/run_backtest.py b/backend/run_backtest.py index 3399009..eba648b 100644 --- a/backend/run_backtest.py +++ b/backend/run_backtest.py @@ -46,6 +46,19 @@ except ImportError: import pandas as pd +# [2026-09-27 §8.5-1 중복 통합] 지표 계산은 실전 경로와 동일한 단일 출처를 사용한다. +# indicators_core 는 순수 계산 모듈(typing 외 import 없음)이라 DB/설정/네트워크 의존이 +# 없고 순환 import 도 발생하지 않는다. 이 스크립트는 backend/ 를 루트로 실행되므로 +# sys.path 에 스크립트 디렉터리를 명시적으로 넣어 `python backend/run_backtest.py`, +# `python run_backtest.py` 양쪽에서 동일하게 import 되도록 한다. +sys.path.insert(0, str(Path(__file__).resolve().parent)) +from app.services.indicators_core import ( # noqa: E402 + adx_wilder as _core_adx_wilder, + atr_sma as _core_atr_sma, + bollinger_sma as _core_bollinger_sma, + rsi_wilder as _core_rsi_wilder, +) + # --------------------------------------------------------------------------- # 백테스트 대상 종목 # --------------------------------------------------------------------------- @@ -227,44 +240,26 @@ def determine_adaptive_mode( def calculate_adx_simple(highs: List[float], lows: List[float], closes: List[float], period: int = 14) -> float: - """간이 ADX 계산 (백테스트용).""" + """ADX (백테스트 적응형 모드용) — 실전과 동일한 Wilder ADX + 20.0 폴백 + + [2026-09-27 §8.5-1 수정] 이전 구현은 마지막 단계에서 DX(0~100)에 **합산형** + 평활(초기값 = sum(dxs[:period]))을 적용해 반환값이 정식 ADX 의 정확히 `period` + 배로 발산했다(측정: 배율 14.0×). 그 결과 호출부 `determine_adaptive_mode()` 의 + Aggressive 게이트 `adx > 25`(§8.4)가 사실상 상시 True 가 되어 백테스트가 + 실전보다 공격적인 사이징(포지션 7%·SL -9%)으로 돌았다 = 성과 상방 편향. + + 이제 `calculate_adx`(= indicators_core.adx_wilder = 실전 경로 + mean_reversion_strategy.calculate_adx)와 **동일 알고리즘**이다. 두 함수는 + 폴백 계약만 다르다(아래 참조) — 호출부 호환을 위해 이름·시그니처는 유지한다. + + 폴백 계약(기존 유지): + - `len(closes) < period + 1` → 20.0 (중립값) + - adx_wilder 가 데이터 부족으로 None (`len < period*2 + 1` 또는 DX 부족) → 20.0 + """ if len(closes) < period + 1: return 20.0 - plus_dm, minus_dm, tr_list = [], [], [] - for i in range(1, len(closes)): - h, l, pc = highs[i], lows[i], closes[i - 1] - up = h - highs[i - 1] - down = lows[i - 1] - l - plus_dm.append(up if up > down and up > 0 else 0) - minus_dm.append(down if down > up and down > 0 else 0) - tr_list.append(max(h - l, abs(h - pc), abs(l - pc))) - - def _smooth(data, p): - if len(data) < p: - return [sum(data) / max(len(data), 1)] - s = [sum(data[:p])] - for v in data[p:]: - s.append(s[-1] - s[-1] / p + v) - return s - - s_tr = _smooth(tr_list, period) - s_pdm = _smooth(plus_dm, period) - s_mdm = _smooth(minus_dm, period) - - dxs = [] - for i in range(min(len(s_tr), len(s_pdm), len(s_mdm))): - if s_tr[i] == 0: - continue - pdi = s_pdm[i] / s_tr[i] * 100 - mdi = s_mdm[i] / s_tr[i] * 100 - denom = pdi + mdi - if denom > 0: - dxs.append(abs(pdi - mdi) / denom * 100) - - if not dxs: - return 20.0 - adx_smooth = _smooth(dxs, period) - return adx_smooth[-1] if adx_smooth else 20.0 + adx = calculate_adx(highs, lows, closes, period) + return adx if adx is not None else 20.0 # --------------------------------------------------------------------------- @@ -342,95 +337,45 @@ class BacktestMetrics: # --------------------------------------------------------------------------- def calculate_rsi(closes: List[float], period: int = 14) -> Optional[float]: - """RSI(Relative Strength Index)""" - if len(closes) < period + 1: - return None - gains, losses = [], [] - for i in range(-period, 0): - diff = closes[i] - closes[i - 1] - gains.append(max(diff, 0)) - losses.append(max(-diff, 0)) - avg_gain = sum(gains) / period - avg_loss = sum(losses) / period - if avg_loss == 0: - return 100.0 - rs = avg_gain / avg_loss - return 100 - (100 / (1 + rs)) + """RSI(Relative Strength Index) — Wilder 평활 (실전 경로와 동일) + + [2026-09-27 §8.5-1 수정] 이전에는 `indicators_core.rsi_sma`(평활 없는 SMA 방식) + 를 썼으나 실전 경로(mean_reversion_strategy.calculate_rsi = rsi_wilder)와 + 알고리즘이 달라 진입/청산 게이트 판정이 약 10% 의 봉에서 갈렸다 + (측정 n=78,000: mean|Δ|=4.68, p95=14.51, RSI<46 진입게이트 불일치 9.7%, + RSI>=70 전량청산 불일치 5.7%). 이제 `rsi_wilder` 로 통일한다. + → 이 변경으로 과거 백테스트 수치와 결과가 달라지는 것은 **의도된 것**이다. + + 시그니처·`None` 계약(데이터 부족 시 None)은 그대로 유지한다. + """ + return _core_rsi_wilder(closes, period) def calculate_bollinger(closes: List[float], period: int = 20) -> Optional[Tuple[float, float, float]]: - """볼린저 밴드 (2sigma) -> (upper, middle, lower)""" - if len(closes) < period: - return None - window = closes[-period:] - middle = sum(window) / period - variance = sum((p - middle) ** 2 for p in window) / period - std = variance ** 0.5 - return (middle + 2 * std, middle, middle - 2 * std) + """볼린저 밴드 (2sigma) -> (upper, middle, lower) + + [2026-09-27 §8.5-1 중복 통합] indicators_core.bollinger_sma(num_std=2.0) 위임. + 모표준편차(ddof=0) 사용 — 실전 경로와 동일. + """ + return _core_bollinger_sma(closes, period, 2.0) def calculate_atr(highs: List[float], lows: List[float], closes: List[float], period: int = 14) -> Optional[float]: - """ATR(Average True Range)""" - if len(highs) < period + 1 or len(lows) < period + 1 or len(closes) < period + 1: - return None - true_ranges = [] - for i in range(1, len(highs)): - h, l, c_prev = highs[i], lows[i], closes[i - 1] - tr = max(h - l, abs(h - c_prev), abs(l - c_prev)) - true_ranges.append(tr) - if len(true_ranges) < period: - return None - return sum(true_ranges[-period:]) / period + """ATR(Average True Range) — TR 의 단순평균 + + [2026-09-27 §8.5-1 중복 통합] indicators_core.atr_sma() 위임. 실전 경로와 동일. + """ + return _core_atr_sma(highs, lows, closes, period) def calculate_adx(highs: List[float], lows: List[float], closes: List[float], period: int = 14) -> Optional[float]: - """ADX(Average Directional Index) - Wilder's smoothing""" - n = len(highs) - if n < period * 2 + 1: - return None + """ADX(Average Directional Index) - Wilder's smoothing - trs, plus_dms, minus_dms = [], [], [] - for i in range(1, n): - h, l, c_prev = highs[i], lows[i], closes[i - 1] - h_prev, l_prev = highs[i - 1], lows[i - 1] - tr = max(h - l, abs(h - c_prev), abs(l - c_prev)) - up_move = h - h_prev - down_move = l_prev - l - plus_dm = up_move if up_move > down_move and up_move > 0 else 0.0 - minus_dm = down_move if down_move > up_move and down_move > 0 else 0.0 - trs.append(tr) - plus_dms.append(plus_dm) - minus_dms.append(minus_dm) - - def _wilder_smooth(values): - smoothed = [sum(values[:period])] - for v in values[period:]: - smoothed.append(smoothed[-1] - smoothed[-1] / period + v) - return smoothed - - atr_s = _wilder_smooth(trs) - plus_dm_s = _wilder_smooth(plus_dms) - minus_dm_s = _wilder_smooth(minus_dms) - - dxs = [] - for atr_v, p_dm, m_dm in zip(atr_s, plus_dm_s, minus_dm_s): - if atr_v == 0: - dxs.append(0.0) - continue - plus_di = 100.0 * p_dm / atr_v - minus_di = 100.0 * m_dm / atr_v - di_sum = plus_di + minus_di - dxs.append(100.0 * abs(plus_di - minus_di) / di_sum if di_sum else 0.0) - - if len(dxs) < period: - return None - # [FIX] ADX는 합산형 _wilder_smooth(초기값=sum)가 아니라 평균형 Wilder 평활을 - # 써야 한다. 기존엔 DX(0~100)에 sum-accumulation을 적용해 ADX가 100을 넘어 - # 발산했고, SIDEWAYS 게이트(ADX<20)가 사실상 무력화됐다. mean_reversion_strategy와 동일 수정. - adx_val = sum(dxs[:period]) / period # 초기값 = 평균 - for dx_v in dxs[period:]: - adx_val = adx_val - adx_val / period + dx_v / period - return round(adx_val, 2) + [2026-09-27 §8.5-1 중복 통합] indicators_core.adx_wilder() 위임. 실전 경로와 동일. + (DX 평활은 평균형 — 합산형을 쓰면 ADX 가 100을 넘어 발산하고 SIDEWAYS 게이트가 + 무력화된다. 2026-09-27 부터 calculate_adx_simple 도 이 함수에 위임한다.) + """ + return _core_adx_wilder(highs, lows, closes, period) def is_reversal_candle(opens: List[float], highs: List[float], diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py new file mode 100644 index 0000000..8938ef5 --- /dev/null +++ b/backend/tests/conftest.py @@ -0,0 +1,203 @@ +"""유닛 테스트 계층 공용 안전망 — 실제 KIS 호출·라이브 DB를 구조적으로 차단한다. + +## 왜 필요한가 +파일별 방어(각 테스트가 자기 목을 다는 방식)는 두 번 실패했다. + +1. `test_daytrading_stale_position_guard.py` — 리팩터링으로 `close_stale_positions()`가 + `_execute_exit` → `_execute_exit_stale` 로 바뀌자 목이 빗나가 **실제 KIS 매도 주문이 + 2회 전송**됐다. +2. `test_daytrading_orb_watchlist_and_capital_split.py` — `get_balance`만 목킹하고 + `get_buyable_cash()`는 빠뜨려 `_get_access_token → _request_new_token` 으로 내려갔고, + `korea_investment_api._handle_rate_limit()`의 "토큰 발급 1분 제한: 69.8초 대기" + (`asyncio.sleep`)에 걸려 테스트 하나가 140초를 소비했다. 소켓을 막아도 예외가 + 재시도 로직에 삼켜진 뒤 잠들기 때문에 느려지는 것은 그대로다. + +프로젝트 메모리에는 멀티테넌트 1단계 작업 중 라이브 경로 호출로 **모의투자 1주 오발주** +사고가 기록돼 있다. 규약은 "격리 Postgres(`_test` 접미사) + KIS mock"인데 유닛 계층에는 +`conftest.py`가 없어 강제 수단이 전혀 없었다. + +## 설계 원칙 +- **그럴듯한 값을 반환하지 않는다.** 모든 차단 대상은 호출되는 즉시 예외를 던진다. + 조용히 성공하는 스텁은 목킹 누락을 감추고 테스트를 거짓 통과시킨다. +- 예외는 `Exception`이 아니라 **`BaseException` 파생**(`KISCallBlocked`)이다. + 프로덕션 코드 곳곳의 `except Exception:` 재시도/폴백 블록이 삼켜버리면 차단이 무력화되고 + (위 사례 2처럼) 오히려 느려지기만 한다. `BaseException`이면 통과해서 테스트를 실패시킨다. +- **차단은 클래스 메서드에 적용**한다(`KoreaInvestmentAPI.`). 인스턴스가 아니라 + 클래스를 막아야 `from ... import korea_investment_api`로 싱글톤 참조를 미리 캡처한 + 모듈(예: `daytrading/entry_service`, `api/endpoints/portfolio`)이나 멀티테넌트에서 새로 + 만드는 인스턴스까지 전부 덮인다. 개별 테스트의 `patch.object(instance, ...)`는 인스턴스 + 속성으로 클래스 속성을 가리므로 **테스트 쪽 목이 항상 이긴다**(autouse 픽스처가 먼저 + 적용되고 테스트 내 patch가 나중에 적용되는 순서). +- **시세·잔고 계열도 차단**한다. 전역 기본 스텁을 주면 기존 테스트의 기대값이 조용히 + 바뀌므로, 값을 주는 대신 막고 각 테스트가 명시적으로 덮게 한다. +- `tests/integration/`은 제외한다(격리 컨테이너 Postgres + 자체 목 규약, 별도 conftest). + +## 사용법 + cd backend + DATABASE_URL="postgresql+asyncpg://u:p@127.0.0.1:5432/autostock_test" \ + python -m pytest tests/ --ignore=tests/integration + +`--allow-network` 를 주면 KIS 차단을 끈다(실 연동 점검 전용, 기본 꺼짐). +""" +import os +import sys +from pathlib import Path + +import pytest + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) + + +# ───────────────────────────────────────────────────────────────────────────── +# 차단 예외 — `except Exception:` 에 삼켜지지 않도록 BaseException 파생 +# ───────────────────────────────────────────────────────────────────────────── +class KISCallBlocked(BaseException): + """테스트 중 실제 KIS/주문 경로가 호출됨 — 해당 테스트의 목킹이 누락됐다는 뜻.""" + + +def _blocked(dotted_name: str): + """호출 즉시 실패하는 async 스텁 — 값을 반환하지 않는다(거짓 통과 방지).""" + + async def _boom(*args, **kwargs): + raise KISCallBlocked( + f"[tests/conftest.py] 실호출 차단: {dotted_name}() 가 목킹되지 않은 채 호출됐습니다. " + f"해당 테스트에서 patch.object(...)로 명시적으로 덮으십시오. " + f"(실 연동 점검이라면 tests/integration/ 으로 옮기거나 --allow-network 사용)" + ) + + _boom.__name__ = dotted_name.rsplit(".", 1)[-1] + _boom.__qualname__ = dotted_name + return _boom + + +# 차단 대상 — 추측이 아니라 코드에서 정의 위치를 확인한 목록 (2026-09-27 기준). +# +# app/services/korea_investment_api.py +# 174 class KoreaInvestmentAPI +# 410 async def _request_new_token +# 461 async def _get_access_token +# 632 async def get_current_price +# 652 async def get_daily_chart +# 801 async def get_overseas_price +# 822 async def get_minute_chart +# 904 async def get_minute_chart_full_day +# 1001 async def get_overseas_daily_chart +# 1044 async def place_overseas_order +# 1087 async def get_balance +# 1164 async def get_buyable_cash +# 1202 async def get_overseas_balance +# 1234 async def place_order +# app/services/trade_executor.py +# 260 async def safe_place_order +# 334 async def safe_place_order_v2 +_KIS_CLASS_METHODS = ( + # ── 주문 계열 (실발주 위험) ── + "place_order", + "place_overseas_order", + # ── 토큰 계열 (rate-limit sleep 70초의 근원) ── + "_request_new_token", + "_get_access_token", + # ── 시세·잔고 계열 ── + "get_current_price", + "get_daily_chart", + "get_overseas_price", + "get_overseas_daily_chart", + "get_minute_chart", + "get_minute_chart_full_day", + "get_balance", + "get_buyable_cash", + "get_overseas_balance", +) + +_TRADE_EXECUTOR_FUNCS = ( + "safe_place_order", + "safe_place_order_v2", +) + + +# ───────────────────────────────────────────────────────────────────────────── +# pytest 훅 +# ───────────────────────────────────────────────────────────────────────────── +def pytest_addoption(parser): + parser.addoption( + "--allow-network", + action="store_true", + default=False, + help="KIS 실호출 차단을 해제한다 (실 연동 점검 전용 — 라이브 계좌 주문 위험).", + ) + + +def pytest_configure(config): + """라이브 DB 가드 — DB 이름이 `_test`로 끝나지 않으면 세션 자체를 중단. + + `tests/integration/conftest.py` 도 동일한 가드를 import 시점에 수행한다. 여기서는 + 유닛 계층까지 포함해 세션 전체에 적용하되, 판정 기준(`settings.DATABASE_URL`의 DB 이름)이 + 같으므로 integration 단독 실행의 기존 동작은 바뀌지 않는다. env만 보던 방식은 + env 미설정 시 `settings.DATABASE_URL` 기본값(라이브 가능성)으로 조용히 새는 결함이 있어 + `settings`를 기준으로 본다. + """ + from app.core.config import settings + + db_url = settings.DATABASE_URL or os.environ.get("DATABASE_URL") or "" + db_name = db_url.rsplit("/", 1)[-1].split("?", 1)[0] + if not db_name.endswith("_test"): + pytest.exit( + "settings.DATABASE_URL의 DB 이름이 '_test'로 끝나지 않습니다" + f"(현재: {db_name!r}). 테스트는 반드시 격리 DB(예: autostock_test)를 대상으로 " + "실행해야 합니다. 예:\n" + ' DATABASE_URL="postgresql+asyncpg://u:p@127.0.0.1:5432/autostock_test" ' + "python -m pytest tests/ --ignore=tests/integration", + returncode=1, + ) + + +def _is_integration(request) -> bool: + """`tests/integration/` 하위이거나 `integration` 마커가 붙은 테스트인가.""" + try: + path = str(request.node.fspath) + except Exception: # pragma: no cover - fspath 미지원 노드 방어 + path = "" + if f"{os.sep}integration{os.sep}" in path: + return True + return request.node.get_closest_marker("integration") is not None + + +@pytest.fixture(autouse=True) +def _block_live_kis(request, monkeypatch): + """모든 유닛 테스트에 자동 적용 — KIS 주문/토큰/시세/잔고 경로를 호출 즉시 실패로 치환. + + 적용 순서상 이 픽스처가 먼저 클래스 속성을 덮고, 테스트 본문의 + `patch.object(instance, ...)` / `patch("...module.attr", ...)` 가 나중에 적용되므로 + 개별 테스트의 명시적 목이 항상 우선한다. + """ + if request.config.getoption("--allow-network"): + return + if _is_integration(request): + return + + from app.services import korea_investment_api as kis_mod + + for name in _KIS_CLASS_METHODS: + if not hasattr(kis_mod.KoreaInvestmentAPI, name): + raise AssertionError( + f"tests/conftest.py 차단 목록이 코드와 어긋납니다: " + f"KoreaInvestmentAPI.{name} 가 존재하지 않습니다(이름 변경/삭제?)." + ) + monkeypatch.setattr( + kis_mod.KoreaInvestmentAPI, + name, + _blocked(f"korea_investment_api.{name}"), + raising=True, + ) + + from app.services import trade_executor as te_mod + + for name in _TRADE_EXECUTOR_FUNCS: + if not hasattr(te_mod, name): + raise AssertionError( + f"tests/conftest.py 차단 목록이 코드와 어긋납니다: " + f"trade_executor.{name} 가 존재하지 않습니다(이름 변경/삭제?)." + ) + monkeypatch.setattr( + te_mod, name, _blocked(f"trade_executor.{name}"), raising=True + ) diff --git a/backend/tests/test_adaptive_strategy.py b/backend/tests/test_adaptive_strategy.py index d2b1576..42c66ed 100644 --- a/backend/tests/test_adaptive_strategy.py +++ b/backend/tests/test_adaptive_strategy.py @@ -225,10 +225,14 @@ class TestFallback: assert params.fallback_used is True def test_fallback_uses_config_values(self): + from app.core.config import settings + params = _get_fallback_params() - # Should match config.py defaults (MAX_POSITION_SIZE=0.06) - assert params.position_size == 0.06 - assert params.atr_stop_multiplier == 2.5 + # fallback은 하드코딩이 아니라 settings 값을 그대로 읽어야 한다. + # config.py 기본값이 바뀌어도(예: MAX_POSITION_SIZE 0.06→0.04) 이 테스트가 + # 함께 갱신되도록 settings를 직접 참조한다. + assert params.position_size == settings.MAX_POSITION_SIZE + assert params.atr_stop_multiplier == settings.ATR_STOP_MULTIPLIER def test_get_current_without_init(self): """초기화 없이 호출하면 fallback을 반환해야 한다.""" diff --git a/backend/tests/test_ai_analysis_buy_amount.py b/backend/tests/test_ai_analysis_buy_amount.py index 8710dc5..975d330 100644 --- a/backend/tests/test_ai_analysis_buy_amount.py +++ b/backend/tests/test_ai_analysis_buy_amount.py @@ -55,8 +55,16 @@ def test_limited_by_available_cash_and_reserve(): def test_limited_by_swing_capital_cap(): - # 스윙 상한 50% = 500만, 이미 490만 보유 → 잔여 10만 - portfolio_cache.summary = _summary(stock_value=4_900_000) + # 스윙 자본 상한이 구속 조건이 되는 경우를 검증한다. + # [2026-09-27] 기존에는 stock_value=4_900_000 을 하드코딩해 "상한 50% = 500만" 을 + # 전제했으나, SWING_MAX_CAPITAL_RATIO 가 0.50→0.70 으로 바뀌면서(2026-09-23) + # 상한이 700만이 되어 종목당 6% 캡(60만)이 먼저 걸렸다 → 8주가 나와 실패. + # config 값이 또 바뀌어도 의도가 유지되도록 상한에서 역산한다(잔여 10만 → 1주). + from app.core.config import settings + + _total = 10_000_000 + _held = int(_total * settings.SWING_MAX_CAPITAL_RATIO) - 100_000 + portfolio_cache.summary = _summary(total_assets=_total, stock_value=_held) with patch("app.services.adaptive_strategy.get_current_adaptive_params", return_value=_Adp()): out = _suggest_buy_amount(70_000, "BUY", 0.7, "005930") assert out["suggested_quantity"] == 1 diff --git a/backend/tests/test_api_endpoints.py b/backend/tests/test_api_endpoints.py index 34ba1b5..52b3953 100644 --- a/backend/tests/test_api_endpoints.py +++ b/backend/tests/test_api_endpoints.py @@ -139,6 +139,41 @@ from app.models.models import Portfolio, TradeHistory # --------------------------------------------------------------------------- # Fixtures # --------------------------------------------------------------------------- +@pytest.fixture(autouse=True) +def bind_mock_kis_to_captured_refs(): + """`_mock_kis`를 이미 실제 싱글톤을 캡처한 모듈들에까지 강제 바인딩. + + 파일 상단의 `patch("app.services.korea_investment_api.korea_investment_api", _mock_kis)`는 + **이 모듈이 가장 먼저 import될 때만** 통한다. 전체 스위트 실행에서는 알파벳 순으로 앞선 + 테스트 모듈(`test_adaptive_strategy` 등)이 이미 `app.main`을 import해 + `app/api/endpoints/portfolio.py:20` 의 `from app.services.korea_investment_api import + korea_investment_api` 가 **실제 싱글톤을 자기 모듈 네임스페이스에 캡처**해 둔 상태다. + 그래서 소스 모듈 속성만 갈아끼운 상단 patch가 새고, `/api/v1/portfolio/summary` 가 + 라이브 `get_balance()` → `_get_access_token()` → `_handle_rate_limit()` 의 70초 + `asyncio.sleep` 까지 내려갔다. + (2026-09-27 측정: 단독 실행 2.9초 / 전체 스위트에서 이 파일 2건이 71초·142초 + 모니터링 5건 실패) + + `tests/conftest.py` 의 autouse 차단 픽스처가 클래스 메서드를 "호출 시 실패"로 덮으므로, + 복구도 **픽스처 단계**에서 해야 한다(부모 conftest autouse → 모듈 autouse 순서이므로 + 이쪽이 나중에 적용되어 이긴다). + """ + from app.services.korea_investment_api import KoreaInvestmentAPI + + patches = [ + patch.object(mod, "korea_investment_api", _mock_kis) + for name, mod in list(sys.modules.items()) + if name.startswith("app.") + and isinstance(getattr(mod, "korea_investment_api", None), KoreaInvestmentAPI) + ] + for p in patches: + p.start() + try: + yield + finally: + for p in reversed(patches): + p.stop() + + @pytest_asyncio.fixture(autouse=True) async def setup_database(): """각 테스트 전에 테이블 생성, 테스트 후 모든 데이터 정리""" @@ -159,25 +194,39 @@ async def db_session(): @pytest_asyncio.fixture async def client(): - """httpx AsyncClient (ASGITransport 기반) + """httpx AsyncClient (ASGITransport 기반) — 실제 X-API-Key 헤더로 인증. - require_api_key 의존성을 테스트 전용 noop 함수로 대체하여 - .env SECRET_KEY 값에 무관하게 인증을 통과시킴. + [2026-09-27 수정] 선재 결함: 이 픽스처는 `require_api_key` 의존성만 + noop("test-key" 반환)으로 오버라이드했다. 그런데 portfolio/trades 라우터에는 + 멀티테넌트 레이어(`app/api/__init__.py` → `Depends(deny_non_owner_session_when_mt)`)가 + 추가돼 있고, 그 체인은 `get_current_user`(app/core/tenant_auth.py)에서 + require_api_key 의 **반환값**을 API 키 또는 세션 토큰으로 다시 해석한다. + "test-key" 는 둘 다 아니므로 사용자 해석 실패 → 401 (20건 실패의 단일 원인). + + → 인증을 무력화하지 않고, 실제 운영과 동일하게 X-API-Key 헤더에 유효한 키를 + 실어 보낸다(`_get_api_key()` = API_KEY 우선, 없으면 SECRET_KEY 폴백). + 이렇게 하면 require_api_key → get_current_user(API 키 호출자 = legacy owner) + → deny_non_owner_session_when_mt(MULTI_TENANT_ENABLED=False 이므로 통과) + 전체 체인이 실제로 실행된다. 의존성 오버라이드는 get_db 뿐. """ - from app.core.security import require_api_key + from app.core.security import _get_api_key - async def _bypass_api_key(): - return "test-key" + transport = ASGITransport(app=app) + async with httpx.AsyncClient( + transport=transport, + base_url="http://test", + headers={"X-API-Key": _get_api_key()}, + ) as c: + yield c - app.dependency_overrides[require_api_key] = _bypass_api_key +@pytest_asyncio.fixture +async def unauthenticated_client(): + """인증 헤더가 없는 클라이언트 — require_api_key 가 실제로 걸려 있는지 검증용.""" transport = ASGITransport(app=app) async with httpx.AsyncClient(transport=transport, base_url="http://test") as c: yield c - # 테스트 완료 후 오버라이드 제거 - app.dependency_overrides.pop(require_api_key, None) - def _reset_portfolio_cache(**overrides): """포트폴리오 캐시를 테스트에 맞게 초기화하는 헬퍼""" @@ -706,3 +755,40 @@ class TestTradeHistoryEndpoints: response2 = await client.get("/api/v1/trades", params={"status": "FAILED"}) assert response2.status_code == 200 assert len(response2.json()) == 0 + + +# =========================================================================== +# 4. 인증 강제 테스트 (require_api_key 가 무력화되지 않았음을 보장) +# =========================================================================== +class TestApiKeyEnforcement: + """[2026-09-27 추가] client 픽스처가 헤더 인증 방식으로 바뀌었으므로, + 인증 자체가 살아 있는지를 별도로 고정한다. 과거처럼 require_api_key 를 + dependency_overrides 로 우회하면 이 테스트가 먼저 깨진다. + """ + + @pytest.mark.parametrize( + "method,path", + [ + ("get", "/api/v1/portfolio"), + ("get", "/api/v1/portfolio/summary"), + ("get", "/api/v1/trades"), + ("get", "/api/v1/trades/stats/summary"), + ("delete", "/api/v1/trades/pending"), + ("get", "/api/v1/monitoring/health"), + ], + ) + @pytest.mark.asyncio + async def test_requires_api_key( + self, unauthenticated_client: httpx.AsyncClient, method: str, path: str + ): + """X-API-Key 헤더가 없으면 401""" + response = await getattr(unauthenticated_client, method)(path) + assert response.status_code == 401, f"{method.upper()} {path} 가 인증 없이 통과했다" + + @pytest.mark.asyncio + async def test_rejects_invalid_api_key(self, unauthenticated_client: httpx.AsyncClient): + """잘못된 키도 401 (세션 토큰으로도 해석 불가)""" + response = await unauthenticated_client.get( + "/api/v1/trades", headers={"X-API-Key": "not-a-valid-key"} + ) + assert response.status_code == 401 diff --git a/backend/tests/test_bugfixes_20260923.py b/backend/tests/test_bugfixes_20260923.py index 125e2b9..4e9c1e3 100644 --- a/backend/tests/test_bugfixes_20260923.py +++ b/backend/tests/test_bugfixes_20260923.py @@ -41,28 +41,58 @@ async def test_dt_full_exit_does_not_zero_swing_shares(): assert q.await_args.args[0]["quantity"] == 8 -def test_lstm_checkpoint_roundtrip_with_weights_only(): - """LSTM 체크포인트가 weights_only=True 로 로드 가능해야 한다(앙상블 무력화 방지).""" +def test_lstm_checkpoint_roundtrip_with_weights_only(tmp_path: Path): + """LSTM 체크포인트가 weights_only=True 로 로드 가능해야 한다(앙상블 무력화 방지). + + [2026-09-27 수정] 선재 결함: 이 테스트는 `ml_ensemble.LSTMPredictor` 를 임포트했는데 + 그 이름은 git 이력에 존재한 적이 없다(ImportError 로 항상 실패 = 이슈5 불변식이 + 사실상 미검증 상태였음). 실제 래퍼 클래스는 `ml_ensemble.StockLSTM` + (app/services/ml_ensemble.py:48, save():163 / load():185). + + 불변식은 그대로 유지·강화한다: + 1) save() 산출물이 `torch.load(..., weights_only=True)` 로 **그대로** 읽혀야 한다 + (numpy 배열 저장 금지 → list 로 저장, ml_ensemble.py:165 주석). + 2) load() 는 그 체크포인트를 복원하고 정규화 파라미터를 ndarray 로 되돌려야 한다. + + tmp_path 를 쓰는 것이 중요하다: load() 는 MODEL_DIR 내부 파일에 대해서만 + `weights_only=False` 폴백을 허용하므로(ml_ensemble.py:194-206), MODEL_DIR 밖에서 + 검증해야 폴백에 가려지지 않고 순수 weights_only 경로가 증명된다. + """ torch = pytest.importorskip("torch") - from app.services.ml_ensemble import LSTMPredictor + from app.services.ml_ensemble import MODEL_DIR, StockLSTM - p = LSTMPredictor(input_size=4) - if p.model is None: + m = StockLSTM(input_size=4) + if m.model is None: pytest.skip("torch 모델 초기화 불가") - p.seq_mean = np.array([1.0, 2.0, 3.0, 4.0], dtype=np.float32) - p.seq_std = np.array([1.0, 1.0, 1.0, 1.0], dtype=np.float32) + m.seq_mean = np.array([1.0, 2.0, 3.0, 4.0], dtype=np.float32) + m.seq_std = np.array([1.0, 1.0, 1.0, 1.0], dtype=np.float32) - import tempfile - with tempfile.TemporaryDirectory() as d: - path = Path(d) / "lstm.pt" - p.save(path) - ckpt = torch.load(path, map_location="cpu", weights_only=True) # 여기서 예외 없어야 함 - assert ckpt["seq_mean"] == [1.0, 2.0, 3.0, 4.0] + path = tmp_path / "lstm.pt" + # MODEL_DIR 밖이어야 load() 의 weights_only=False 폴백이 적용되지 않는다 + assert not path.resolve().is_relative_to(MODEL_DIR.resolve()) - p2 = LSTMPredictor(input_size=4) - assert p2.load(path) is True - assert isinstance(p2.seq_mean, np.ndarray) - np.testing.assert_allclose(p2.seq_mean, p.seq_mean) + m.save(path) + assert path.exists(), "save() 가 체크포인트를 만들지 않았다" + + # (1) 폴백 없이 weights_only=True 로 바로 읽혀야 한다 + ckpt = torch.load(path, map_location="cpu", weights_only=True) + assert ckpt["seq_mean"] == [1.0, 2.0, 3.0, 4.0] + assert ckpt["seq_std"] == [1.0, 1.0, 1.0, 1.0] + assert isinstance(ckpt["seq_mean"], list), "numpy 배열 저장 시 weights_only 로드가 거부된다" + assert ckpt["config"]["input_size"] == 4 + + # (2) load() 왕복 + m2 = StockLSTM(input_size=4) + assert m2.load(path) is True + assert isinstance(m2.seq_mean, np.ndarray) + np.testing.assert_allclose(m2.seq_mean, m.seq_mean) + np.testing.assert_allclose(m2.seq_std, m.seq_std) + + # (3) 가중치도 동일하게 복원됐는지 (state_dict 왕복 확인) + sd1, sd2 = m.model.state_dict(), m2.model.state_dict() + assert sd1.keys() == sd2.keys() + for k in sd1: + assert torch.allclose(sd1[k].cpu(), sd2[k].cpu()), f"가중치 복원 불일치: {k}" @pytest.mark.asyncio @@ -157,13 +187,43 @@ def test_active_trade_patch_supports_quantity(): def test_sync_seeds_active_trade_for_external_positions(): - """외부 유입 보유 종목(ActiveTrade 없음)은 동기화 시 자동 등록되어야 손절 관리를 받는다.""" + """외부 유입 보유 종목(ActiveTrade 없음)은 동기화 시 자동 등록되어야 손절 관리를 받는다. + + 과거 버그(2026-09-22 카카오뱅크 -13%): 다른 인스턴스·증권사 앱에서 매수된 + 포지션은 ActiveTrade 행이 없어 손절·분할익절·트레일링 관리를 전혀 받지 못했고, + 잔량 추적이 불가해 부분매도가 반복되거나 차단됐다. → sync 시 시딩이 불변식. + + [2026-09-27] 시딩 구현이 `db.add(ActiveTrade(entry_price=Decimal(str(_avg))))` + 직접 쓰기 → **db_worker 큐 경유**(`enqueue_active_trade_upsert`)로 바뀌었다 + (CLAUDE.md §3: sync_portfolio 직접 쓰기 예외는 Portfolio 까지만). + payload 는 DLQ JSON 직렬화 때문에 Decimal 이 아닌 순수 float/int 를 담고, + `_seed_payloads` 에 모은 뒤 commit 성공 후에 큐잉한다. + 따라서 검사 대상을 'Decimal 생성자 리터럴' → '큐 경유 + payload 필수 키 + + commit 이후 큐잉' 으로 갱신했다(불변식에 더 충실). 다음 사람은 문자열만 보고 + 고치지 말고 시딩 동작이 유지되는지를 확인할 것. + """ import inspect + import re from app.api.endpoints import portfolio as pf src = inspect.getsource(pf.sync_portfolio) assert "외부 유입 포지션 — 동기화 시 자동 등록" in src # 데이트레이딩 태깅 종목은 제외 (엔진이 당일 청산 대상으로 복원해버림) assert '(_h.strategy_type or "swing") == "daytrading"' in src - # 평균단가를 진입가로, 손절가는 STOP_LOSS_PERCENT 기준 + # 손절가는 STOP_LOSS_PERCENT 기준 assert "_cfg.STOP_LOSS_PERCENT" in src - assert "entry_price=Decimal(str(_avg))" in src + # ActiveTrade 쓰기는 db_worker 큐 경유 (직접 INSERT 부활 금지) + assert "enqueue_active_trade_upsert" in src, "시딩 경로 소실 — 외부 유입 포지션 무관리" + # 주석에 남은 이력 문구는 제외하고 실제 코드만 본다 + _code = "\n".join(l.split("#")[0] for l in src.splitlines()) + assert "db.add(ActiveTrade(" not in _code, "ActiveTrade 직접 INSERT 부활 (§3 위반)" + # 시딩 payload: 진입가(=평균단가)·잔량·손절가 등 필수 키 + JSON 직렬화 가능 타입 + m = re.search(r"_seed_payload = \{(.*?)\n\s*\}", src, re.S) + assert m, "시딩 payload 리터럴을 찾지 못함" + body = m.group(1) + for key in ("stock_code", "quantity", "entry_price", "stop_loss", "status", "remaining_pct"): + assert f'"{key}"' in body, f"시딩 payload 에 {key} 누락" + assert "_avg" in body, "진입가가 평균단가(_avg) 기준이 아님" + assert "Decimal" not in body, "payload 는 순수 float/int (DLQ JSON 직렬화 불가)" + # 큐잉은 commit 성공 후 — 롤백된 Portfolio 에 ActiveTrade 가 생기면 안 된다 + assert src.index("await db.commit()") < src.index("_enq_seed_at("), \ + "commit 전에 시딩 큐잉 — 롤백 시 유령 ActiveTrade" diff --git a/backend/tests/test_daytrading_orb_watchlist_and_capital_split.py b/backend/tests/test_daytrading_orb_watchlist_and_capital_split.py index a5c969b..2da0e10 100644 --- a/backend/tests/test_daytrading_orb_watchlist_and_capital_split.py +++ b/backend/tests/test_daytrading_orb_watchlist_and_capital_split.py @@ -61,7 +61,13 @@ async def test_populate_falls_back_to_latest_analysis_date(): @pytest.mark.asyncio async def test_entry_no_lunch_block_and_cutoff_from_settings(): - """점심시간(12:00)에도 VWAP/RSI_BB 스캔이 수행되고, 마감은 설정값(15:00) 기준.""" + """점심시간(12:00)에도 VWAP/RSI_BB 스캔이 수행되고, 마감은 설정값(15:00) 기준. + + `scan_and_enter()`는 잔고 조회에서 `get_balance()`와 `get_buyable_cash()`를 **둘 다** + 호출한다(entry_service.py:344, 350). 후자를 목킹하지 않으면 실제 KIS 토큰 발급 + 경로로 내려가 `_handle_rate_limit()`의 70초 `asyncio.sleep`에 걸리고(테스트 하나가 + 140초), 예외는 `except Exception` 폴백에 삼켜져 통과해 버린다. 둘 다 반드시 목킹한다. + """ from app.services.daytrading.position_manager import day_trading_state es.set_watchlist([{"code": "005930", "name": "삼성전자"}]) captured = {} @@ -85,6 +91,7 @@ async def test_entry_no_lunch_block_and_cutoff_from_settings(): patch.object(es, "is_daytrading_allowed_by_playbook", return_value=True), \ patch.object(es, "check_market_regime", AsyncMock(return_value={"regime": "neutral"})), \ patch.object(es.korea_investment_api, "get_balance", AsyncMock(return_value={"output2": [{"tot_evlu_amt": "10000000", "prvs_rcdl_excc_amt": "5000000"}]})), \ + patch.object(es.korea_investment_api, "get_buyable_cash", AsyncMock(return_value=5_000_000.0)), \ patch.object(es, "_try_enter", fake_try_enter): captured.clear() return await es.scan_and_enter() diff --git a/backend/tests/test_daytrading_stale_position_guard.py b/backend/tests/test_daytrading_stale_position_guard.py index 7723b30..1374295 100644 --- a/backend/tests/test_daytrading_stale_position_guard.py +++ b/backend/tests/test_daytrading_stale_position_guard.py @@ -4,6 +4,7 @@ 장중이면 전일 포지션을 즉시 전량 시장가 청산해야 한다. """ from datetime import datetime, timedelta +from types import SimpleNamespace from unittest.mock import AsyncMock, patch import pytest @@ -12,6 +13,65 @@ from app.services.daytrading import exit_service as es from app.services.daytrading.position_manager import day_trading_state +class LiveCallBlocked(AssertionError): + """테스트가 실제 외부 I/O(KIS/DB)를 호출하려 한 경우 — 목킹 누락 신호.""" + + +def _blocked(name): + async def _boom(*a, **k): + raise LiveCallBlocked(f"실호출 차단: {name} — 목킹이 누락되었습니다") + return _boom + + +class _EmptySession: + """AsyncSessionLocal 대체 — SELECT 결과가 빈 목록인 세션(네트워크 없음).""" + + async def __aenter__(self): + return self + + async def __aexit__(self, *a): + return False + + async def execute(self, *a, **k): + return SimpleNamespace(scalars=lambda: SimpleNamespace(all=lambda: [])) + + +@pytest.fixture(autouse=True) +def _block_live_io(): + """KIS 실호출·DB 실접속 전면 차단. + + close_stale_positions() 가 타는 외부 I/O 경로를 모두 막는다: + - trade_executor.safe_place_order (exit_service._execute_exit_impl 내부 지연 임포트) + - korea_investment_api.place_order / place_overseas_order / _get_access_token + - korea_investment_api.get_current_price / get_balance / 분봉 조회 + - app.core.database.AsyncSessionLocal (ActiveTrade 조회) + 개별 테스트는 필요한 항목만 중첩 patch 로 정상 응답으로 덮어쓴다. + """ + import app.services.trade_executor as te + + api = es.korea_investment_api + patches = [ + patch.object(te, "safe_place_order", _blocked("safe_place_order")), + patch.object(te, "safe_place_order_v2", _blocked("safe_place_order_v2")), + patch.object(api, "place_order", _blocked("place_order")), + patch.object(api, "place_overseas_order", _blocked("place_overseas_order")), + patch.object(api, "_get_access_token", _blocked("_get_access_token")), + patch.object(api, "_request_new_token", _blocked("_request_new_token")), + patch.object(api, "get_current_price", _blocked("get_current_price")), + patch.object(api, "get_balance", _blocked("get_balance")), + patch.object(api, "get_minute_chart", _blocked("get_minute_chart")), + patch.object(api, "get_minute_chart_full_day", _blocked("get_minute_chart_full_day")), + patch("app.core.database.AsyncSessionLocal", _EmptySession), + ] + for p in patches: + p.start() + try: + yield + finally: + for p in reversed(patches): + p.stop() + + def _pos(entry_dt): return { "stock_name": "LG씨엔에스", "strategy": "day_trade", "entry_price": 70800.0, @@ -20,6 +80,14 @@ def _pos(entry_dt): } +def _impl_call(mock) -> dict: + """_execute_exit_impl(stock_code, pos, price, reason, sell_pct, is_stale) 호출 인자 → dict.""" + names = ["stock_code", "pos", "price", "reason", "sell_pct", "is_stale"] + d = dict(zip(names, mock.await_args.args)) + d.update(mock.await_args.kwargs) + return d + + @pytest.fixture(autouse=True) def _clean(): day_trading_state.positions = {} @@ -38,13 +106,14 @@ async def test_stale_position_closed_in_full(): with patch.object(es, "is_market_open", return_value=True), \ patch.object(es.korea_investment_api, "get_current_price", AsyncMock(return_value={"output": {"stck_prpr": "71200"}})), \ - patch.object(es, "_execute_exit", exec_mock): + patch.object(es, "_execute_exit_impl", exec_mock): out = await es.close_stale_positions() assert len(out) == 1 - kwargs = exec_mock.await_args.kwargs - assert kwargs["sell_pct"] == 1.0 and "전일 미청산" in kwargs["reason"] - assert exec_mock.await_args.args[2] == 71200.0 # 현재가로 매도 + call = _impl_call(exec_mock) + assert call["sell_pct"] == 1.0 and "전일 미청산" in call["reason"] + assert call["is_stale"] is True # 전일 손익은 당일 집계 제외 + assert call["price"] == 71200.0 # 현재가로 매도 @pytest.mark.asyncio @@ -52,7 +121,7 @@ async def test_today_position_untouched(): day_trading_state.positions["064400"] = _pos(es._kst_now()) exec_mock = AsyncMock() with patch.object(es, "is_market_open", return_value=True), \ - patch.object(es, "_execute_exit", exec_mock): + patch.object(es, "_execute_exit_impl", exec_mock): assert await es.close_stale_positions() == [] exec_mock.assert_not_awaited() @@ -60,7 +129,6 @@ async def test_today_position_untouched(): @pytest.mark.asyncio async def test_stale_position_found_from_db_after_midnight_reset(): """자정 reset 으로 인메모리가 비어도 DB(ActiveTrade)에서 전일 포지션을 찾아 청산한다.""" - from types import SimpleNamespace from datetime import datetime as _dt day_trading_state.positions = {} @@ -84,12 +152,13 @@ async def test_stale_position_found_from_db_after_midnight_reset(): patch("app.core.database.AsyncSessionLocal", _Sess), \ patch.object(es.korea_investment_api, "get_current_price", AsyncMock(return_value={"output": {"stck_prpr": "70300"}})), \ - patch.object(es, "_execute_exit", exec_mock): + patch.object(es, "_execute_exit_impl", exec_mock): out = await es.close_stale_positions() assert len(out) == 1 - assert exec_mock.await_args.args[1]["quantity"] == 4 - assert exec_mock.await_args.kwargs["sell_pct"] == 1.0 + call = _impl_call(exec_mock) + assert call["pos"]["quantity"] == 4 + assert call["sell_pct"] == 1.0 @pytest.mark.asyncio @@ -97,7 +166,7 @@ async def test_no_action_when_market_closed(): day_trading_state.positions["064400"] = _pos(es._kst_now() - timedelta(days=1)) exec_mock = AsyncMock() with patch.object(es, "is_market_open", return_value=False), \ - patch.object(es, "_execute_exit", exec_mock): + patch.object(es, "_execute_exit_impl", exec_mock): assert await es.close_stale_positions() == [] exec_mock.assert_not_awaited() @@ -109,10 +178,10 @@ async def test_falls_back_to_entry_price_when_quote_fails(): with patch.object(es, "is_market_open", return_value=True), \ patch.object(es.korea_investment_api, "get_current_price", AsyncMock(side_effect=RuntimeError("quote down"))), \ - patch.object(es, "_execute_exit", exec_mock): + patch.object(es, "_execute_exit_impl", exec_mock): out = await es.close_stale_positions() assert len(out) == 1 - assert exec_mock.await_args.args[2] == 70800.0 # entry_price 폴백 + assert _impl_call(exec_mock)["price"] == 70800.0 # entry_price 폴백 @pytest.mark.asyncio diff --git a/backend/tests/test_mean_reversion.py b/backend/tests/test_mean_reversion.py index 394012a..6f38c42 100644 --- a/backend/tests/test_mean_reversion.py +++ b/backend/tests/test_mean_reversion.py @@ -37,6 +37,27 @@ _mock_strategy_cfg.get.side_effect = lambda key, default=None: { "MEAN_REVERSION_MULTIPLIER_WEAK": 1.00, }.get(key, default) +# --------------------------------------------------------------------------- +# [2026-09-27] import 순서 의존 제거 +# +# 아래 patch.dict("sys.modules", ...) 는 **이 파일이 먼저 import될 때만** 먹는다. +# 전체 스위트에서는 알파벳 앞선 테스트들이 이미 app.services.mean_reversion_strategy 를 +# 로드해 두므로, 그 모듈은 **실제** strategy_cfg 를 전역에 바인딩한 상태가 된다. +# 그러면 목이 새어 strategy_cfg.get() → None → settings.MEAN_REVERSION_RSI_THRESHOLD(46) +# 이 쓰이고, RSI 임계 30 을 전제한 케이스(test_bull_rsi_too_high: rsi=45)가 깨졌다. +# (단독 실행 74 passed / 전체 스위트 1 failed 로 나타난 순서 의존 버그) +# +# → 모듈 전역 참조를 **테스트 시점에** 덮는 autouse 픽스처로 바꿔, import 순서와 무관하게 +# 항상 같은 목이 쓰이도록 한다. 기대값은 하나도 바꾸지 않았다. +# --------------------------------------------------------------------------- +@pytest.fixture(autouse=True) +def _bind_mock_strategy_cfg(): + import app.services.mean_reversion_strategy as _m + + with patch.object(_m, "strategy_cfg", _mock_strategy_cfg): + yield + + with patch.dict("sys.modules", { "app.core.config": MagicMock(), "app.services.strategy_config_service": MagicMock(strategy_cfg=_mock_strategy_cfg), diff --git a/backend/tests/test_p1_audit_fixes.py b/backend/tests/test_p1_audit_fixes.py index 02216de..dde5c7a 100644 --- a/backend/tests/test_p1_audit_fixes.py +++ b/backend/tests/test_p1_audit_fixes.py @@ -1,39 +1,138 @@ """감사 P1 수정 검증: MDD 수량 반영 / 전략명 명시 / 국내 DT 태깅.""" import inspect +import re +from unittest.mock import patch import pytest -def test_mdd_reduction_reflects_quantities(): - """MDD 축소는 ActiveTrade·Portfolio·DT 인메모리 수량을 모두 줄여야 한다.""" +def _active_trade_payload_scopes(src: str) -> dict: + """`enqueue_active_trade_upsert(<변수>)` 로 넘기는 payload 변수별 '전략명 한정 키' 집합. + + 불변식: ActiveTrade upsert 는 반드시 대상 행을 전략명으로 한정해야 한다. + - `strategy_name` : 전략명이 있는 행을 동등 매칭 + - `strategy_name_not` : 전략명이 NULL 인 legacy/동기화 유입 행을 비-DT 조건으로 겨냥 + 둘 중 아무것도 없으면 db_worker 가 '가장 최근 active 행'을 잡아 같은 종목의 + 데이트레이딩 행을 덮어쓴다(= 이 가드가 막으려는 과거 버그). + + [2026-09-27] 구현이 dict 리터럴(`{"strategy_name": x, ...}`) → 변수 조립 + (`payload["strategy_name"] = x`) 로 바뀌면서 리터럴 문자열 매칭이 깨졌다. + 변수명·조립 순서에 의존하지 않도록 payload 변수 단위로 키를 수집한다. + """ + scopes: dict = {} + for var in re.findall(r"enqueue_active_trade_upsert\(\s*(_\w+)\s*\)", src): + scopes[var] = set( + re.findall( + rf'{re.escape(var)}\[\s*"(strategy_name|strategy_name_not)"\s*\]\s*=', + src, + ) + ) | set( + # dict 리터럴/update() 로 넣는 경우도 함께 인정 + re.findall( + rf'{re.escape(var)}(?:\s*=|\.update\()\s*\{{[^}}]*?"(strategy_name|strategy_name_not)"\s*:', + src, + re.S, + ) + ) + return scopes + + +@pytest.mark.asyncio +async def test_mdd_reduction_reflects_quantities(): + """MDD 축소는 ActiveTrade·Portfolio·DT 인메모리 수량을 모두 줄여야 한다. + + 과거 버그(P1 감사): 축소 매도가 체결됐는데 ActiveTrade.quantity 가 축소 전 + 값으로 남아, 다음 청산이 실제 보유량보다 많이 매도했다(과다매도 → 주문 거부 + 또는 미보유 매도). 그래서 '잔량이 payload 에 실리는지' 를 가드한다. + + [2026-09-27] 구현이 dict 리터럴 `{"quantity": remaining_qty}` → + `_sync_active_trade_after_reduction` 내부 헬퍼(`_upsert`/`_upsert_non_dt`)의 + payload 조립(`"quantity": qty`)으로 바뀌어 문자열 매칭이 깨졌다. 동작은 동일. + 문자열 대신 **실제로 큐에 실리는 payload** 를 캡처해 검증하도록 갱신 — + 리팩터링에는 견고하고, 잔량 반영이 빠지면 여전히 실패한다. + """ from app.services import mdd_manager as m + + captured: list = [] + + async def _cap(payload): + captured.append(payload) + + # (1) DT 병존 없음 → 최신 active 행 1건에 잔량·배율 반영 + with patch("app.services.db_worker.enqueue_active_trade_upsert", _cap), \ + patch.object(m, "_dt_position_qty", lambda _code: 0): + await m._sync_active_trade_after_reduction("005930", quantity=10, remaining_qty=7) + assert len(captured) == 1, "축소 반영 payload 가 큐잉되지 않음" + assert captured[0].get("quantity") == 7, "ActiveTrade 잔량 미반영 → 다음 청산이 과다매도" + assert captured[0]["remaining_pct_multiply"] == pytest.approx(0.7) + + # (2) DT 포지션 병존 → DT 행은 건드리지 않고(비-DT 필터) 스윙 잔량만 반영 + captured.clear() + with patch("app.services.db_worker.enqueue_active_trade_upsert", _cap), \ + patch.object(m, "_dt_position_qty", lambda _code: 3): + await m._sync_active_trade_after_reduction("005930", quantity=10, remaining_qty=7) + assert len(captured) == 1 + assert captured[0].get("quantity") == 7, "DT 병존 경로에서 잔량 미반영" + assert captured[0].get("strategy_name_not"), "DT ActiveTrade 행 오염 방지 필터 누락" + + # 두 축소 경로(REDUCE_AND_PAUSE / FORCE_REDUCE) 모두 인메모리 반영을 호출해야 한다 src = inspect.getsource(m) assert src.count("_reflect_reduction(stock_code, sell_qty") == 2, "두 축소 경로 모두 반영 필요" - assert '"quantity": remaining_qty' in src helper = inspect.getsource(m._reflect_reduction) assert "reflect_sell" in helper and "day_trading_state" in helper assert "remove_position" in helper and 'pos["quantity"] = _left' in helper def test_swing_exit_always_passes_strategy_name(): - """전략명을 비우면 db_worker 가 최신 active 행(=DT)을 닫거나 덮어쓴다.""" + """전략명을 비우면 db_worker 가 최신 active 행(=DT)을 닫거나 덮어쓴다. + + 과거 버그(P1 감사): 스윙 청산이 전략명 없이 close/upsert 를 큐잉해, 같은 종목에 + 데이트레이딩 행이 있으면 DT 행이 닫히거나 잔량이 덮어써지고 스윙 행은 영구 잔존. + + [2026-09-27] 전략명이 NULL 인 legacy/동기화 유입 행을 'swing' 으로 폴백하면 + 매칭 0건이 되는 문제 때문에 `_at_filter_not_dt` 분기가 추가됐다: + - 전략명 있음 → `payload["strategy_name"] = _at_strategy` + - 전략명 NULL → `payload["strategy_name_not"] = DT_STRATEGY_NAMES` + 두 경로 모두 'DT 행을 건드리지 않는다' 는 불변식을 지킨다. 따라서 특정 리터럴 + 대신 **모든 upsert payload 가 전략명으로 한정되는지** 를 검사한다. + """ from app.services.swing import exit_service as se src = inspect.getsource(se) assert "strategy_name=None" not in src, "close 호출에 전략명 누락" - assert 'strategy_name=_strategy or "swing"' in src - assert '"strategy_name": _at_strategy' in src # 트레일링 고점 upsert - assert '"strategy_name": _strategy or "swing"' in src # 분할매도 upsert + assert 'strategy_name=_strategy or "swing"' in src # 전량청산 close 경로 + scopes = _active_trade_payload_scopes(src) + # 트레일링 고점 upsert + 분할매도 upsert = 최소 2곳 + assert len(scopes) >= 2, f"ActiveTrade upsert payload 검출 실패: {scopes}" + for var, keys in scopes.items(): + assert keys, f"{var}: 전략명 한정 없이 upsert → DT ActiveTrade 행 오염" + # NULL 전략명 경로는 DT 제외 필터로 비-DT 행을 겨냥해야 한다 + assert "DT_STRATEGY_NAMES" in src, "DT 제외 필터 근거 소실" def test_executor_sell_excludes_daytrading_shares(): - """Portfolio 행은 스윙+DT 합계 → 스윙 청산이 DT 보유분까지 팔면 안 된다.""" + """Portfolio 행은 스윙+DT 합계 → 스윙 청산이 DT 보유분까지 팔면 안 된다. + + 과거 버그(P1 감사): Portfolio 한 행에 스윙+DT 수량이 합산돼 있어 스윙 청산이 + DT 보유분까지 매도했고, ActiveTrade 갱신도 DT 행을 집어 닫아버렸다. + + [2026-09-27] 분할매도 upsert 가 dict 리터럴 → `_at_payload[...] = ...` 조립으로 + 바뀌고, 전략명 NULL 행을 위한 `strategy_name_not` 분기가 추가됐다(스윙 청산과 + 동일 패턴). 또 캐시 반영용 '행 합산 수량'(_row_total_qty)과 이 전략 보유분(_own)이 + 분리됐다. 검사 방식만 갱신 — 불변식(DT 수량 제외 + DT 행 비오염)은 그대로 본다. + """ from app.services import trade_executor as te src = inspect.getsource(te._execute_sell) assert "day_trading_state" in src and "_dt_qty" in src assert "owned_by_daytrading" in src - assert 'total_qty = _own' in src - # 분할매도 upsert 는 실제 ActiveTrade 행의 전략명(_at_name)을 사용한다 - assert '"strategy_name": _at_name' in src + assert 'total_qty = _own' in src, "DT 수량 차감 후 매도 대상 축소 누락" + # 캐시/Portfolio 행 잔량은 행 합산 기준이어야 DT 보유분이 사라지지 않는다 + assert "_row_total_qty" in src + # 분할매도 upsert 는 전략명(또는 비-DT 필터)으로 대상 행을 한정해야 한다 + scopes = _active_trade_payload_scopes(src) + assert scopes, "ActiveTrade upsert payload 검출 실패 — 검사 대상 확인 필요" + for var, keys in scopes.items(): + assert keys, f"{var}: 전략명 한정 없이 upsert → DT ActiveTrade 행 오염" + assert "DT_STRATEGY_NAMES" in src, "DT 제외 필터 근거 소실" def test_sync_dt_tagging_applies_to_domestic_and_survives_missing_output1(): diff --git a/backend/tests/test_p2_audit_fixes.py b/backend/tests/test_p2_audit_fixes.py index 2a7e5da..188db02 100644 --- a/backend/tests/test_p2_audit_fixes.py +++ b/backend/tests/test_p2_audit_fixes.py @@ -1,4 +1,5 @@ """감사 P2 수정 검증: 주문 멱등성 / shield / 다중행 / v2·수동매도 / 스윙 쿨다운.""" +import asyncio import inspect from unittest.mock import AsyncMock, patch @@ -63,12 +64,53 @@ async def test_find_recent_order_matches_side_and_qty(): assert none is None # 수량 불일치 -def test_dt_exit_state_update_is_shielded(): +@pytest.mark.asyncio +async def test_dt_exit_state_update_is_shielded(): + """DT 청산의 '체결 이후 상태 반영' 은 취소로부터 보호(asyncio.shield)되어야 한다. + + 과거 버그(P2 감사): 스케줄러 단계 타임아웃(_guard)이 매도 주문 직후를 취소하면 + "브로커는 팔았는데 인메모리 포지션은 그대로" 가 되어 다음 사이클이 같은 물량을 + 다시 팔았다(미보유 매도/중복 매도). + + [2026-09-27] `_execute_exit` 가 `_execute_exit_impl` 로 위임하고 shield 는 + impl 안 2곳(`_finalize_exit` / `_finalize_exit_stale`)으로 이동 — 동작은 보존됐고 + `_execute_exit` 본문만 보던 문자열 가드의 **검사 범위**가 어긋났다. + 이제 문자열이 아니라 **동작**을 본다: 주문 체결 직후 태스크를 취소해도 + finalize 가 끝까지 완주하는지 확인한다(shield 제거 시 반드시 실패). + """ from app.services.daytrading import exit_service as es - src = inspect.getsource(es._execute_exit) - assert "asyncio.shield(_finalize_exit(" in src + + finished: list = [] + + async def _fake_finalize(*args, **kwargs): + await asyncio.sleep(0.05) # 취소가 개입할 수 있는 지점 + finished.append(True) # shield 가 있으면 여기까지 도달 + return {"stock_code": "005930"} + + with patch.object(es, "_finalize_exit", _fake_finalize), \ + patch("app.services.trade_executor.safe_place_order", + AsyncMock(return_value={"rt_cd": "0", "output": {"ODNO": "1"}})): + task = asyncio.create_task( + es._execute_exit("005930", {"quantity": 10}, 1000.0, "테스트 청산") + ) + await asyncio.sleep(0.01) # 주문 완료 후 finalize 진입까지 진행 + task.cancel() + with pytest.raises(asyncio.CancelledError): + await task + await asyncio.sleep(0.1) # shield 된 내부 코루틴 완주 대기 + + assert finished, "체결 후 상태 반영이 취소됐다 — shield 누락 (중복 매도 위험)" + + # 상태 반영 구현(인메모리 제거 + DB 기록)이 유지되는지 — 위임 체인을 따라 확인 fin = inspect.getsource(es._finalize_exit) + if "_finalize_exit_impl" in fin: + fin += inspect.getsource(es._finalize_exit_impl) assert "remove_position" in fin and "enqueue_trade_history" in fin + # 전일(stale) 청산 경로도 동일하게 보호되어야 한다 + impl = inspect.getsource(es._execute_exit_impl) + assert "asyncio.shield(_finalize_exit_stale(" in impl + # shield 는 반드시 주문 전송 '이후' 여야 한다(주문 실패 시 상태 반영 금지) + assert impl.index("safe_place_order(") < impl.index("asyncio.shield(") def test_swing_active_trade_lookup_handles_multiple_rows(): diff --git a/AUTOTRADE_DIAGNOSIS.md b/docs/AUTOTRADE_DIAGNOSIS.md similarity index 100% rename from AUTOTRADE_DIAGNOSIS.md rename to docs/AUTOTRADE_DIAGNOSIS.md diff --git a/docs/BATCH_ANALYSIS_GUIDE.md b/docs/BATCH_ANALYSIS_GUIDE.md deleted file mode 100644 index 50750c8..0000000 --- a/docs/BATCH_ANALYSIS_GUIDE.md +++ /dev/null @@ -1,116 +0,0 @@ -# 배치 분석 가이드 - -## 개요 -매일 아침 장 시작 전에 전체 종목을 분석하여 DB에 캐시합니다. -이를 통해 대시보드 첫 진입 시 빠른 로딩이 가능합니다. - -## 수동 실행 - -### 테스트 (10개 종목만) -```bash -cd backend -../.venv/bin/python batch_daily_analysis.py --test -``` - -### 제한된 개수 분석 -```bash -cd backend -../.venv/bin/python batch_daily_analysis.py --limit 30 -``` - -### 전체 분석 -```bash -cd backend -../.venv/bin/python batch_daily_analysis.py -``` - -## 자동 실행 (Cron) - -### Cron Job 설정 -```bash -./setup-cron.sh -``` - -이 스크립트는: -- 매일 평일(월~금) 오전 8시 50분에 배치 분석 실행 -- 장 시작 전(9시) 데이터를 준비 -- 로그는 `logs/batch_analysis.log`에 저장 - -### Cron Job 확인 -```bash -crontab -l | grep batch_daily_analysis -``` - -### Cron Job 제거 -```bash -crontab -e -# 해당 라인 삭제 후 저장 -``` - -## 로그 확인 - -### 실시간 로그 보기 -```bash -tail -f logs/batch_analysis.log -``` - -### 최근 로그 확인 -```bash -tail -100 logs/batch_analysis.log -``` - -## 동작 방식 - -1. **8:50 AM**: Cron이 배치 스크립트 실행 -2. **종목 조회**: KOSPI + KOSDAQ 거래량 상위 종목 -3. **기술적 분석**: 각 종목의 RSI, MA, 볼린저 밴드 등 계산 -4. **DB 저장**: `stock_analysis` 테이블에 저장 -5. **9:00 AM**: 장 시작 -6. **첫 진입**: DB에서 캐시된 데이터를 빠르게 로드 (1초 이내) - -## API 제한 대응 - -한국투자증권 API는 다음 제한이 있습니다: -- 토큰 발급: 1분에 1회 -- API 호출: 초당 2회 권장 - -배치 스크립트는: -- 각 종목 분석 후 2초 대기 -- API 에러 시 자동 재시도 (최대 3회) -- 지수 백오프로 대기 시간 증가 - -## 예상 소요 시간 - -- 10개 종목: 약 30초 -- 30개 종목: 약 2분 -- 60개 종목 (전체): 약 4분 - -## 문제 해결 - -### Cron이 실행되지 않음 -```bash -# Cron 데몬 상태 확인 (macOS) -sudo launchctl list | grep cron - -# Cron 데몬 시작 (macOS) -sudo launchctl start com.vix.cron -``` - -### API 토큰 에러 -로그에 "접근토큰 발급 잠시 후 다시 시도하세요" 메시지가 반복되면: -- 배치 스크립트가 너무 빨리 실행됨 -- `batch_daily_analysis.py`의 `await asyncio.sleep(2)` 값을 3~5초로 증가 - -### DB 연결 에러 -```bash -# PostgreSQL 실행 확인 -pg_isready -h localhost -p 5432 - -# 서비스 시작 -./start-all.sh -``` - -## 참고 -- 배치 분석은 선택사항입니다 -- 실행하지 않아도 첫 진입 시 실시간 분석이 자동으로 수행됩니다 -- 다만 실시간 분석은 10~30초 소요됩니다 diff --git a/docs/BATCH_GUIDE.md b/docs/BATCH_GUIDE.md index de2e233..0a0e97c 100644 --- a/docs/BATCH_GUIDE.md +++ b/docs/BATCH_GUIDE.md @@ -1,101 +1,131 @@ -# 배치 분석 시스템 사용 가이드 +# 배치 분석 가이드 + +> 2026-09-27 정리: `BATCH_GUIDE.md` 와 `BATCH_ANALYSIS_GUIDE.md` 두 문서를 하나로 합쳤다. +> 두 문서가 종목 수(200 vs 60)·cron 시각(09:00 vs 08:50)·실행 스크립트를 서로 다르게 적고 있었고, +> 양쪽 모두 **존재하지 않는 스크립트**(`run-batch.sh`, `start-all.sh`)를 안내하고 있었다. +> 아래 내용은 실제 코드(`setup-cron.sh`, `backend/batch_daily_analysis.py`, `config.py`)를 확인해 작성했다. ## 개요 -매일 아침 전체 종목을 분석하여 DB에 저장하는 배치 시스템입니다. -API 응답 속도가 **30초 → 0.1초**로 300배 빨라집니다! +장 시작 전에 전체 종목을 분석해 `stock_analysis` 테이블에 캐시한다. 대시보드 첫 진입 시 실시간 분석(30초)을 기다리지 않고 DB에서 바로 읽는다. -## 사용 방법 +배치는 **선택사항**이다. 실행하지 않아도 첫 진입 시 실시간 분석이 자동 수행되며, 그 경우 10~30초가 걸린다. -### 1. 테스트 실행 (10개 종목만) -```bash -./run-batch.sh --test -``` +## 무엇이 언제 도는가 -### 2. 일부 종목만 분석 (예: 50개) -```bash -./run-batch.sh --limit 50 -``` +`setup-cron.sh` 가 등록하는 cron 잡은 **3개**다(모두 평일, `docker exec autostock-backend` 경유). -### 3. 전체 종목 분석 (실제 운영) -```bash -./run-batch.sh -``` +| 시각 | 스크립트 | 로그 | +|------|----------|------| +| 06:00 | `prefetch_token.py` | `logs/token_prefetch.log` | +| 08:50 | `batch_daily_analysis.py` | `logs/batch_analysis.log` | +| 09:00~15:00 매 10분 | `batch_intraday_analysis.py` | `logs/batch_intraday.log` | -또는 +토큰 프리페치가 06:00에 먼저 도는 이유: KIS 토큰 발급은 1분에 1회 제한이라, 08:50 배치가 토큰 발급부터 시작하면 실패 시 재시도 여유가 없다. + +## 수동 실행 + +컨테이너 밖에서 돌릴 때는 프로젝트 venv 를 쓴다. ```bash cd backend -python batch_daily_analysis.py + +# 테스트 (10종목) +../.venv/bin/python batch_daily_analysis.py --test + +# 개수 제한 +../.venv/bin/python batch_daily_analysis.py --limit 30 + +# 전체 +../.venv/bin/python batch_daily_analysis.py ``` -## Cron 설정 (자동 실행) - -매일 아침 9시에 자동 실행: +컨테이너 안에서: ```bash -# crontab 편집 -crontab -e - -# 아래 내용 추가 (경로는 실제 경로로 수정) -0 9 * * 1-5 cd /Users/commylife/myproject/java/workspace/auto-stock && ./run-batch.sh >> logs/batch.log 2>&1 +docker exec autostock-backend python /app/batch_daily_analysis.py --test ``` -## API 사용 +인자는 `--test`(10종목)와 `--limit N` 두 개뿐이다. + +## 자동 실행 -### 빠른 모드 (DB 조회 - 0.1초) ```bash +./setup-cron.sh # 3개 잡 등록 (이미 있으면 건너뜀) +crontab -l | grep batch # 확인 +crontab -e # 제거는 해당 라인 삭제 +``` + +`setup-cron.sh` 는 `docker` 명령을 찾지 못하면 중단한다. 컨테이너명은 `autostock-backend` 로 고정되어 있다. + +## 로그 + +```bash +tail -f logs/batch_analysis.log +tail -100 logs/batch_analysis.log +``` + +## 동작 방식 + +1. 종목 목록 조회 — 네이버 시가총액 순위(`naver_finance`, KOSPI+KOSDAQ). 실패 시 `stock_daily_prices` 최근 거래일 기준으로 폴백 +2. 종목별 기술적 분석 — RSI, MA5/20/60, 볼린저, 스토캐스틱 등 +3. `stock_analysis` 테이블에 upsert +4. 장 시작 후 첫 진입은 이 캐시를 읽는다 + +분석 종목 수는 `config.py` 의 `ANALYSIS_STOCK_COUNT`(기본 **300**)가 결정한다. 이 값은 `strategy_configs` DB 테이블로도 덮일 수 있다 — CLAUDE.md §0 파라미터 우선순위 참고. + +## 소요 시간 + +종목당 API 대기가 있어 개수에 거의 비례한다. 10종목 약 30초, 30종목 약 2분 기준으로 추정하면 된다. 정확한 값은 `logs/batch_analysis.log` 의 실제 실행 기록을 보는 것이 맞다. + +## API 제한 대응 + +KIS API 제한: +- 토큰 발급 1분에 1회 +- 호출 초당 2회 권장 + +배치 쪽 대응: +- 종목 분석 사이 대기 +- 에러 시 지수 백오프 재시도 + +## 조회 API + +전역 프리픽스는 `/api/v1` 이다. + +```bash +# 빠른 조회 (DB 캐시) GET /api/v1/recommendations/rule-based -``` -### 실시간 모드 (KIS API - 30초) -```bash +# 실시간 분석 (느림) GET /api/v1/recommendations/rule-based?realtime=true +GET /api/v1/recommendations/rule-based/realtime # 분석 후 DB upsert + 반환 ``` -## 프론트엔드 버튼 추가 예시 +프론트엔드는 기본을 DB 조회로 두고, 실시간은 명시적 버튼으로 분리하는 것이 좋다. -```typescript -// 실시간 새로고침 버튼 - +## `stock_analysis` 주요 컬럼 -// 기본은 DB 조회 (빠름) - +`backend/app/models/models.py` 의 `StockAnalysis` 가 정본이다. + +``` +stock_code, stock_name, analysis_date, current_price, change_rate, volume, +rsi, ma5, ma20, ma60, stochastic_k, stochastic_d, signals(JSONB), score, ... ``` -## 데이터베이스 스키마 +## 문제 해결 -```sql -CREATE TABLE stock_analysis ( - id SERIAL PRIMARY KEY, - stock_code VARCHAR(20), - stock_name VARCHAR(100), - analysis_date DATE, - current_price INTEGER, - rsi NUMERIC(10,4), - ma5 NUMERIC(15,2), - ma20 NUMERIC(15,2), - signals JSONB, - score INTEGER, - ... -); +**cron 이 실행되지 않음** (macOS) +```bash +sudo launchctl list | grep cron ``` +cron 잡이 `docker exec` 를 쓰므로 Docker Desktop 이 떠 있어야 한다. -## 장점 +**"접근토큰 발급 잠시 후 다시 시도하세요" 반복** +토큰 발급 1분 제한에 걸린 것이다. 06:00 프리페치 잡이 등록됐는지 먼저 확인하고, 수동 실행을 연달아 하지 않는다. +⚠️ 로컬에서 배치를 돌릴 때 NAS 와 같은 앱키를 쓰면 **서로 토큰을 무효화**한다. 로컬 실행은 앱키를 분리하거나 피한다. -- ✅ **속도**: 30초 → 0.1초 (300배 빠름) -- ✅ **안정성**: KIS API Rate Limit 회피 -- ✅ **편의성**: 사용자가 기다릴 필요 없음 -- ✅ **확장성**: 과거 데이터 추적 가능 -- ✅ **분석**: 백테스팅, 통계 분석 가능 - -## 주의사항 - -- 배치 실행 시간: 약 5-10분 (전체 200개 종목 기준) -- KIS API Rate Limit: 초당 1건 제한 -- 데이터는 매일 아침 9시 기준 -- 장중 실시간 가격은 `realtime=true` 옵션 사용 +**DB 연결 에러** +```bash +docker compose ps +docker compose logs autostock-db --tail 50 +``` diff --git a/REFACTORING_PLAN.md b/docs/REFACTORING_PLAN.md similarity index 93% rename from REFACTORING_PLAN.md rename to docs/REFACTORING_PLAN.md index 22fbff6..6b08ef4 100644 --- a/REFACTORING_PLAN.md +++ b/docs/REFACTORING_PLAN.md @@ -1,5 +1,15 @@ # AutoStock 리팩토링 마스터 플랜 +> ⚠️ **폐기됨 (2026-09-27)** +> 이 계획이 목표로 삼은 `app/strategy/` 패키지(`strategy_manager`, `base_strategy`, `signal`, +> `indicators`, `scanner`, `strategies/`)는 **어디서도 import되지 않는 죽은 코드로 확인되어 삭제**되었습니다. +> `short_term_strategy.py`, `kosdaq_scanner.py` 도 함께 제거되었습니다(2.5 항목 무효). +> 아래 내용은 이력 참고용이며, 현재 구조는 `CLAUDE.md` §2 를 따릅니다. +> 전략 구조를 다시 손대려면 `CLAUDE.md` §8.5(미해결 항목)부터 확인하십시오. + +--- + + > **목표**: 현재 엉망진창인 구조를 유지보수 가능한 모듈형 아키텍처로 전환 > **원칙**: 기존 코드는 버리지 않고 재배치 + 추상화 레이어 추가 > **일정**: 2026년 3월 ~ 4월 (단계별 점진적 리팩토링) diff --git a/RISK_MANAGEMENT_API.md b/docs/RISK_MANAGEMENT_API.md similarity index 100% rename from RISK_MANAGEMENT_API.md rename to docs/RISK_MANAGEMENT_API.md diff --git a/SPEED_OPTIMIZATION.md b/docs/SPEED_OPTIMIZATION.md similarity index 100% rename from SPEED_OPTIMIZATION.md rename to docs/SPEED_OPTIMIZATION.md