From c1fbe192347c79bf4bbddb5ebddcf7803dbaa07c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=8B=A0=EB=8C=80=EC=8A=B9?= Date: Sun, 15 Mar 2026 17:05:27 +0900 Subject: [PATCH 1/6] docs: add engine.py decomposition plan (C2) 4-phase plan to reduce engine.py from 6689 to ~1900 lines: - Phase A: constants, models, allocator extraction (zero risk) - Phase B: regime, risk gates, indicators, sizing - Phase C: 7 strategy modules to simulation/strategies/ - Phase D: dispatcher cleanup with strategy registry Co-Authored-By: Claude Opus 4.6 --- .../plans/2026-03-15-engine-decomposition.md | 1010 +++++++++++++++++ 1 file changed, 1010 insertions(+) create mode 100644 docs/superpowers/plans/2026-03-15-engine-decomposition.md diff --git a/docs/superpowers/plans/2026-03-15-engine-decomposition.md b/docs/superpowers/plans/2026-03-15-engine-decomposition.md new file mode 100644 index 0000000..263546e --- /dev/null +++ b/docs/superpowers/plans/2026-03-15-engine-decomposition.md @@ -0,0 +1,1010 @@ +# Engine.py Decomposition (C2) Implementation Plan + +> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Decompose `ats/simulation/engine.py` (6689 lines) into focused modules, reducing it to ~1800 lines of orchestration while preserving all behavior and backtest compatibility. + +**Architecture:** Extract constants, Pydantic models, StrategyAllocator, regime logic, risk gates, sizing, and per-strategy scan/exit methods into separate modules under `ats/simulation/`. The engine retains orchestration (`run_backtest_day`, `run_cycle`), execution (`_execute_buy/sell`), state management, and SSE broadcast. No behavioral changes — pure structural refactor. + +**Tech Stack:** Python 3.9+, Pydantic, NumPy, Pandas, asyncio + +**Validation:** After each phase, run `cd /Users/daniel/dev/atm-dev && python3 run_tests.py` (58 tests) + verify backtest produces identical results. + +--- + +## File Structure + +After decomposition, `ats/simulation/` will contain: + +| File | Responsibility | Source Lines | Est. Lines | +|------|---------------|-------------|------------| +| `constants.py` | All dict constants, watchlist, type alias | 152-494 | ~345 | +| `models.py` | 8 Pydantic models (SimPosition, SimSignal, etc.) | 23-151 | ~130 | +| `allocator.py` | StrategyAllocator class + `_compute_adx` helper | 497-823 | ~330 | +| `regime.py` | Market regime, stock regime, index trend, VIX | 1517-1860 + 2047-2142 | ~500 | +| `risk_gates.py` | RG1-RG5, bearish divergence, S/R detection | 2144-2215, 2244-2340 | ~170 | +| `sizing.py` | Position sizing pure functions (ATR, multipliers) | Extracted from `_execute_buy` | ~120 | +| `indicators.py` | `_calculate_indicators*`, `_confirm_trend`, `_estimate_trend_stage` | 1426-1515, 1938-2046 | ~200 | +| `strategies/momentum.py` | `_scan_entries_momentum` + `_check_exits_momentum` | 2789-3064, 5866-6055 | ~460 | +| `strategies/smc.py` | SMC scan + exit + 4 scoring methods + indicators | 3065-3430 | ~370 | +| `strategies/mean_reversion.py` | MR scan + exit + 3 scoring + indicators | 3432-3892 | ~460 | +| `strategies/breakout_retest.py` | BRT scan + exit + scoring + conditions + zones | 3892-4549 | ~660 | +| `strategies/arbitrage.py` | Arb scan + exit + pairs + basis + sizing | 4550-5563 | ~1015 | +| `strategies/defensive.py` | Defensive scan + exit | 2514-2654 | ~140 | +| `strategies/volatility.py` | Volatility premium scan + exit | 2654-2789 | ~135 | +| `engine.py` | Orchestration, execution, state, SSE broadcast | Remainder | ~1800 | + +**Existing files unchanged:** `controller.py`, `event_bus.py`, `universe.py`, `watchlists.py`, `portfolio_allocator.py` + +--- + +## Chunk 1: Phase A — Zero-Risk Extractions + +### Task 1: Extract constants.py + +**Files:** +- Create: `ats/simulation/constants.py` +- Modify: `ats/simulation/engine.py:152-494` + +- [ ] **Step 1: Create constants.py with all dict constants** + +Extract these from engine.py lines 152-494: +```python +# ats/simulation/constants.py +""" +Simulation engine constants: regime parameters, strategy weights, ETF universes. +""" +from __future__ import annotations +from typing import Any, Callable, Coroutine, Dict, List + +# ── 워치리스트 ── +WATCHLIST = [ + {"code": "005930", "ticker": "005930.KS", "name": "삼성전자"}, + # ... (전체 복사) +] + +OnEventType = Callable[[str, Any], Coroutine[Any, Any, None]] + +REGIME_PARAMS = { ... } +STOCK_REGIME_THRESHOLDS = [ ... ] +REGIME_EXIT_PARAMS = { ... } +BASE_KELLY: float = 0.50 +REGIME_OVERRIDES: Dict[str, Dict[str, Any]] = { ... } +VIX_SIZING_SCALE = { ... } +REGIME_STRATEGY_WEIGHTS: Dict[str, Dict[str, float]] = { ... } +INDEX_TREND_STRATEGY_WEIGHTS: Dict[str, Dict[str, float]] = { ... } +REGIME_DISPLAY_NAMES: Dict[str, Dict[str, str]] = { ... } +STRATEGY_DISPLAY_NAMES: Dict[str, Dict[str, str]] = { ... } +REGIME_STRATEGY_COMPOSITION: Dict[str, Dict] = { ... } +INVERSE_ETFS = { ... } +SAFE_HAVEN_ETFS: Dict[str, List[Dict[str, str]]] = { ... } +MULTI_STRATEGIES = [...] +REGIME_STRATEGY_MODES: Dict[str, str] = { ... } +``` + +- [ ] **Step 2: Replace engine.py constants with imports** + +In engine.py, remove lines 152-494 and add at top: +```python +from simulation.constants import ( + WATCHLIST, OnEventType, + REGIME_PARAMS, STOCK_REGIME_THRESHOLDS, REGIME_EXIT_PARAMS, + BASE_KELLY, REGIME_OVERRIDES, VIX_SIZING_SCALE, + REGIME_STRATEGY_WEIGHTS, INDEX_TREND_STRATEGY_WEIGHTS, + REGIME_DISPLAY_NAMES, STRATEGY_DISPLAY_NAMES, + REGIME_STRATEGY_COMPOSITION, + INVERSE_ETFS, SAFE_HAVEN_ETFS, + MULTI_STRATEGIES, REGIME_STRATEGY_MODES, +) +``` + +- [ ] **Step 3: Fix external imports of constants** + +Check `ats/scripts/analyze_market_today.py` — it imports `INDEX_TREND_STRATEGY_WEIGHTS, REGIME_STRATEGY_WEIGHTS` from `simulation.engine`. Update to import from `simulation.constants`. + +Also check `ats/simulation/controller.py` — it defines its own `OnEventType` at line 29. Update it to import from `simulation.constants` instead. + +- [ ] **Step 4: Run tests** + +Run: `cd /Users/daniel/dev/atm-dev && python3 run_tests.py` +Expected: 58/58 PASS (no behavioral change) + +- [ ] **Step 5: Commit** + +```bash +git add ats/simulation/constants.py ats/simulation/engine.py ats/scripts/analyze_market_today.py +git commit -m "refactor: extract simulation constants to dedicated module" +``` + +--- + +### Task 2: Extract models.py + +**Files:** +- Create: `ats/simulation/models.py` +- Modify: `ats/simulation/engine.py:23-151` + +- [ ] **Step 1: Create models.py with all 8 Pydantic models** + +Extract lines 23-151 from engine.py: +```python +# ats/simulation/models.py +""" +Pydantic models for simulation state serialization (frontend SSE + backtest). +""" +from __future__ import annotations +from typing import Any, Dict, List, Optional +from pydantic import BaseModel + + +class SimSystemState(BaseModel): ... +class SimPosition(BaseModel): ... +class SimOrder(BaseModel): ... +class SimSignal(BaseModel): ... +class SimRiskMetrics(BaseModel): ... +class SimTradeRecord(BaseModel): ... +class SimEquityPoint(BaseModel): ... +class SimPerformanceSummary(BaseModel): ... +``` + +Copy all field definitions exactly as-is from engine.py lines 23-151. + +- [ ] **Step 2: Replace engine.py model definitions with imports** + +Remove lines 23-151 from engine.py, add: +```python +from simulation.models import ( + SimSystemState, SimPosition, SimOrder, SimSignal, + SimRiskMetrics, SimTradeRecord, SimEquityPoint, + SimPerformanceSummary, +) +``` + +- [ ] **Step 3: Check external model imports** + +Search for any files importing these models from `simulation.engine` and update: +```bash +grep -rn "from simulation.engine import Sim" ats/ +``` +Update each to import from `simulation.models`. + +- [ ] **Step 4: Run tests** + +Run: `cd /Users/daniel/dev/atm-dev && python3 run_tests.py` +Expected: 58/58 PASS + +- [ ] **Step 5: Commit** + +```bash +git add ats/simulation/models.py ats/simulation/engine.py +git commit -m "refactor: extract Pydantic simulation models to dedicated module" +``` + +--- + +### Task 3: Extract allocator.py + +**Files:** +- Create: `ats/simulation/allocator.py` +- Modify: `ats/simulation/engine.py:497-823` + +- [ ] **Step 1: Create allocator.py** + +Extract `StrategyAllocator` class (lines 497-795) and `_compute_adx()` function (lines 797-823): + +```python +# ats/simulation/allocator.py +""" +Multi-strategy capital allocator with regime-based weights, Kelly scaling, and correlation tracking. +""" +from __future__ import annotations +from typing import Dict, List, Optional +import numpy as np + +from simulation.constants import REGIME_STRATEGY_WEIGHTS + + +class StrategyAllocator: + """멀티 전략 모드용 전략별 자본 배분 관리자.""" + # ... (전체 클래스 복사) + + +def _compute_adx(high, low, close, period: int = 14): + """Standalone ADX calculation (used by regime classification).""" + # ... (함수 복사) +``` + +- [ ] **Step 2: Update engine.py imports** + +Remove lines 497-823, add: +```python +from simulation.allocator import StrategyAllocator, _compute_adx +``` + +- [ ] **Step 3: Run tests** + +Run: `cd /Users/daniel/dev/atm-dev && python3 run_tests.py` +Expected: 58/58 PASS + +- [ ] **Step 4: Commit** + +```bash +git add ats/simulation/allocator.py ats/simulation/engine.py +git commit -m "refactor: extract StrategyAllocator to dedicated module" +``` + +--- + +## Chunk 2: Phase B — Regime, Risk, Indicators, Sizing + +### Task 4: Extract regime.py + +**Files:** +- Create: `ats/simulation/regime.py` +- Modify: `ats/simulation/engine.py` + +- [ ] **Step 1: Create regime.py with regime-related methods as standalone functions** + +Extract these methods, converting from `self.` to explicit parameters: + +```python +# ats/simulation/regime.py +""" +Market regime classification: breadth-based regime judgment, smoothing, +index trend analysis, per-stock regime scoring. +""" +from __future__ import annotations +from typing import Any, Dict, List, Optional +import numpy as np +import pandas as pd + +from simulation.constants import ( + REGIME_PARAMS, STOCK_REGIME_THRESHOLDS, REGIME_OVERRIDES, + INDEX_TREND_STRATEGY_WEIGHTS, REGIME_STRATEGY_WEIGHTS, +) +from simulation.allocator import _compute_adx + + +def judge_market_regime( + ohlcv_cache: Dict[str, pd.DataFrame], + watchlist: list, + vix_level: float = 18.0, +) -> str: + """Breadth-based regime classification. Returns regime string.""" + # Adapt from engine.py _judge_market_regime() (lines 1517-1623) + # Replace self._ohlcv_cache → ohlcv_cache, self._watchlist → watchlist, etc. + ... + + +def smooth_regime( + raw_regime: str, + candidate: str, + candidate_days: int, + confirmation_days: int = 5, +) -> tuple: + """5-day regime confirmation. Returns (smoothed_regime, new_candidate, new_days).""" + # Adapt from engine.py _smooth_regime() (lines 1624-1640) + ... + + +def analyze_index_trend( + index_ohlcv: List[Dict], + market_regime: str, +) -> Dict: + """SPX/KOSPI index trend analysis. Returns trend dict.""" + # Adapt from engine.py _analyze_index_trend() (lines 1684-1806) + ... + + +def get_index_strategy_weights( + trend_result: Dict, +) -> Optional[Dict[str, float]]: + """Map index trend to strategy weight overrides.""" + # Adapt from engine.py _update_strategy_weights_from_index() (lines 1807-1860) + # Return the weights dict instead of mutating engine state + ... + + +def classify_stock_regime( + df: pd.DataFrame, +) -> str: + """Per-stock composite regime scoring (0-100 → 6 tiers).""" + # Adapt from engine.py _classify_stock_regime() (lines 2047-2114) + ... +``` + +- [ ] **Step 2: Add thin delegation methods on SimulationEngine** + +In engine.py, replace the extracted methods with thin wrappers: +```python +def _judge_market_regime(self) -> str: + return regime.judge_market_regime( + self._ohlcv_cache, self._watchlist, self._vix_level, + ) + +def _smooth_regime(self, raw_regime: str) -> str: + result, self._regime_candidate, self._regime_candidate_days = regime.smooth_regime( + raw_regime, self._regime_candidate, + self._regime_candidate_days, self._regime_confirmation_days, + ) + return result + +def _analyze_index_trend(self) -> Dict: + return regime.analyze_index_trend(self._index_ohlcv, self._market_regime) + +def _classify_stock_regime(self, df: pd.DataFrame) -> str: + return regime.classify_stock_regime(df) +``` + +This preserves the existing call pattern while delegating logic. + +**Methods that remain on engine (NOT extracted):** +- `_update_market_regime()` (1641-1652) — orchestrates regime detection, mutates `self._market_regime` +- `update_vix()` (1654-1666) — VIX history management, backtest API +- `update_index_data()` (1672-1682) — index OHLCV buffer, backtest API +- `_update_stock_regimes()` (2115-2138) — 7-day caching orchestration, calls `classify_stock_regime()` +- `get_market_intelligence()` (1861-1886) — UI-facing aggregation +- `_reduce_positions_for_regime()` (1917-1932) — regime downgrade position cleanup +- `_get_vix_sizing_mult()` (1888-1916) — moved to sizing.py (see Task 7) + +- [ ] **Step 3: Run tests** + +Run: `cd /Users/daniel/dev/atm-dev && python3 run_tests.py` +Expected: 58/58 PASS + +- [ ] **Step 4: Commit** + +```bash +git add ats/simulation/regime.py ats/simulation/engine.py +git commit -m "refactor: extract market regime logic to simulation/regime.py" +``` + +--- + +### Task 5: Extract risk_gates.py + +**Files:** +- Create: `ats/simulation/risk_gates.py` +- Modify: `ats/simulation/engine.py` + +- [ ] **Step 1: Create risk_gates.py** + +Extract lines 2144-2215 (`_risk_gate_check`) and 2244-2340 (divergence/S/R detection). + +**Methods that remain on engine (NOT extracted):** +- `_force_liquidate_all()` (2216-2221) — mutates `self.positions`, calls `self._execute_sell()` +- `force_liquidate_all_immediate()` (2223-2242) — public API, same mutations + +```python +# ats/simulation/risk_gates.py +""" +Risk gates RG1-RG5: daily loss, MDD, position limits, cash ratio, VIX extreme. +""" +from __future__ import annotations +from typing import Dict +import pandas as pd + +from simulation.constants import REGIME_PARAMS + + +def check_risk_gates( + daily_start_equity: float, + current_equity: float, + peak_equity: float, + initial_capital: float, + active_position_count: int, + cash: float, + market_regime: str, + vix_level: float = 18.0, + vix_ema20: float = 18.0, +) -> tuple: + """ + Run RG1-RG5 risk gates. + Returns: (can_trade: bool, reason: str) + """ + # Adapt from engine.py _risk_gate_check() (lines 2144-2215) + ... + + +def detect_bearish_divergence(df: pd.DataFrame, lookback: int = 10) -> bool: + """Detect RSI/MACD bearish divergence.""" + # Adapt from engine.py _detect_bearish_divergence() (lines 2244-2271) + ... + + +def detect_support_resistance(df: pd.DataFrame, lookback: int = 40) -> dict: + """Detect support/resistance levels via clustering.""" + # Adapt from engine.py _detect_support_resistance() (lines 2272-2301) + ... + + +def cluster_levels(levels: list, tolerance: float = 0.015) -> list: + """Cluster price levels by tolerance.""" + # Adapt from engine.py _cluster_levels() (lines 2302-2336) + ... +``` + +- [ ] **Step 2: Update engine.py with delegation** + +Replace the extracted methods with thin wrappers calling `risk_gates.*` functions. Keep `_force_liquidate_all()` and `force_liquidate_all_immediate()` on engine as-is. + +- [ ] **Step 3: Run tests** + +Run: `cd /Users/daniel/dev/atm-dev && python3 run_tests.py` +Expected: 58/58 PASS + +- [ ] **Step 4: Commit** + +```bash +git add ats/simulation/risk_gates.py ats/simulation/engine.py +git commit -m "refactor: extract risk gates to simulation/risk_gates.py" +``` + +--- + +### Task 6: Extract indicators.py + +**Files:** +- Create: `ats/simulation/indicators.py` +- Modify: `ats/simulation/engine.py` + +- [ ] **Step 1: Create indicators.py** + +Extract base indicator calculation and trend helpers: + +```python +# ats/simulation/indicators.py +""" +Technical indicator calculation for simulation engine. +MA, RSI, MACD, BB, ATR, ADX/DMI, Volume MA, OBV. +Trend confirmation and stage estimation. +""" +from __future__ import annotations +import numpy as np +import pandas as pd + + +def calculate_indicators( + df: pd.DataFrame, + ma_short: int = 5, + ma_long: int = 20, + rsi_period: int = 14, +) -> pd.DataFrame: + """Calculate base technical indicators on OHLCV DataFrame.""" + # Adapt from engine.py _calculate_indicators() (lines 1426-1515) + ... + + +def confirm_trend(df: pd.DataFrame) -> dict: + """Check MA alignment + ADX strength. Returns trend dict.""" + # Adapt from engine.py _confirm_trend() (lines 1938-1993) + ... + + +def estimate_trend_stage(df: pd.DataFrame) -> str: + """Classify trend as EARLY/MID/LATE.""" + # Adapt from engine.py _estimate_trend_stage() (lines 1994-2046) + ... +``` + +- [ ] **Step 2: Update engine.py — delegate to indicators module** + +- [ ] **Step 3: Run tests** + +Run: `cd /Users/daniel/dev/atm-dev && python3 run_tests.py` +Expected: 58/58 PASS + +- [ ] **Step 4: Commit** + +```bash +git add ats/simulation/indicators.py ats/simulation/engine.py +git commit -m "refactor: extract technical indicators to simulation/indicators.py" +``` + +--- + +### Task 7: Extract sizing.py + +**Files:** +- Create: `ats/simulation/sizing.py` +- Modify: `ats/simulation/engine.py` + +- [ ] **Step 1: Create sizing.py with pure sizing functions** + +Extract the position sizing logic from `_execute_buy()` (lines 5563-5811): + +```python +# ats/simulation/sizing.py +""" +Position sizing: ATR-based risk sizing with regime/signal/VIX multipliers. +""" +from __future__ import annotations +from typing import Dict, Optional + +from simulation.constants import ( + REGIME_PARAMS, REGIME_OVERRIDES, VIX_SIZING_SCALE, BASE_KELLY, +) + + +def get_vix_sizing_mult( + vix_ema20: float, + strategy: str = "momentum", +) -> float: + """VIX-based sizing multiplier (1.0 normal → 0.3 panic).""" + # Adapt from engine.py _get_vix_sizing_mult() (lines 1888-1916) + ... + + +def calculate_position_size( + price: float, + atr: float, + equity: float, + cash: float, + signal_strength: int, + market_regime: str, + trend_strength: str = "MODERATE", + trend_stage: str = "MID", + vix_ema20: float = 18.0, + strategy: str = "momentum", + kelly_scalar: float = 1.0, + vol_scalar: float = 1.0, + fixed_amount: float = 0, + min_cash_ratio: float = 0.30, + slippage_pct: float = 0.001, + commission_pct: float = 0.00015, +) -> dict: + """ + Calculate position quantity + buy_amount. + Returns: {"quantity": int, "buy_price": float, "buy_amount": float, "raw_qty": int} + """ + # Extract sizing logic from _execute_buy(), return computed values + # Engine keeps execution logic (position creation, order recording) + ... +``` + +- [ ] **Step 2: Refactor _execute_buy() to use sizing.calculate_position_size()** + +Engine's `_execute_buy()` (lines 5563-5811) has two halves: +- **Lines 5563-~5680**: Pure sizing computation (ATR lookup, multipliers, quantity calc, caps) → extract to `sizing.py` +- **Lines ~5680-5811**: Position creation, order recording, state mutation → stays on engine + +The boundary: everything before `pos = SimPosition(...)` is sizing; everything from `SimPosition(...)` onward is execution. + +Engine's `_execute_buy()` calls `sizing.calculate_position_size()` then handles position creation. Also move `_get_vix_sizing_mult()` (lines 1888-1916) to `sizing.py` as `get_vix_sizing_mult()` since it's a pure function of VIX/strategy params. + +- [ ] **Step 3: Run tests** + +Run: `cd /Users/daniel/dev/atm-dev && python3 run_tests.py` +Expected: 58/58 PASS + +- [ ] **Step 4: Commit** + +```bash +git add ats/simulation/sizing.py ats/simulation/engine.py +git commit -m "refactor: extract position sizing to simulation/sizing.py" +``` + +--- + +## Chunk 3: Phase C — Strategy Extraction + +### Task 8: Create strategies package + momentum + +**Files:** +- Create: `ats/simulation/strategies/__init__.py` +- Create: `ats/simulation/strategies/momentum.py` +- Modify: `ats/simulation/engine.py` + +- [ ] **Step 1: Create strategies package** + +```python +# ats/simulation/strategies/__init__.py +"""Per-strategy scan/exit modules for the simulation engine.""" +``` + +- [ ] **Step 2: Create momentum.py** + +Extract `_scan_entries_momentum()` (lines 2789-3064) and `_check_exits_momentum()` (lines 5866-6054): + +```python +# ats/simulation/strategies/momentum.py +""" +Momentum Swing strategy: 6-Phase pipeline scan + 7-tier exit cascade. +""" +from __future__ import annotations +from typing import TYPE_CHECKING +if TYPE_CHECKING: + from simulation.engine import SimulationEngine + + +def scan_entries(engine: 'SimulationEngine'): + """ + Phase 1-4 momentum entry scanning. + Mutates engine.signals list directly (same pattern as original). + """ + # Full copy of _scan_entries_momentum() logic + # Replace all `self.` with `engine.` + ... + + +def check_exits(engine: 'SimulationEngine'): + """ + Momentum exit cascade: ES1→ES2→ES3→ES4→ES5→ES6→ES7. + Calls engine._execute_sell() / engine._execute_partial_sell() directly. + """ + # Full copy of _check_exits_momentum() logic + # Replace all `self.` with `engine.` + ... +``` + +**Key pattern:** Each strategy module receives the engine instance as parameter, accessing its state directly. This avoids creating a complex context object while keeping the exact same behavior. + +- [ ] **Step 3: Update engine.py dispatcher** + +Replace the inline methods with imports: +```python +from simulation.strategies import momentum as strat_momentum + +def _scan_entries_momentum(self): + strat_momentum.scan_entries(self) + +def _check_exits_momentum(self): + strat_momentum.check_exits(self) +``` + +- [ ] **Step 4: Run tests** + +Run: `cd /Users/daniel/dev/atm-dev && python3 run_tests.py` +Expected: 58/58 PASS + +- [ ] **Step 5: Commit** + +```bash +git add ats/simulation/strategies/ ats/simulation/engine.py +git commit -m "refactor: extract momentum strategy to simulation/strategies/" +``` + +--- + +### Task 9: Extract SMC strategy + +**Files:** +- Create: `ats/simulation/strategies/smc.py` +- Modify: `ats/simulation/engine.py` + +- [ ] **Step 1: Create smc.py** + +Extract these methods (lines 3065-3430): +- `_scan_entries_smc()` → `scan_entries(engine)` +- `_calculate_indicators_smc()` → `calculate_indicators_smc(engine, df)` +- `_score_smc_bias()` → `score_smc_bias(df)` +- `_score_volatility()` → `score_volatility(df)` +- `_score_obv_signal()` → `score_obv_signal(df)` +- `_score_momentum_signal()` → `score_momentum_signal(df)` +- `_check_exits_smc()` → `check_exits(engine)` + +- [ ] **Step 2: Update engine.py with delegation stubs** + +- [ ] **Step 3: Run tests and commit** + +```bash +git commit -m "refactor: extract SMC strategy to simulation/strategies/smc.py" +``` + +--- + +### Task 10: Extract Mean Reversion strategy + +**Files:** +- Create: `ats/simulation/strategies/mean_reversion.py` +- Modify: `ats/simulation/engine.py` + +- [ ] **Step 1: Create mean_reversion.py** + +Extract lines 3432-3892 (ALL methods — scan AND exit): +- `_calculate_indicators_mean_reversion()` (3432-3469) → `calculate_indicators(engine, df)` +- `_score_mr_signal()` (3470-3502) → `score_mr_signal(df)` +- `_score_mr_volatility()` (3503-3543) → `score_mr_volatility(df)` +- `_score_mr_confirmation()` (3544-3583) → `score_mr_confirmation(df)` +- `_scan_entries_mean_reversion()` (3584-3712) → `scan_entries(engine)` +- `_check_exits_mean_reversion()` (3714-3892) → `check_exits(engine)` + +- [ ] **Step 2: Update engine.py with delegation stubs** + +- [ ] **Step 3: Run tests and commit** + +```bash +git commit -m "refactor: extract mean reversion strategy to simulation/strategies/" +``` + +--- + +### Task 11: Extract Breakout-Retest strategy + +**Files:** +- Create: `ats/simulation/strategies/breakout_retest.py` +- Modify: `ats/simulation/engine.py` + +- [ ] **Step 1: Create breakout_retest.py** + +Extract lines 3892-4549 — the largest strategy module (ALL methods — scan AND exit): +- `_calculate_indicators_breakout_retest()` (3892-3910) → `calculate_indicators(engine, df)` +- `_score_brt_structure()` (3911-3937) → `score_structure(df)` +- `_score_brt_volatility()` (3938-3967) → `score_volatility(df)` +- `_score_brt_obv()` (3968-3987) → `score_obv(df)` +- `_score_brt_momentum()` (3988-4023) → `score_momentum(df)` +- `_check_brt_six_conditions()` (4024-4075) → `check_six_conditions(df)` +- `_apply_brt_fakeout_filters()` (4076-4109) → `apply_fakeout_filters(df)` +- `_capture_brt_retest_zones()` (4110-4172) → `capture_retest_zones(df, price, atr)` +- `_scan_entries_breakout_retest()` (4173-4373) → `scan_entries(engine)` +- `_score_brt_retest_zone()` (4374-4406) → `score_retest_zone(df, state)` +- `_check_exits_breakout_retest()` (4407-4549) → `check_exits(engine)` + +- [ ] **Step 2: Update engine.py with delegation stubs** + +- [ ] **Step 3: Run tests and commit** + +```bash +git commit -m "refactor: extract breakout-retest strategy to simulation/strategies/" +``` + +--- + +### Task 12: Extract Arbitrage strategy + +**Files:** +- Create: `ats/simulation/strategies/arbitrage.py` +- Modify: `ats/simulation/engine.py` + +- [ ] **Step 1: Create arbitrage.py** + +Extract lines 4550-5563 — the most complex strategy (ALL methods — scan AND exit): +- `_discover_pairs()` (4550-4683) → `discover_pairs(engine)` +- `_load_fixed_pairs()` (4684-4775) → `load_fixed_pairs(engine)` +- `_check_basis_gate()` (4776-4902) → `check_basis_gate(engine)` +- `_score_arb_correlation()` (4903-4939) → `score_correlation(pair)` +- `_score_arb_spread()` (4940-4981) → `score_spread(pair)` +- `_score_arb_volume()` (4982-5020) → `score_volume(pair)` +- `_calculate_arb_ev()` (5021-5080) → `calculate_ev(pair)` +- `_size_arb_pair()` (5081-5110) → `size_pair(price_a, price_b, score)` — stays in arb module (not sizing.py) because it's pair-specific dollar-neutral logic +- `_scan_entries_arbitrage()` (5111-5356) → `scan_entries(engine)` +- `_check_exits_arbitrage()` (5357-5563) → `check_exits(engine)` + +**Note:** This module calls `engine._execute_buy_arb()` and `engine._execute_sell_short()` which remain on the engine. + +- [ ] **Step 2: Update engine.py with delegation stubs** + +- [ ] **Step 3: Run tests and commit** + +```bash +git commit -m "refactor: extract arbitrage strategy to simulation/strategies/" +``` + +--- + +### Task 13: Extract Defensive + Volatility strategies + +**Files:** +- Create: `ats/simulation/strategies/defensive.py` +- Create: `ats/simulation/strategies/volatility.py` +- Modify: `ats/simulation/engine.py` + +- [ ] **Step 1: Create defensive.py** + +Extract lines 2514-2654: +- `_scan_entries_defensive()` → `scan_entries(engine)` +- `_check_exits_defensive()` → `check_exits(engine)` + +- [ ] **Step 2: Create volatility.py** + +Extract lines 2654-2789: +- `_scan_entries_volatility()` → `scan_entries(engine)` +- `_check_exits_volatility()` → `check_exits(engine)` + +- [ ] **Step 3: Update engine.py dispatchers** + +- [ ] **Step 4: Run tests and commit** + +```bash +git commit -m "refactor: extract defensive + volatility strategies to simulation/strategies/" +``` + +--- + +## Chunk 4: Phase D — Cleanup and Verification + +### Task 14: Clean up engine.py dispatcher methods + +**Files:** +- Modify: `ats/simulation/engine.py` + +- [ ] **Step 1: Replace all inline strategy delegation stubs with a strategy registry** + +```python +# At top of engine.py +from simulation.strategies import ( + momentum as strat_momentum, + smc as strat_smc, + mean_reversion as strat_mr, + breakout_retest as strat_brt, + arbitrage as strat_arb, + defensive as strat_def, + volatility as strat_vol, +) + +# Strategy dispatch registry +_STRATEGY_MODULES = { + "momentum": strat_momentum, + "smc": strat_smc, + "mean_reversion": strat_mr, + "breakout_retest": strat_brt, + "arbitrage": strat_arb, + "defensive": strat_def, + "volatility": strat_vol, +} +``` + +- [ ] **Step 2: Simplify _scan_entries() and _check_exits() dispatchers** + +```python +def _scan_entries(self): + if self.strategy_mode == "multi" or self.strategy_mode in REGIME_STRATEGY_MODES: + self._scan_entries_multi() + else: + module = _STRATEGY_MODULES.get(self.strategy_mode) + if module: + module.scan_entries(self) + +def _check_exits(self): + if self.strategy_mode == "multi" or self.strategy_mode in REGIME_STRATEGY_MODES: + self._check_exits_multi() + else: + module = _STRATEGY_MODULES.get(self.strategy_mode) + if module: + module.check_exits(self) +``` + +- [ ] **Step 3: Update _scan_entries_multi() to use registry** + +Replace hardcoded strategy method calls with registry dispatch: +```python +def _scan_entries_multi(self): + for strategy_name in active_strategies: + module = _STRATEGY_MODULES.get(strategy_name) + if module: + self._exit_tag_filter = strategy_name + module.scan_entries(self) +``` + +- [ ] **Step 4: Fix strategy_mode mutation hack in BOTH multi methods** + +Replace the `self.strategy_mode` swapping in `_scan_entries_multi()` AND `_check_exits_multi()` with direct module dispatch: + +```python +def _scan_entries_multi(self): + # ... (existing collect_mode / signal aggregation logic stays) + for strategy_name in active_strategies: + module = _STRATEGY_MODULES.get(strategy_name) + if module and self._strategy_allocator.is_active(strategy_name): + self._exit_tag_filter = strategy_name + module.scan_entries(self) + self._exit_tag_filter = None + # ... (existing signal sorting / dedup logic stays) + +def _check_exits_multi(self): + strategy_tags = set(pos.strategy_tag for pos in self.positions.values() if pos.status == "ACTIVE") + for tag in strategy_tags: + module = _STRATEGY_MODULES.get(tag) + if module: + self._exit_tag_filter = tag + module.check_exits(self) + self._exit_tag_filter = None +``` + +- [ ] **Step 5: Run tests** + +Run: `cd /Users/daniel/dev/atm-dev && python3 run_tests.py` +Expected: 58/58 PASS + +- [ ] **Step 6: Commit** + +```bash +git add ats/simulation/engine.py +git commit -m "refactor: simplify engine dispatchers with strategy registry" +``` + +--- + +### Task 15: Verify engine.py line count and backtest parity + +**Files:** +- No file changes — verification only + +- [ ] **Step 1: Count engine.py lines** + +Run: `wc -l ats/simulation/engine.py` +Expected: ~1800-2200 lines (down from 6689) + +- [ ] **Step 2: Verify all simulation/ modules exist** + +```bash +ls -la ats/simulation/ +# Expected: __init__.py, allocator.py, constants.py, controller.py, engine.py, +# event_bus.py, indicators.py, models.py, portfolio_allocator.py, +# regime.py, risk_gates.py, sizing.py, strategies/, universe.py, watchlists.py +ls -la ats/simulation/strategies/ +# Expected: __init__.py, arbitrage.py, breakout_retest.py, defensive.py, +# mean_reversion.py, momentum.py, smc.py, volatility.py +``` + +- [ ] **Step 3: Run full test suite** + +Run: `cd /Users/daniel/dev/atm-dev && python3 run_tests.py` +Expected: 58/58 PASS + +- [ ] **Step 4: Run a quick backtest to verify identical results** + +```bash +cd /Users/daniel/dev/atm-dev && python3 -c " +from ats.backtest.historical_engine import HistoricalBacktester +bt = HistoricalBacktester(market='sp500', strategy_mode='multi') +# Just verify it initializes without error +print('Backtest engine initialized OK') +print(f'Engine type: {type(bt.engine).__name__}') +print(f'Engine line count verification: strategy modules loaded') +" +``` + +- [ ] **Step 5: Final commit** + +```bash +git add -A ats/simulation/ +git commit -m "refactor: complete engine.py decomposition — 6689→~1800 lines + +Extracted modules: +- constants.py: regime params, strategy weights, ETF universes +- models.py: 8 Pydantic serialization models +- allocator.py: StrategyAllocator (capital allocation) +- regime.py: market/stock regime classification +- risk_gates.py: RG1-RG5 risk checks +- indicators.py: technical indicator calculation +- sizing.py: ATR-based position sizing +- strategies/: 7 strategy modules (momentum, smc, mr, brt, arb, def, vol) + +No behavioral changes. All 58 tests pass." +``` + +--- + +## Important Notes for Implementation + +### Backtest Compatibility +`HistoricalBacktester` accesses these engine attributes directly: +- `engine._ohlcv_cache`, `engine._current_prices`, `engine._market_regime` +- `engine._replay_mode`, `engine.positions`, `engine.closed_trades` +- `engine.update_vix()`, `engine.update_index_data()`, `engine.run_backtest_day()` + +All must remain accessible on `SimulationEngine`. Delegation methods are fine; removing them is not. + +### Strategy Module Pattern +Each `strategies/*.py` module receives the engine instance as its first parameter. This avoids creating abstraction layers while enabling extraction. The engine's public attributes are the module's API contract: +- `engine._ohlcv_cache`, `engine._current_prices`, `engine._stock_names` +- `engine.positions`, `engine.signals`, `engine.orders` +- `engine._market_regime`, `engine._strategy_allocator` +- `engine._execute_buy()`, `engine._execute_sell()`, `engine._execute_partial_sell()` +- `engine._phase_stats`, `engine._add_risk_event()` + +### Constants Import Chain +``` +simulation/constants.py (no dependencies within simulation/) + ↑ +simulation/allocator.py (imports constants) +simulation/regime.py (imports constants, allocator._compute_adx) +simulation/risk_gates.py (imports constants) +simulation/sizing.py (imports constants) +simulation/indicators.py (no simulation dependencies — pure functions) +simulation/strategies/* (import from TYPE_CHECKING only, receive engine at runtime) + ↑ +simulation/engine.py (imports all above) +``` +No circular dependencies. From 8373a3336212f57f9108b290d8ef590f5785c0d4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=8B=A0=EB=8C=80=EC=8A=B9?= Date: Sun, 15 Mar 2026 17:17:23 +0900 Subject: [PATCH 2/6] refactor: extract constants, models, allocator from engine.py (C2 Phase A) Phase A of engine.py decomposition - zero-risk extractions: - constants.py: regime params, strategy weights, ETF universes (~345 lines) - models.py: 8 Pydantic models (SimPosition, SimSignal, etc.) (~130 lines) - allocator.py: StrategyAllocator class + _compute_adx helper (~330 lines) All 58 tests pass. No behavioral changes. Co-Authored-By: Claude Opus 4.6 --- ats/scripts/analyze_market_today.py | 4 +- ats/simulation/allocator.py | 340 ++++++++++++ ats/simulation/constants.py | 350 ++++++++++++ ats/simulation/engine.py | 825 +--------------------------- ats/simulation/models.py | 140 +++++ 5 files changed, 851 insertions(+), 808 deletions(-) create mode 100644 ats/simulation/allocator.py create mode 100644 ats/simulation/constants.py create mode 100644 ats/simulation/models.py diff --git a/ats/scripts/analyze_market_today.py b/ats/scripts/analyze_market_today.py index 5dc239a..3e04b4e 100644 --- a/ats/scripts/analyze_market_today.py +++ b/ats/scripts/analyze_market_today.py @@ -88,7 +88,7 @@ def analyze_index_trend(market: str): # ── ADX(14) ── try: - from simulation.engine import _compute_adx + from simulation.allocator import _compute_adx adx_s, _, _ = _compute_adx( pd.Series(highs.values), pd.Series(lows.values), pd.Series(closes.values), 14 ) @@ -171,7 +171,7 @@ def analyze_index_trend(market: str): def show_strategy_weights(trend: str): """Part 2: 추세 → INDEX_TREND_STRATEGY_WEIGHTS 매핑.""" - from simulation.engine import INDEX_TREND_STRATEGY_WEIGHTS, REGIME_STRATEGY_WEIGHTS + from simulation.constants import INDEX_TREND_STRATEGY_WEIGHTS, REGIME_STRATEGY_WEIGHTS print(f"\n{'=' * 70}") print(f" Part 2: 전략 비중 결정 (INDEX_TREND_STRATEGY_WEIGHTS)") diff --git a/ats/simulation/allocator.py b/ats/simulation/allocator.py new file mode 100644 index 0000000..a7cf609 --- /dev/null +++ b/ats/simulation/allocator.py @@ -0,0 +1,340 @@ +""" +멀티 전략 모드용 전략별 자본 배분 관리자. +Extracted from engine.py for modularity (C2 decomposition). +""" +from __future__ import annotations + +from typing import Dict, List + +import numpy as np +import pandas as pd + +from simulation.constants import REGIME_STRATEGY_WEIGHTS + + +class StrategyAllocator: + """ + 멀티 전략 모드용 전략별 자본 배분 관리자. + + 각 전략에 레짐 기반 비중을 할당하고, 전략별 포지션 수/자본 한도를 관리. + 물리적 현금 풀은 단일이지만, 가상 예산(virtual budget)으로 전략 간 자본을 분리. + + Phase 3 추가: + - Correlation Control: 전략간 rolling corr 모니터링, corr>0.4 시 비중 조정 + - Dynamic Kelly: regime + VIX + 최근 승률 반영 Kelly × 0.25 + """ + + def __init__(self, strategies: List[str], regime: str = "NEUTRAL"): + self.strategies = strategies + self.regime = regime + self.weights: Dict[str, float] = {} + # Volatility Targeting 상태 + self._daily_returns: List[float] = [] + self._target_vol: float = 0.15 # 연 15% 포트폴리오 변동성 + self._vol_scalar: float = 1.0 # target_vol / realized_vol + self._prev_equity: float = 0.0 + # Phase 7: Risk Parity 상태 + self._rp_weights: Dict[str, float] = {} # 전략별 RP 비중 + self._rp_warmup_done: bool = False # 데이터 충분 여부 + # Correlation Control 상태 (Phase 3.1) + self._strategy_daily_pnl: Dict[str, List[float]] = {s: [] for s in strategies} + self._corr_matrix: Dict[tuple, float] = {} # (s1, s2) → rolling corr + self._corr_adjustment: Dict[str, float] = {} # strategy → 비중 조정 승수 + # Dynamic Kelly 상태 (Phase 3.4) + self._strategy_wins: Dict[str, int] = {s: 0 for s in strategies} + self._strategy_losses: Dict[str, int] = {s: 0 for s in strategies} + self._strategy_win_pnl: Dict[str, float] = {s: 0.0 for s in strategies} + self._strategy_loss_pnl: Dict[str, float] = {s: 0.0 for s in strategies} + self._kelly_scalar: float = 1.0 + self._vix_ema: float = 0.0 + self._apply_regime_weights(regime) + + def _apply_regime_weights(self, regime: str): + """레짐에 맞는 전략 비중 적용 (correlation + Risk Parity 조정 반영).""" + weights = REGIME_STRATEGY_WEIGHTS.get(regime, REGIME_STRATEGY_WEIGHTS["NEUTRAL"]) + raw = {s: weights.get(s, 0.0) for s in self.strategies} + # Correlation 조정 적용 + if self._corr_adjustment: + for s in raw: + raw[s] *= self._corr_adjustment.get(s, 1.0) + total = sum(raw.values()) + if total > 0: + raw = {s: w / total for s, w in raw.items()} + # Phase 7: Risk Parity 블렌딩 (비활성화 — 모든 비율에서 성능 저하 확인) + # RP는 momentum(핵심 전략) 비중을 과도하게 축소하여 net alpha 감소 + # if self._rp_warmup_done and self._rp_weights: + # blended = {} + # for s in self.strategies: + # regime_w = raw.get(s, 0.0) + # rp_w = self._rp_weights.get(s, regime_w) + # blended[s] = 0.90 * regime_w + 0.10 * rp_w + # total = sum(blended.values()) + # if total > 0: + # raw = {s: w / total for s, w in blended.items()} + self.weights = raw + + def update_regime(self, regime: str): + """레짐 변경 시 비중 갱신.""" + if regime != self.regime: + self.regime = regime + self._apply_regime_weights(regime) + + def override_weights(self, weights: Dict[str, float]): + """지수 추세 기반 전략 비중 오버라이드. + + INDEX_TREND_STRATEGY_WEIGHTS에서 받은 비중으로 교체. + 활성 전략에 없는 전략은 무시하고 나머지를 정규화. + Correlation 조정도 적용. + """ + active_weights = {} + for strat, w in weights.items(): + if strat in self.strategies: + active_weights[strat] = w + + if not active_weights: + return # 유효한 전략 없으면 무시 + + # Correlation 조정 적용 + if self._corr_adjustment: + for s in active_weights: + active_weights[s] *= self._corr_adjustment.get(s, 1.0) + + # 정규화 (합 = 1.0) + total = sum(active_weights.values()) + if total > 0: + self.weights = {s: w / total for s, w in active_weights.items()} + + def is_active(self, strategy: str) -> bool: + """해당 전략이 현재 레짐에서 활성인지.""" + return self.weights.get(strategy, 0.0) > 0.01 + + def get_budget(self, strategy: str, total_equity: float, used_by_strategy: float) -> float: + """전략의 남은 가용 자본 (가상 예산).""" + weight = self.weights.get(strategy, 0.0) + budget = total_equity * weight + return max(0.0, budget - used_by_strategy) + + def get_max_positions(self, strategy: str, regime_max: int) -> int: + """전략별 최대 포지션 수 (Largest Remainder Method).""" + dist = self.distribute_positions(regime_max) + return dist.get(strategy, 1) + + def distribute_positions(self, regime_max: int) -> Dict[str, int]: + """Largest Remainder Method로 포지션 수 공정 분배. + + Phase 4.2: round() 대신 LRM 사용 — 저비중 전략 굶주림 해결. + 합계가 regime_max를 초과하지 않도록 보장. + """ + active = {s: w for s, w in self.weights.items() if w > 0.01} + if not active: + return {} + raw = {s: regime_max * w for s, w in active.items()} + floors = {s: int(v) for s, v in raw.items()} + remainders = {s: raw[s] - floors[s] for s in active} + leftover = regime_max - sum(floors.values()) + for s in sorted(remainders, key=lambda k: remainders[k], reverse=True): + if leftover <= 0: + break + floors[s] += 1 + leftover -= 1 + return {s: max(1, v) for s, v in floors.items()} + + def get_max_weight_for_strategy(self, strategy: str, regime_max_weight: float) -> float: + """전략별 종목당 최대 비중. 전략 비중이 작을수록 더 집중.""" + weight = self.weights.get(strategy, 0.0) + if weight < 0.15: + return regime_max_weight + return regime_max_weight + + # ── Volatility Targeting ── + + def update_daily_return(self, total_equity: float): + """일일 수익률 기록 및 변동성 스칼라 갱신.""" + if self._prev_equity > 0: + daily_ret = (total_equity - self._prev_equity) / self._prev_equity + self._daily_returns.append(daily_ret) + if len(self._daily_returns) > 60: + self._daily_returns = self._daily_returns[-60:] + if len(self._daily_returns) >= 20: + recent = self._daily_returns[-20:] + realized_vol = float(np.std(recent)) * (252 ** 0.5) + if realized_vol > 0.001: + self._vol_scalar = min(1.5, max(0.3, self._target_vol / realized_vol)) + else: + self._vol_scalar = 1.0 + self._prev_equity = total_equity + + def get_vol_scalar(self) -> float: + """현재 변동성 타겟팅 스칼라 (0.3 ~ 1.5).""" + return self._vol_scalar + + # ── Phase 7: Risk Parity ── + + def update_risk_parity(self): + """전략별 실현 변동성의 역수에 비례하는 Risk Parity 비중 계산. + + 전략별 일일 PnL의 비영 일(포지션 있는 날)만 사용. + 최소 10일 데이터 필요, 2개 이상 전략에 데이터 있어야 활성화. + """ + active_vols: Dict[str, float] = {} + for s in self.strategies: + pnl_series = self._strategy_daily_pnl.get(s, []) + # 비영 일만 (포지션 있는 날의 PnL) + nonzero = [p for p in pnl_series[-60:] if abs(p) > 0.01] + if len(nonzero) >= 10: + vol = float(np.std(nonzero)) + active_vols[s] = max(vol, 0.001) + + if len(active_vols) < 2: + self._rp_warmup_done = False + return + + # 역변동성 비중 + inv_vols = {s: 1.0 / v for s, v in active_vols.items()} + total_iv = sum(inv_vols.values()) + self._rp_weights = {s: iv / total_iv for s, iv in inv_vols.items()} + self._rp_warmup_done = True + + # 비중 재적용 + self._apply_regime_weights(self.regime) + + # ── Correlation Control (Phase 3.1) ── + + def record_strategy_daily_pnl(self, strategy: str, daily_pnl: float): + """전략별 일일 PnL 기록.""" + if strategy in self._strategy_daily_pnl: + self._strategy_daily_pnl[strategy].append(daily_pnl) + if len(self._strategy_daily_pnl[strategy]) > 60: + self._strategy_daily_pnl[strategy] = self._strategy_daily_pnl[strategy][-60:] + + def update_correlation(self): + """전략간 rolling correlation 계산 및 비중 조정. + + corr > 0.4 인 페어의 고상관 전략 비중 축소, 저상관 전략 비중 확대. + 20거래일 이상 데이터 필요. + """ + active = [s for s in self.strategies if len(self._strategy_daily_pnl.get(s, [])) >= 20] + if len(active) < 2: + return + + # 페어별 correlation 계산 + high_corr_strategies = set() + for i, s1 in enumerate(active): + for s2 in active[i + 1:]: + pnl1 = self._strategy_daily_pnl[s1][-20:] + pnl2 = self._strategy_daily_pnl[s2][-20:] + if np.std(pnl1) < 1e-10 or np.std(pnl2) < 1e-10: + corr = 0.0 + else: + corr = float(np.corrcoef(pnl1, pnl2)[0, 1]) + self._corr_matrix[(s1, s2)] = corr + if corr > 0.4: + high_corr_strategies.add(s1) + high_corr_strategies.add(s2) + + # 비중 조정: 고상관 전략 0.8x, 저상관 전략 1.2x + self._corr_adjustment = {} + for s in self.strategies: + if s in high_corr_strategies: + self._corr_adjustment[s] = 0.8 + elif len(active) > 0 and s in active: + self._corr_adjustment[s] = 1.2 + else: + self._corr_adjustment[s] = 1.0 + + # 비중 재적용 + self._apply_regime_weights(self.regime) + + def get_corr_matrix(self) -> Dict[tuple, float]: + """현재 전략간 상관관계 매트릭스.""" + return self._corr_matrix.copy() + + # ── Dynamic Kelly (Phase 3.4) ── + + def record_trade_result(self, strategy: str, pnl_pct: float): + """전략별 거래 결과 기록 (Kelly 동적 조정용).""" + if strategy not in self._strategy_wins: + return + if pnl_pct > 0: + self._strategy_wins[strategy] += 1 + self._strategy_win_pnl[strategy] += pnl_pct + else: + self._strategy_losses[strategy] += 1 + self._strategy_loss_pnl[strategy] += abs(pnl_pct) + + def update_kelly(self, vix_ema: float): + """Dynamic Kelly 스칼라 갱신. + + Quarter-Kelly(0.25) 기반, regime + VIX + 최근 승률 반영. + kelly_scalar = base(0.25) × regime_mult × vix_mult × performance_mult + """ + self._vix_ema = vix_ema + base_kelly = 0.25 # Quarter-Kelly + + # Regime 승수 + regime_mult = {"BULL": 1.2, "NEUTRAL": 1.0, "RANGE_BOUND": 0.8, "BEAR": 0.5}.get(self.regime, 1.0) + + # VIX 승수 (VIX 높을수록 보수적) + if vix_ema <= 16: + vix_mult = 1.1 + elif vix_ema <= 20: + vix_mult = 1.0 + elif vix_ema <= 25: + vix_mult = 0.8 + elif vix_ema <= 30: + vix_mult = 0.6 + else: + vix_mult = 0.4 + + # 최근 성과 승수 (전체 전략 합산 승률) + total_w = sum(self._strategy_wins.values()) + total_l = sum(self._strategy_losses.values()) + total_trades = total_w + total_l + if total_trades >= 10: + win_rate = total_w / total_trades + if win_rate >= 0.45: + perf_mult = 1.1 + elif win_rate >= 0.35: + perf_mult = 1.0 + elif win_rate >= 0.25: + perf_mult = 0.8 + else: + perf_mult = 0.6 + else: + perf_mult = 1.0 # 데이터 부족 시 기본값 + + self._kelly_scalar = base_kelly * regime_mult * vix_mult * perf_mult + # 범위 제한: 0.05 ~ 0.40 + self._kelly_scalar = min(0.40, max(0.05, self._kelly_scalar)) + + def get_kelly_scalar(self) -> float: + """현재 Dynamic Kelly 스칼라 (0.05 ~ 0.40).""" + return self._kelly_scalar + + +def _compute_adx(high: pd.Series, low: pd.Series, close: pd.Series, period: int = 14): + """순수 pandas로 ADX, +DI, -DI 계산 (ta 라이브러리 의존 없음).""" + plus_dm = high.diff().clip(lower=0) + minus_dm = (-low.diff()).clip(lower=0) + + # +DM과 -DM 중 큰 쪽만 유효 + mask_plus = plus_dm <= minus_dm + mask_minus = minus_dm <= plus_dm + plus_dm = plus_dm.copy() + minus_dm = minus_dm.copy() + plus_dm[mask_plus] = 0 + minus_dm[mask_minus] = 0 + + tr1 = high - low + tr2 = (high - close.shift()).abs() + tr3 = (low - close.shift()).abs() + tr = pd.concat([tr1, tr2, tr3], axis=1).max(axis=1) + + atr = tr.rolling(window=period).mean() + plus_di = 100 * (plus_dm.rolling(window=period).mean() / atr.replace(0, np.nan)) + minus_di = 100 * (minus_dm.rolling(window=period).mean() / atr.replace(0, np.nan)) + + dx = (plus_di - minus_di).abs() / (plus_di + minus_di).replace(0, np.nan) * 100 + adx = dx.rolling(window=period).mean() + + return adx, plus_di, minus_di diff --git a/ats/simulation/constants.py b/ats/simulation/constants.py new file mode 100644 index 0000000..dc8611f --- /dev/null +++ b/ats/simulation/constants.py @@ -0,0 +1,350 @@ +""" +Simulation engine constants: regime parameters, strategy weights, ETF universes. +Extracted from engine.py for modularity (C2 decomposition). +""" +from __future__ import annotations + +from typing import Any, Callable, Coroutine, Dict, List + +# ── 워치리스트 ── + +WATCHLIST = [ + {"code": "005930", "ticker": "005930.KS", "name": "삼성전자"}, + {"code": "000660", "ticker": "000660.KS", "name": "SK하이닉스"}, + {"code": "005380", "ticker": "005380.KS", "name": "현대자동차"}, + {"code": "035420", "ticker": "035420.KS", "name": "NAVER"}, + {"code": "051910", "ticker": "051910.KS", "name": "LG화학"}, + {"code": "006400", "ticker": "006400.KS", "name": "삼성SDI"}, + {"code": "003670", "ticker": "003670.KS", "name": "포스코퓨처엠"}, + {"code": "105560", "ticker": "105560.KS", "name": "KB금융"}, + {"code": "055550", "ticker": "055550.KS", "name": "신한지주"}, + {"code": "066570", "ticker": "066570.KS", "name": "LG전자"}, +] + +OnEventType = Callable[[str, Any], Coroutine[Any, Any, None]] + +# ── Phase별 시장 체제 파라미터 (6단계: STRONG_BULL ~ CRISIS) ── +REGIME_PARAMS = { + "STRONG_BULL": {"max_positions": 10, "max_weight": 0.15}, + "BULL": {"max_positions": 10, "max_weight": 0.15}, + "NEUTRAL": {"max_positions": 6, "max_weight": 0.12}, + "RANGE_BOUND": {"max_positions": 4, "max_weight": 0.08}, + "BEAR": {"max_positions": 2, "max_weight": 0.05}, + "CRISIS": {"max_positions": 1, "max_weight": 0.05}, +} + +# ── 종목별 레짐 분류 임계값 (0-100 복합 스코어 → 6단계) ── +STOCK_REGIME_THRESHOLDS: list = [ + # (min_score, regime) — 내림차순 매칭 + (80, "STRONG_BULL"), + (60, "BULL"), + (45, "NEUTRAL"), + (30, "RANGE_BOUND"), + (15, "BEAR"), + (0, "CRISIS"), +] + +# ── 체제별 청산 파라미터 ── +REGIME_EXIT_PARAMS = { + "STRONG_BULL": {"max_holding": 40, "take_profit": 0.20, "trail_activation": 0.05}, + "BULL": {"max_holding": 40, "take_profit": 0.20, "trail_activation": 0.05}, + "NEUTRAL": {"max_holding": 25, "take_profit": 0.12, "trail_activation": 0.04}, + "RANGE_BOUND": {"max_holding": 15, "take_profit": 0.08, "trail_activation": 0.03}, + "BEAR": {"max_holding": 15, "take_profit": 0.08, "trail_activation": 0.03}, + "CRISIS": {"max_holding": 10, "take_profit": 0.05, "trail_activation": 0.02}, +} + +# ── 레짐별 전략 오버라이드 (Entry/Exit/Risk 모듈화) ── +# 기존 전략 메서드 내에서 self._market_regime을 확인하여 분기 +# ── 레짐별 Kelly Fraction (포지션 사이징 근거) ── +# 기본 Half-Kelly(0.5) 대비 레짐별 공격도 조절 +# STRONG_BULL=0.75 → CRISIS=0.25, 선형 보간 (step=0.10) +# effective_sizing = kelly_fraction / BASE_KELLY(0.5) +BASE_KELLY: float = 0.50 # 현물 기본 Half-Kelly + +REGIME_OVERRIDES: Dict[str, Dict[str, Any]] = { + "STRONG_BULL": { + # Kelly: 3/4 Kelly → effective ×1.50 + "kelly_fraction": 0.75, + # Entry: Donchian 돌파 추가, 피라미딩 허용 + "donchian_entry": True, # 모멘텀에 Donchian 20d 돌파 시그널 추가 + "donchian_period": 20, + "pyramiding_enabled": True, # 추가 매수 허용 (모멘텀 전략) + "pyramiding_max": 1, # 최대 1회 추가 매수 + "pyramiding_pnl_min": 0.05, # 수익 5% 이상일 때 피라미딩 + # Exit: 공격적 트레일링 + "trail_atr_mult": 2.0, # 기본 3.0 → 2.0 (타이트) + "trail_floor_pct": -0.04, # 최소 바닥 -4% + # Risk + "min_cash_override": None, # 기본 사용 + }, + "BULL": { + # Kelly: 0.65 → effective ×1.30 + "kelly_fraction": 0.65, + # Entry: 표준 (기존 유지) + "donchian_entry": False, + "pyramiding_enabled": False, + # Exit: 이격도 부분 청산 + "disparity_partial_sell": True, # MA20 이격도 과대 시 50% 분할 청산 + "disparity_threshold": 1.15, # 가격/MA20 > 115% + "partial_sell_ratio": 0.5, # 50% 청산 + # Risk + "min_cash_override": None, + }, + "NEUTRAL": { + # Kelly: 0.55 → effective ×1.10 + "kelly_fraction": 0.55, + # Entry: MR 강화 (ADX 필터 엄격) + "mr_adx_limit": 22, # 기본 25 → 22 (추세 약한 것만) + # Exit: 공격적 시간감쇄 + "time_decay_enabled": True, + "time_decay_days": 10, # 10일 보유 + 수익 < 2% → 강제 청산 + "time_decay_pnl_min": 0.02, + # Risk + "min_cash_override": None, + }, + "RANGE_BOUND": { + # Kelly: 0.45 → effective ×0.90 + "kelly_fraction": 0.45, + # Entry: 지지/저항 영역 활용 + "sr_zone_entry": True, # S/R 영역 가격일 때만 MR 진입 + "sr_atr_buffer": 1.5, # 지지선 ± 1.5×ATR 내 + # Exit: 박스 이탈 즉시 손절 + "box_breakout_exit": True, # 박스 상단/하단 이탈 시 즉시 청산 + "box_lookback": 40, # 40일 고가/저가 = 박스 범위 + # Risk + "min_cash_override": None, + }, + "BEAR": { + # Kelly: 0.35 → effective ×0.70 + "kelly_fraction": 0.35, + # Entry: 방어 강화 + "defensive_vix_threshold": 20, # 기본 25 → 20 (더 일찍 방어) + # Exit: 빠른 청산 + "bear_exit_tighten": True, # TP 축소, 보유기간 단축 + # Risk + "min_cash_override": 0.50, # 현금 50% 유지 + }, + "CRISIS": { + # Kelly: 1/4 Kelly → effective ×0.50 + "kelly_fraction": 0.25, + # Entry: 안전자산 스위칭 + "safe_haven_enabled": True, # GLD, TLT 등 안전자산 편입 + "crisis_vix_threshold": 30, # VIX > 30 → 최대 방어 + # Exit: 초빠른 청산 + "crisis_exit_immediate": True, # 비방어 포지션 즉시 청산 + # Risk + "min_cash_override": 0.70, # 현금 70% 유지 + }, +} + +# ── VIX 기반 포지션 사이징 스케일러 ── +VIX_SIZING_SCALE = { + # (lower, upper): multiplier + (0, 16): 1.0, # 안정 + (16, 20): 0.9, # 보통 + (20, 25): 0.8, # 경계 + (25, 30): 0.6, # 고변동 + (30, 100): 0.3, # 공포 — 극소 사이징 +} + +# ── 레짐별 전략 비중 매핑 (멀티 전략 모드용) ── +# 레짐 표시명 ↔ primary 전략 1:1 매칭: +# BULL → 추세추종 = momentum primary +# NEUTRAL/RANGE → 평균회귀 = mean_reversion primary +# BEAR → 하락방어 = defensive primary +# 주의: 이전 MR+Def 2전략 집중(Sharpe 2.51)에서 다전략 활성화로 변경 +# 성능 변동 예상 → 반드시 2-year backtest 검증 후 production 적용 +REGIME_STRATEGY_WEIGHTS: Dict[str, Dict[str, float]] = { + # Fallback: 지수 데이터 부족 시 Phase 0 breadth 기반 레짐 사용 + "BULL": {"momentum": 0.30, "smc": 0.15, "mean_reversion": 0.35, "defensive": 0.15, "breakout_retest": 0.05}, + "NEUTRAL": {"mean_reversion": 0.60, "defensive": 0.25, "arbitrage": 0.10, "smc": 0.05}, + "RANGE_BOUND": {"mean_reversion": 0.55, "arbitrage": 0.20, "defensive": 0.20, "smc": 0.05}, + "BEAR": {"defensive": 0.55, "volatility": 0.15, "mean_reversion": 0.25, "smc": 0.05}, +} + +# 지수 추세별 동적 전략 비중 — 표시명과 primary 전략 1:1 매칭 +# _analyze_index_trend() 결과에 따라 REGIME_STRATEGY_WEIGHTS를 오버라이드 +INDEX_TREND_STRATEGY_WEIGHTS: Dict[str, Dict[str, float]] = { + # STRONG_BULL "공격적 추세추종": momentum primary (40%) + # 강한 상승추세 → 모멘텀 breakout 매수 + BRT 돌파 리테스트 + "STRONG_BULL": { + "momentum": 0.40, # PRIMARY: 추세추종 (표시명 매칭) + "breakout_retest": 0.20, # 돌파 리테스트 (강한 추세에서 유효) + "smc": 0.10, # 스마트머니 구조 확인 + "mean_reversion": 0.20, # 눌림목 매수 + "defensive": 0.10, # 최소 헤지 + }, + # BULL "추세추종": momentum primary (30%) + # 상승추세 → 추세 추종 + SMC 구조확인 + MR 눌림목 + "BULL": { + "momentum": 0.30, # PRIMARY: 추세추종 (표시명 매칭) + "smc": 0.15, # SMC 구조 확인 + "mean_reversion": 0.35, # 눌림목 매수 (여전히 강한 알파) + "defensive": 0.15, # 적정 헤지 + "breakout_retest": 0.05, # 소량 돌파 매매 + }, + # NEUTRAL "평균회귀": mean_reversion primary (60%) + # 횡보 → 과매도 반등 매수 + 페어 차익거래 + "NEUTRAL": { + "mean_reversion": 0.60, # PRIMARY: 평균회귀 (표시명 매칭) + "defensive": 0.25, # 적정 헤지 + "arbitrage": 0.10, # 페어 차익거래 + "smc": 0.05, # 구조 필터 + }, + # BEAR "하락방어": defensive primary (55%) + # 하락추세 → 인버스 ETF + VIX 프리미엄 + "BEAR": { + "defensive": 0.55, # PRIMARY: 인버스 헤지 (표시명 매칭) + "volatility": 0.15, # VIX 스파이크 역매매 + "mean_reversion": 0.25, # 극단 과매도만 + "smc": 0.05, # 구조 필터 (반전 감지) + }, + # RANGE_BOUND "박스권 회귀": mean_reversion primary (55%) + # 박스권 → 평균회귀 + 차익거래 강화 + "RANGE_BOUND": { + "mean_reversion": 0.55, # PRIMARY: 박스권 회귀 (표시명 매칭) + "arbitrage": 0.20, # 페어 차익거래 (박스권 유리) + "defensive": 0.20, # 적정 헤지 + "smc": 0.05, # 구조 필터 + }, + # CRISIS "위기방어": defensive primary (85%) + # 위기 → 자본 보존 최우선 + "CRISIS": { + "defensive": 0.85, # PRIMARY: 인버스 헤지 (표시명 매칭) + "mean_reversion": 0.15, # 최소 MR + }, +} + +# 레짐별 전략 표시 이름 (프론트엔드 UI용) +# Multi 모드에서 현재 레짐에 맞는 전략명을 동적으로 표시 +REGIME_DISPLAY_NAMES: Dict[str, Dict[str, str]] = { + "STRONG_BULL": {"ko": "공격적 추세추종", "en": "Aggressive Trend Trail"}, + "BULL": {"ko": "추세추종", "en": "Trend Trail"}, + "NEUTRAL": {"ko": "평균회귀", "en": "Mean Reversion"}, + "RANGE_BOUND": {"ko": "박스권 회귀", "en": "Range Reversion"}, + "BEAR": {"ko": "하락방어", "en": "Bear Shield"}, + "CRISIS": {"ko": "위기방어", "en": "Crisis Shield"}, +} + +# 개별 전략 표시 이름 (프론트엔드 UI용) +STRATEGY_DISPLAY_NAMES: Dict[str, Dict[str, str]] = { + "multi": {"ko": "적응형 알파", "en": "Adaptive Alpha", "desc": "레짐 적응형 동적 전략"}, + "momentum": {"ko": "모멘텀 스윙", "en": "Momentum Swing", "desc": "6-Phase 추세추종 전략"}, + "smc": {"ko": "스마트머니", "en": "Smart Money", "desc": "SMC 4-Layer 구조분석"}, + "mean_reversion": {"ko": "평균회귀", "en": "Mean Reversion", "desc": "과매도 반등 매수 전략"}, + "arbitrage": {"ko": "페어 차익거래", "en": "Pairs Arbitrage", "desc": "Z-Score 통계적 차익"}, + "breakout_retest": {"ko": "돌파 리테스트", "en": "Breakout Retest", "desc": "돌파 후 되돌림 진입"}, + "defensive": {"ko": "인버스 헤지", "en": "Inverse Hedge", "desc": "인버스 ETF 하락 방어"}, + "volatility": {"ko": "변동성 프리미엄", "en": "Vol Premium", "desc": "VIX 스파이크 역매매"}, + # 레짐 전략 모드 표시 이름 + "regime_strong_bull": {"ko": "공격적 추세추종", "en": "Aggressive Trend Trail", "desc": "강세장 전용 · 모멘텀 주도 복합전략"}, + "regime_bull": {"ko": "추세추종", "en": "Trend Trail", "desc": "상승추세 추종 · 모멘텀+MR 복합전략"}, + "regime_neutral": {"ko": "평균회귀", "en": "Mean Reversion", "desc": "횡보장 전용 · 과매도 반등 복합전략"}, + "regime_range_bound": {"ko": "박스권 회귀", "en": "Range Reversion", "desc": "박스권 전용 · 차익거래 보조 복합전략"}, + "regime_bear": {"ko": "하락방어", "en": "Bear Shield", "desc": "하락장 전용 · 인버스 헤지 복합전략"}, + "regime_crisis": {"ko": "위기방어", "en": "Crisis Shield", "desc": "위기 전용 · 자본 보존 복합전략"}, +} + +# 레짐별 전략 구성 메타데이터 (프론트엔드 UI + 내부 문서화) +# 각 레짐의 표시 이름이 primary 전략과 1:1 매칭되도록 구성 +REGIME_STRATEGY_COMPOSITION: Dict[str, Dict] = { + "STRONG_BULL": { + "primary": "momentum", + "secondary": ["breakout_retest", "mean_reversion"], + "filter": ["smc"], + "hedge": ["defensive"], + "rationale": { + "ko": "강한 상승추세 → 모멘텀 추세추종 주력, 돌파매매 보조, MR 눌림목 매수", + "en": "Strong uptrend → momentum primary, breakout secondary, MR dip-buying", + }, + }, + "BULL": { + "primary": "momentum", + "secondary": ["smc", "mean_reversion"], + "filter": [], + "hedge": ["defensive"], + "rationale": { + "ko": "상승추세 → 모멘텀 주력, SMC 구조확인 보조, MR 눌림목 매수", + "en": "Uptrend → momentum primary, SMC structure confirmation, MR dip-buying", + }, + }, + "NEUTRAL": { + "primary": "mean_reversion", + "secondary": ["arbitrage"], + "filter": ["smc"], + "hedge": ["defensive"], + "rationale": { + "ko": "횡보 → 평균회귀 주력, 페어 차익거래 보조", + "en": "Sideways → mean reversion primary, pairs arbitrage secondary", + }, + }, + "RANGE_BOUND": { + "primary": "mean_reversion", + "secondary": ["arbitrage"], + "filter": ["smc"], + "hedge": ["defensive"], + "rationale": { + "ko": "박스권 → 평균회귀 주력, 페어 차익거래 보조", + "en": "Range-bound → mean reversion primary, pairs arbitrage secondary", + }, + }, + "BEAR": { + "primary": "defensive", + "secondary": ["volatility"], + "filter": ["smc"], + "hedge": [], + "rationale": { + "ko": "하락추세 → 인버스 헤지 주력, VIX 프리미엄 보조, 최소 MR", + "en": "Downtrend → inverse hedge primary, VIX premium secondary, minimal MR", + }, + }, + "CRISIS": { + "primary": "defensive", + "secondary": [], + "filter": [], + "hedge": [], + "rationale": { + "ko": "위기 → 인버스 헤지 최대, 자본 보존 최우선", + "en": "Crisis → maximum inverse hedge, capital preservation priority", + }, + }, +} + +# 인버스 ETF 유니버스 (Phase 3.3: Defensive 전략) +INVERSE_ETFS = { + "sp500": ["SH", "SDS"], # ProShares Short S&P500, UltraShort S&P500 + "nasdaq": ["PSQ", "QID"], # ProShares Short QQQ, UltraShort QQQ + "kospi": ["114800.KS", "252670.KS"], # KODEX 인버스, KODEX 200선물인버스2X +} + +# 안전자산 ETF 유니버스 (CRISIS 레짐 방어 전략) +SAFE_HAVEN_ETFS: Dict[str, List[Dict[str, str]]] = { + "sp500": [ + {"ticker": "GLD", "name": "Gold ETF"}, + {"ticker": "TLT", "name": "US Treasury 20Y+"}, + {"ticker": "UUP", "name": "US Dollar Index"}, + ], + "nasdaq": [ + {"ticker": "GLD", "name": "Gold ETF"}, + {"ticker": "TLT", "name": "US Treasury 20Y+"}, + ], + "kospi": [ + {"ticker": "411060.KS", "name": "KODEX 미국달러선물"}, + {"ticker": "132030.KS", "name": "KODEX 골드선물"}, + ], +} + +# 멀티 전략 모드 기본 전략 목록 +MULTI_STRATEGIES = ["momentum", "smc", "breakout_retest", "mean_reversion", "defensive", "volatility", "arbitrage"] + +# 레짐 전략 모드: 레짐 가중치 고정 + multi 파이프라인 재사용 +# 각 모드는 특정 레짐의 전략 비중을 고정하여 실행 (적응형 알파의 개별 테스트용) +REGIME_STRATEGY_MODES: Dict[str, str] = { + "regime_strong_bull": "STRONG_BULL", + "regime_bull": "BULL", + "regime_neutral": "NEUTRAL", + "regime_range_bound": "RANGE_BOUND", + "regime_bear": "BEAR", + "regime_crisis": "CRISIS", +} diff --git a/ats/simulation/engine.py b/ats/simulation/engine.py index 9736bfd..1314fe5 100644 --- a/ats/simulation/engine.py +++ b/ats/simulation/engine.py @@ -11,815 +11,28 @@ import math import uuid from datetime import datetime -from typing import Any, Callable, Coroutine, Dict, List, Optional +from typing import Any, Dict, List, Optional import numpy as np import pandas as pd -from pydantic import BaseModel - -# ── Pydantic Models (프론트엔드 TypeScript 인터페이스와 1:1 매칭) ── - - -class SimSystemState(BaseModel): - status: str = "STOPPED" - mode: str = "PAPER" - started_at: Optional[str] = None - market_phase: str = "CLOSED" - market_regime: str = "NEUTRAL" # BULL / BEAR / NEUTRAL (Phase 0) - next_scan_at: Optional[str] = None - total_equity: float = 0 - cash: float = 0 - invested: float = 0 - daily_pnl: float = 0 - daily_pnl_pct: float = 0 - position_count: int = 0 - max_positions: int = 10 - - -class SimPosition(BaseModel): - id: str - stock_code: str - stock_name: str - status: str # PENDING | ACTIVE | CLOSING | CLOSED - quantity: int - entry_price: float - current_price: float - pnl: float - pnl_pct: float - stop_loss: float - take_profit: float - trailing_stop: float - highest_price: float - entry_date: str - days_held: int - max_holding_days: int = 30 - weight_pct: float - trailing_activated: bool = False - side: str = "LONG" # "LONG" | "SHORT" (Arbitrage 양방향) - lowest_price: float = 0.0 # Short 트레일링용 최저가 추적 - pair_id: Optional[str] = None # Arbitrage 페어 연결 ID - strategy_tag: str = "momentum" # 진입 시 활성 전략 태그 (exit 라우팅용) - scale_count: int = 0 # 피라미딩 횟수 (max 1) - avg_entry_price: float = 0.0 # 가중평균 매입가 (스케일업 시 사용) - entry_signal_strength: int = 0 # 진입 시그널 강도 (0-100) - entry_regime: str = "" # 진입 시 시장 레짐 (BULL/NEUTRAL/BEAR/RANGE_BOUND) - entry_trend_strength: str = "" # 추세 강도 (STRONG/MODERATE/WEAK) - disparity_sold: bool = False # BULL 이격도 부분 청산 완료 여부 - stock_regime: str = "" # 진입 시 종목 개별 레짐 (STRONG_BULL~CRISIS) - - -class SimOrder(BaseModel): - id: str - stock_code: str - stock_name: str - side: str # BUY | SELL - order_type: str # LIMIT | MARKET - status: str # PENDING | FILLED | CANCELLED - price: float - filled_price: Optional[float] = None - quantity: int - filled_quantity: int = 0 - created_at: str - filled_at: Optional[str] = None - reason: str - - -class SimSignal(BaseModel): - id: str - stock_code: str - stock_name: str - type: str # BUY | SELL - price: float - reason: str - strength: int - detected_at: str - - -class SimRiskMetrics(BaseModel): - daily_pnl_pct: float = 0 - daily_loss_limit: float = -5.0 - mdd: float = 0 - mdd_limit: float = -15.0 - cash_ratio: float = 100.0 - min_cash_ratio: float = 30.0 - consecutive_stops: int = 0 - max_consecutive_stops: int = 3 - daily_trade_amount: float = 0 - max_daily_trade_amount: float = 30_000_000 - is_trading_halted: bool = False - halt_reason: Optional[str] = None - - -class SimTradeRecord(BaseModel): - id: str - stock_code: str - stock_name: str - entry_date: str - exit_date: str - entry_price: float - exit_price: float - quantity: int - pnl: float - pnl_pct: float - exit_reason: str - holding_days: int - strategy_tag: str = "momentum" - entry_signal_strength: int = 0 # 진입 시그널 강도 (0-100) - entry_regime: str = "" # 진입 시 시장 레짐 - entry_trend_strength: str = "" # 추세 강도 - stock_regime: str = "" # 진입 시 종목 개별 레짐 - - -class SimEquityPoint(BaseModel): - date: str - equity: float - drawdown_pct: float - - -class SimPerformanceSummary(BaseModel): - total_return_pct: float = 0 - total_trades: int = 0 - win_rate: float = 0 - avg_win_pct: float = 0 - avg_loss_pct: float = 0 - profit_factor: float = 0 - sharpe_ratio: float = 0 - max_drawdown_pct: float = 0 - avg_holding_days: float = 0 - best_trade_pct: float = 0 - worst_trade_pct: float = 0 - - -# ── 워치리스트 ── - -WATCHLIST = [ - {"code": "005930", "ticker": "005930.KS", "name": "삼성전자"}, - {"code": "000660", "ticker": "000660.KS", "name": "SK하이닉스"}, - {"code": "005380", "ticker": "005380.KS", "name": "현대자동차"}, - {"code": "035420", "ticker": "035420.KS", "name": "NAVER"}, - {"code": "051910", "ticker": "051910.KS", "name": "LG화학"}, - {"code": "006400", "ticker": "006400.KS", "name": "삼성SDI"}, - {"code": "003670", "ticker": "003670.KS", "name": "포스코퓨처엠"}, - {"code": "105560", "ticker": "105560.KS", "name": "KB금융"}, - {"code": "055550", "ticker": "055550.KS", "name": "신한지주"}, - {"code": "066570", "ticker": "066570.KS", "name": "LG전자"}, -] - -OnEventType = Callable[[str, Any], Coroutine[Any, Any, None]] - -# ── Phase별 시장 체제 파라미터 (6단계: STRONG_BULL ~ CRISIS) ── -REGIME_PARAMS = { - "STRONG_BULL": {"max_positions": 10, "max_weight": 0.15}, - "BULL": {"max_positions": 10, "max_weight": 0.15}, - "NEUTRAL": {"max_positions": 6, "max_weight": 0.12}, - "RANGE_BOUND": {"max_positions": 4, "max_weight": 0.08}, - "BEAR": {"max_positions": 2, "max_weight": 0.05}, - "CRISIS": {"max_positions": 1, "max_weight": 0.05}, -} - -# ── 종목별 레짐 분류 임계값 (0-100 복합 스코어 → 6단계) ── -STOCK_REGIME_THRESHOLDS: list = [ - # (min_score, regime) — 내림차순 매칭 - (80, "STRONG_BULL"), - (60, "BULL"), - (45, "NEUTRAL"), - (30, "RANGE_BOUND"), - (15, "BEAR"), - (0, "CRISIS"), -] - -# ── 체제별 청산 파라미터 ── -REGIME_EXIT_PARAMS = { - "STRONG_BULL": {"max_holding": 40, "take_profit": 0.20, "trail_activation": 0.05}, - "BULL": {"max_holding": 40, "take_profit": 0.20, "trail_activation": 0.05}, - "NEUTRAL": {"max_holding": 25, "take_profit": 0.12, "trail_activation": 0.04}, - "RANGE_BOUND": {"max_holding": 15, "take_profit": 0.08, "trail_activation": 0.03}, - "BEAR": {"max_holding": 15, "take_profit": 0.08, "trail_activation": 0.03}, - "CRISIS": {"max_holding": 10, "take_profit": 0.05, "trail_activation": 0.02}, -} - -# ── 레짐별 전략 오버라이드 (Entry/Exit/Risk 모듈화) ── -# 기존 전략 메서드 내에서 self._market_regime을 확인하여 분기 -# ── 레짐별 Kelly Fraction (포지션 사이징 근거) ── -# 기본 Half-Kelly(0.5) 대비 레짐별 공격도 조절 -# STRONG_BULL=0.75 → CRISIS=0.25, 선형 보간 (step=0.10) -# effective_sizing = kelly_fraction / BASE_KELLY(0.5) -BASE_KELLY: float = 0.50 # 현물 기본 Half-Kelly - -REGIME_OVERRIDES: Dict[str, Dict[str, Any]] = { - "STRONG_BULL": { - # Kelly: 3/4 Kelly → effective ×1.50 - "kelly_fraction": 0.75, - # Entry: Donchian 돌파 추가, 피라미딩 허용 - "donchian_entry": True, # 모멘텀에 Donchian 20d 돌파 시그널 추가 - "donchian_period": 20, - "pyramiding_enabled": True, # 추가 매수 허용 (모멘텀 전략) - "pyramiding_max": 1, # 최대 1회 추가 매수 - "pyramiding_pnl_min": 0.05, # 수익 5% 이상일 때 피라미딩 - # Exit: 공격적 트레일링 - "trail_atr_mult": 2.0, # 기본 3.0 → 2.0 (타이트) - "trail_floor_pct": -0.04, # 최소 바닥 -4% - # Risk - "min_cash_override": None, # 기본 사용 - }, - "BULL": { - # Kelly: 0.65 → effective ×1.30 - "kelly_fraction": 0.65, - # Entry: 표준 (기존 유지) - "donchian_entry": False, - "pyramiding_enabled": False, - # Exit: 이격도 부분 청산 - "disparity_partial_sell": True, # MA20 이격도 과대 시 50% 분할 청산 - "disparity_threshold": 1.15, # 가격/MA20 > 115% - "partial_sell_ratio": 0.5, # 50% 청산 - # Risk - "min_cash_override": None, - }, - "NEUTRAL": { - # Kelly: 0.55 → effective ×1.10 - "kelly_fraction": 0.55, - # Entry: MR 강화 (ADX 필터 엄격) - "mr_adx_limit": 22, # 기본 25 → 22 (추세 약한 것만) - # Exit: 공격적 시간감쇄 - "time_decay_enabled": True, - "time_decay_days": 10, # 10일 보유 + 수익 < 2% → 강제 청산 - "time_decay_pnl_min": 0.02, - # Risk - "min_cash_override": None, - }, - "RANGE_BOUND": { - # Kelly: 0.45 → effective ×0.90 - "kelly_fraction": 0.45, - # Entry: 지지/저항 영역 활용 - "sr_zone_entry": True, # S/R 영역 가격일 때만 MR 진입 - "sr_atr_buffer": 1.5, # 지지선 ± 1.5×ATR 내 - # Exit: 박스 이탈 즉시 손절 - "box_breakout_exit": True, # 박스 상단/하단 이탈 시 즉시 청산 - "box_lookback": 40, # 40일 고가/저가 = 박스 범위 - # Risk - "min_cash_override": None, - }, - "BEAR": { - # Kelly: 0.35 → effective ×0.70 - "kelly_fraction": 0.35, - # Entry: 방어 강화 - "defensive_vix_threshold": 20, # 기본 25 → 20 (더 일찍 방어) - # Exit: 빠른 청산 - "bear_exit_tighten": True, # TP 축소, 보유기간 단축 - # Risk - "min_cash_override": 0.50, # 현금 50% 유지 - }, - "CRISIS": { - # Kelly: 1/4 Kelly → effective ×0.50 - "kelly_fraction": 0.25, - # Entry: 안전자산 스위칭 - "safe_haven_enabled": True, # GLD, TLT 등 안전자산 편입 - "crisis_vix_threshold": 30, # VIX > 30 → 최대 방어 - # Exit: 초빠른 청산 - "crisis_exit_immediate": True, # 비방어 포지션 즉시 청산 - # Risk - "min_cash_override": 0.70, # 현금 70% 유지 - }, -} - -# ── VIX 기반 포지션 사이징 스케일러 ── -VIX_SIZING_SCALE = { - # (lower, upper): multiplier - (0, 16): 1.0, # 안정 - (16, 20): 0.9, # 보통 - (20, 25): 0.8, # 경계 - (25, 30): 0.6, # 고변동 - (30, 100): 0.3, # 공포 — 극소 사이징 -} - -# ── 레짐별 전략 비중 매핑 (멀티 전략 모드용) ── -# 레짐 표시명 ↔ primary 전략 1:1 매칭: -# BULL → 추세추종 = momentum primary -# NEUTRAL/RANGE → 평균회귀 = mean_reversion primary -# BEAR → 하락방어 = defensive primary -# 주의: 이전 MR+Def 2전략 집중(Sharpe 2.51)에서 다전략 활성화로 변경 -# 성능 변동 예상 → 반드시 2-year backtest 검증 후 production 적용 -REGIME_STRATEGY_WEIGHTS: Dict[str, Dict[str, float]] = { - # Fallback: 지수 데이터 부족 시 Phase 0 breadth 기반 레짐 사용 - "BULL": {"momentum": 0.30, "smc": 0.15, "mean_reversion": 0.35, "defensive": 0.15, "breakout_retest": 0.05}, - "NEUTRAL": {"mean_reversion": 0.60, "defensive": 0.25, "arbitrage": 0.10, "smc": 0.05}, - "RANGE_BOUND": {"mean_reversion": 0.55, "arbitrage": 0.20, "defensive": 0.20, "smc": 0.05}, - "BEAR": {"defensive": 0.55, "volatility": 0.15, "mean_reversion": 0.25, "smc": 0.05}, -} - -# 지수 추세별 동적 전략 비중 — 표시명과 primary 전략 1:1 매칭 -# _analyze_index_trend() 결과에 따라 REGIME_STRATEGY_WEIGHTS를 오버라이드 -INDEX_TREND_STRATEGY_WEIGHTS: Dict[str, Dict[str, float]] = { - # STRONG_BULL "공격적 추세추종": momentum primary (40%) - # 강한 상승추세 → 모멘텀 breakout 매수 + BRT 돌파 리테스트 - "STRONG_BULL": { - "momentum": 0.40, # PRIMARY: 추세추종 (표시명 매칭) - "breakout_retest": 0.20, # 돌파 리테스트 (강한 추세에서 유효) - "smc": 0.10, # 스마트머니 구조 확인 - "mean_reversion": 0.20, # 눌림목 매수 - "defensive": 0.10, # 최소 헤지 - }, - # BULL "추세추종": momentum primary (30%) - # 상승추세 → 추세 추종 + SMC 구조확인 + MR 눌림목 - "BULL": { - "momentum": 0.30, # PRIMARY: 추세추종 (표시명 매칭) - "smc": 0.15, # SMC 구조 확인 - "mean_reversion": 0.35, # 눌림목 매수 (여전히 강한 알파) - "defensive": 0.15, # 적정 헤지 - "breakout_retest": 0.05, # 소량 돌파 매매 - }, - # NEUTRAL "평균회귀": mean_reversion primary (60%) - # 횡보 → 과매도 반등 매수 + 페어 차익거래 - "NEUTRAL": { - "mean_reversion": 0.60, # PRIMARY: 평균회귀 (표시명 매칭) - "defensive": 0.25, # 적정 헤지 - "arbitrage": 0.10, # 페어 차익거래 - "smc": 0.05, # 구조 필터 - }, - # BEAR "하락방어": defensive primary (55%) - # 하락추세 → 인버스 ETF + VIX 프리미엄 - "BEAR": { - "defensive": 0.55, # PRIMARY: 인버스 헤지 (표시명 매칭) - "volatility": 0.15, # VIX 스파이크 역매매 - "mean_reversion": 0.25, # 극단 과매도만 - "smc": 0.05, # 구조 필터 (반전 감지) - }, - # RANGE_BOUND "박스권 회귀": mean_reversion primary (55%) - # 박스권 → 평균회귀 + 차익거래 강화 - "RANGE_BOUND": { - "mean_reversion": 0.55, # PRIMARY: 박스권 회귀 (표시명 매칭) - "arbitrage": 0.20, # 페어 차익거래 (박스권 유리) - "defensive": 0.20, # 적정 헤지 - "smc": 0.05, # 구조 필터 - }, - # CRISIS "위기방어": defensive primary (85%) - # 위기 → 자본 보존 최우선 - "CRISIS": { - "defensive": 0.85, # PRIMARY: 인버스 헤지 (표시명 매칭) - "mean_reversion": 0.15, # 최소 MR - }, -} - -# 레짐별 전략 표시 이름 (프론트엔드 UI용) -# Multi 모드에서 현재 레짐에 맞는 전략명을 동적으로 표시 -REGIME_DISPLAY_NAMES: Dict[str, Dict[str, str]] = { - "STRONG_BULL": {"ko": "공격적 추세추종", "en": "Aggressive Trend Trail"}, - "BULL": {"ko": "추세추종", "en": "Trend Trail"}, - "NEUTRAL": {"ko": "평균회귀", "en": "Mean Reversion"}, - "RANGE_BOUND": {"ko": "박스권 회귀", "en": "Range Reversion"}, - "BEAR": {"ko": "하락방어", "en": "Bear Shield"}, - "CRISIS": {"ko": "위기방어", "en": "Crisis Shield"}, -} - -# 개별 전략 표시 이름 (프론트엔드 UI용) -STRATEGY_DISPLAY_NAMES: Dict[str, Dict[str, str]] = { - "multi": {"ko": "적응형 알파", "en": "Adaptive Alpha", "desc": "레짐 적응형 동적 전략"}, - "momentum": {"ko": "모멘텀 스윙", "en": "Momentum Swing", "desc": "6-Phase 추세추종 전략"}, - "smc": {"ko": "스마트머니", "en": "Smart Money", "desc": "SMC 4-Layer 구조분석"}, - "mean_reversion": {"ko": "평균회귀", "en": "Mean Reversion", "desc": "과매도 반등 매수 전략"}, - "arbitrage": {"ko": "페어 차익거래", "en": "Pairs Arbitrage", "desc": "Z-Score 통계적 차익"}, - "breakout_retest": {"ko": "돌파 리테스트", "en": "Breakout Retest", "desc": "돌파 후 되돌림 진입"}, - "defensive": {"ko": "인버스 헤지", "en": "Inverse Hedge", "desc": "인버스 ETF 하락 방어"}, - "volatility": {"ko": "변동성 프리미엄", "en": "Vol Premium", "desc": "VIX 스파이크 역매매"}, - # 레짐 전략 모드 표시 이름 - "regime_strong_bull": {"ko": "공격적 추세추종", "en": "Aggressive Trend Trail", "desc": "강세장 전용 · 모멘텀 주도 복합전략"}, - "regime_bull": {"ko": "추세추종", "en": "Trend Trail", "desc": "상승추세 추종 · 모멘텀+MR 복합전략"}, - "regime_neutral": {"ko": "평균회귀", "en": "Mean Reversion", "desc": "횡보장 전용 · 과매도 반등 복합전략"}, - "regime_range_bound": {"ko": "박스권 회귀", "en": "Range Reversion", "desc": "박스권 전용 · 차익거래 보조 복합전략"}, - "regime_bear": {"ko": "하락방어", "en": "Bear Shield", "desc": "하락장 전용 · 인버스 헤지 복합전략"}, - "regime_crisis": {"ko": "위기방어", "en": "Crisis Shield", "desc": "위기 전용 · 자본 보존 복합전략"}, -} - -# 레짐별 전략 구성 메타데이터 (프론트엔드 UI + 내부 문서화) -# 각 레짐의 표시 이름이 primary 전략과 1:1 매칭되도록 구성 -REGIME_STRATEGY_COMPOSITION: Dict[str, Dict] = { - "STRONG_BULL": { - "primary": "momentum", - "secondary": ["breakout_retest", "mean_reversion"], - "filter": ["smc"], - "hedge": ["defensive"], - "rationale": { - "ko": "강한 상승추세 → 모멘텀 추세추종 주력, 돌파매매 보조, MR 눌림목 매수", - "en": "Strong uptrend → momentum primary, breakout secondary, MR dip-buying", - }, - }, - "BULL": { - "primary": "momentum", - "secondary": ["smc", "mean_reversion"], - "filter": [], - "hedge": ["defensive"], - "rationale": { - "ko": "상승추세 → 모멘텀 주력, SMC 구조확인 보조, MR 눌림목 매수", - "en": "Uptrend → momentum primary, SMC structure confirmation, MR dip-buying", - }, - }, - "NEUTRAL": { - "primary": "mean_reversion", - "secondary": ["arbitrage"], - "filter": ["smc"], - "hedge": ["defensive"], - "rationale": { - "ko": "횡보 → 평균회귀 주력, 페어 차익거래 보조", - "en": "Sideways → mean reversion primary, pairs arbitrage secondary", - }, - }, - "RANGE_BOUND": { - "primary": "mean_reversion", - "secondary": ["arbitrage"], - "filter": ["smc"], - "hedge": ["defensive"], - "rationale": { - "ko": "박스권 → 평균회귀 주력, 페어 차익거래 보조", - "en": "Range-bound → mean reversion primary, pairs arbitrage secondary", - }, - }, - "BEAR": { - "primary": "defensive", - "secondary": ["volatility"], - "filter": ["smc"], - "hedge": [], - "rationale": { - "ko": "하락추세 → 인버스 헤지 주력, VIX 프리미엄 보조, 최소 MR", - "en": "Downtrend → inverse hedge primary, VIX premium secondary, minimal MR", - }, - }, - "CRISIS": { - "primary": "defensive", - "secondary": [], - "filter": [], - "hedge": [], - "rationale": { - "ko": "위기 → 인버스 헤지 최대, 자본 보존 최우선", - "en": "Crisis → maximum inverse hedge, capital preservation priority", - }, - }, -} - -# 인버스 ETF 유니버스 (Phase 3.3: Defensive 전략) -INVERSE_ETFS = { - "sp500": ["SH", "SDS"], # ProShares Short S&P500, UltraShort S&P500 - "nasdaq": ["PSQ", "QID"], # ProShares Short QQQ, UltraShort QQQ - "kospi": ["114800.KS", "252670.KS"], # KODEX 인버스, KODEX 200선물인버스2X -} - -# 안전자산 ETF 유니버스 (CRISIS 레짐 방어 전략) -SAFE_HAVEN_ETFS: Dict[str, List[Dict[str, str]]] = { - "sp500": [ - {"ticker": "GLD", "name": "Gold ETF"}, - {"ticker": "TLT", "name": "US Treasury 20Y+"}, - {"ticker": "UUP", "name": "US Dollar Index"}, - ], - "nasdaq": [ - {"ticker": "GLD", "name": "Gold ETF"}, - {"ticker": "TLT", "name": "US Treasury 20Y+"}, - ], - "kospi": [ - {"ticker": "411060.KS", "name": "KODEX 미국달러선물"}, - {"ticker": "132030.KS", "name": "KODEX 골드선물"}, - ], -} - -# 멀티 전략 모드 기본 전략 목록 -MULTI_STRATEGIES = ["momentum", "smc", "breakout_retest", "mean_reversion", "defensive", "volatility", "arbitrage"] - -# 레짐 전략 모드: 레짐 가중치 고정 + multi 파이프라인 재사용 -# 각 모드는 특정 레짐의 전략 비중을 고정하여 실행 (적응형 알파의 개별 테스트용) -REGIME_STRATEGY_MODES: Dict[str, str] = { - "regime_strong_bull": "STRONG_BULL", - "regime_bull": "BULL", - "regime_neutral": "NEUTRAL", - "regime_range_bound": "RANGE_BOUND", - "regime_bear": "BEAR", - "regime_crisis": "CRISIS", -} - - -class StrategyAllocator: - """ - 멀티 전략 모드용 전략별 자본 배분 관리자. - - 각 전략에 레짐 기반 비중을 할당하고, 전략별 포지션 수/자본 한도를 관리. - 물리적 현금 풀은 단일이지만, 가상 예산(virtual budget)으로 전략 간 자본을 분리. - - Phase 3 추가: - - Correlation Control: 전략간 rolling corr 모니터링, corr>0.4 시 비중 조정 - - Dynamic Kelly: regime + VIX + 최근 승률 반영 Kelly × 0.25 - """ - - def __init__(self, strategies: List[str], regime: str = "NEUTRAL"): - self.strategies = strategies - self.regime = regime - self.weights: Dict[str, float] = {} - # Volatility Targeting 상태 - self._daily_returns: List[float] = [] - self._target_vol: float = 0.15 # 연 15% 포트폴리오 변동성 - self._vol_scalar: float = 1.0 # target_vol / realized_vol - self._prev_equity: float = 0.0 - # Phase 7: Risk Parity 상태 - self._rp_weights: Dict[str, float] = {} # 전략별 RP 비중 - self._rp_warmup_done: bool = False # 데이터 충분 여부 - # Correlation Control 상태 (Phase 3.1) - self._strategy_daily_pnl: Dict[str, List[float]] = {s: [] for s in strategies} - self._corr_matrix: Dict[tuple, float] = {} # (s1, s2) → rolling corr - self._corr_adjustment: Dict[str, float] = {} # strategy → 비중 조정 승수 - # Dynamic Kelly 상태 (Phase 3.4) - self._strategy_wins: Dict[str, int] = {s: 0 for s in strategies} - self._strategy_losses: Dict[str, int] = {s: 0 for s in strategies} - self._strategy_win_pnl: Dict[str, float] = {s: 0.0 for s in strategies} - self._strategy_loss_pnl: Dict[str, float] = {s: 0.0 for s in strategies} - self._kelly_scalar: float = 1.0 - self._vix_ema: float = 0.0 - self._apply_regime_weights(regime) - - def _apply_regime_weights(self, regime: str): - """레짐에 맞는 전략 비중 적용 (correlation + Risk Parity 조정 반영).""" - weights = REGIME_STRATEGY_WEIGHTS.get(regime, REGIME_STRATEGY_WEIGHTS["NEUTRAL"]) - raw = {s: weights.get(s, 0.0) for s in self.strategies} - # Correlation 조정 적용 - if self._corr_adjustment: - for s in raw: - raw[s] *= self._corr_adjustment.get(s, 1.0) - total = sum(raw.values()) - if total > 0: - raw = {s: w / total for s, w in raw.items()} - # Phase 7: Risk Parity 블렌딩 (비활성화 — 모든 비율에서 성능 저하 확인) - # RP는 momentum(핵심 전략) 비중을 과도하게 축소하여 net alpha 감소 - # if self._rp_warmup_done and self._rp_weights: - # blended = {} - # for s in self.strategies: - # regime_w = raw.get(s, 0.0) - # rp_w = self._rp_weights.get(s, regime_w) - # blended[s] = 0.90 * regime_w + 0.10 * rp_w - # total = sum(blended.values()) - # if total > 0: - # raw = {s: w / total for s, w in blended.items()} - self.weights = raw - - def update_regime(self, regime: str): - """레짐 변경 시 비중 갱신.""" - if regime != self.regime: - self.regime = regime - self._apply_regime_weights(regime) - - def override_weights(self, weights: Dict[str, float]): - """지수 추세 기반 전략 비중 오버라이드. - - INDEX_TREND_STRATEGY_WEIGHTS에서 받은 비중으로 교체. - 활성 전략에 없는 전략은 무시하고 나머지를 정규화. - Correlation 조정도 적용. - """ - active_weights = {} - for strat, w in weights.items(): - if strat in self.strategies: - active_weights[strat] = w - - if not active_weights: - return # 유효한 전략 없으면 무시 - - # Correlation 조정 적용 - if self._corr_adjustment: - for s in active_weights: - active_weights[s] *= self._corr_adjustment.get(s, 1.0) - - # 정규화 (합 = 1.0) - total = sum(active_weights.values()) - if total > 0: - self.weights = {s: w / total for s, w in active_weights.items()} - - def is_active(self, strategy: str) -> bool: - """해당 전략이 현재 레짐에서 활성인지.""" - return self.weights.get(strategy, 0.0) > 0.01 - - def get_budget(self, strategy: str, total_equity: float, used_by_strategy: float) -> float: - """전략의 남은 가용 자본 (가상 예산).""" - weight = self.weights.get(strategy, 0.0) - budget = total_equity * weight - return max(0.0, budget - used_by_strategy) - - def get_max_positions(self, strategy: str, regime_max: int) -> int: - """전략별 최대 포지션 수 (Largest Remainder Method).""" - dist = self.distribute_positions(regime_max) - return dist.get(strategy, 1) - - def distribute_positions(self, regime_max: int) -> Dict[str, int]: - """Largest Remainder Method로 포지션 수 공정 분배. - - Phase 4.2: round() 대신 LRM 사용 — 저비중 전략 굶주림 해결. - 합계가 regime_max를 초과하지 않도록 보장. - """ - active = {s: w for s, w in self.weights.items() if w > 0.01} - if not active: - return {} - raw = {s: regime_max * w for s, w in active.items()} - floors = {s: int(v) for s, v in raw.items()} - remainders = {s: raw[s] - floors[s] for s in active} - leftover = regime_max - sum(floors.values()) - for s in sorted(remainders, key=lambda k: remainders[k], reverse=True): - if leftover <= 0: - break - floors[s] += 1 - leftover -= 1 - return {s: max(1, v) for s, v in floors.items()} - - def get_max_weight_for_strategy(self, strategy: str, regime_max_weight: float) -> float: - """전략별 종목당 최대 비중. 전략 비중이 작을수록 더 집중.""" - weight = self.weights.get(strategy, 0.0) - if weight < 0.15: - return regime_max_weight - return regime_max_weight - - # ── Volatility Targeting ── - - def update_daily_return(self, total_equity: float): - """일일 수익률 기록 및 변동성 스칼라 갱신.""" - if self._prev_equity > 0: - daily_ret = (total_equity - self._prev_equity) / self._prev_equity - self._daily_returns.append(daily_ret) - if len(self._daily_returns) > 60: - self._daily_returns = self._daily_returns[-60:] - if len(self._daily_returns) >= 20: - recent = self._daily_returns[-20:] - realized_vol = float(np.std(recent)) * (252 ** 0.5) - if realized_vol > 0.001: - self._vol_scalar = min(1.5, max(0.3, self._target_vol / realized_vol)) - else: - self._vol_scalar = 1.0 - self._prev_equity = total_equity - - def get_vol_scalar(self) -> float: - """현재 변동성 타겟팅 스칼라 (0.3 ~ 1.5).""" - return self._vol_scalar - - # ── Phase 7: Risk Parity ── - - def update_risk_parity(self): - """전략별 실현 변동성의 역수에 비례하는 Risk Parity 비중 계산. - - 전략별 일일 PnL의 비영 일(포지션 있는 날)만 사용. - 최소 10일 데이터 필요, 2개 이상 전략에 데이터 있어야 활성화. - """ - active_vols: Dict[str, float] = {} - for s in self.strategies: - pnl_series = self._strategy_daily_pnl.get(s, []) - # 비영 일만 (포지션 있는 날의 PnL) - nonzero = [p for p in pnl_series[-60:] if abs(p) > 0.01] - if len(nonzero) >= 10: - vol = float(np.std(nonzero)) - active_vols[s] = max(vol, 0.001) - - if len(active_vols) < 2: - self._rp_warmup_done = False - return - - # 역변동성 비중 - inv_vols = {s: 1.0 / v for s, v in active_vols.items()} - total_iv = sum(inv_vols.values()) - self._rp_weights = {s: iv / total_iv for s, iv in inv_vols.items()} - self._rp_warmup_done = True - - # 비중 재적용 - self._apply_regime_weights(self.regime) - - # ── Correlation Control (Phase 3.1) ── - - def record_strategy_daily_pnl(self, strategy: str, daily_pnl: float): - """전략별 일일 PnL 기록.""" - if strategy in self._strategy_daily_pnl: - self._strategy_daily_pnl[strategy].append(daily_pnl) - if len(self._strategy_daily_pnl[strategy]) > 60: - self._strategy_daily_pnl[strategy] = self._strategy_daily_pnl[strategy][-60:] - - def update_correlation(self): - """전략간 rolling correlation 계산 및 비중 조정. - - corr > 0.4 인 페어의 고상관 전략 비중 축소, 저상관 전략 비중 확대. - 20거래일 이상 데이터 필요. - """ - active = [s for s in self.strategies if len(self._strategy_daily_pnl.get(s, [])) >= 20] - if len(active) < 2: - return - - # 페어별 correlation 계산 - high_corr_strategies = set() - for i, s1 in enumerate(active): - for s2 in active[i + 1:]: - pnl1 = self._strategy_daily_pnl[s1][-20:] - pnl2 = self._strategy_daily_pnl[s2][-20:] - if np.std(pnl1) < 1e-10 or np.std(pnl2) < 1e-10: - corr = 0.0 - else: - corr = float(np.corrcoef(pnl1, pnl2)[0, 1]) - self._corr_matrix[(s1, s2)] = corr - if corr > 0.4: - high_corr_strategies.add(s1) - high_corr_strategies.add(s2) - - # 비중 조정: 고상관 전략 0.8x, 저상관 전략 1.2x - self._corr_adjustment = {} - for s in self.strategies: - if s in high_corr_strategies: - self._corr_adjustment[s] = 0.8 - elif len(active) > 0 and s in active: - self._corr_adjustment[s] = 1.2 - else: - self._corr_adjustment[s] = 1.0 - - # 비중 재적용 - self._apply_regime_weights(self.regime) - - def get_corr_matrix(self) -> Dict[tuple, float]: - """현재 전략간 상관관계 매트릭스.""" - return self._corr_matrix.copy() - - # ── Dynamic Kelly (Phase 3.4) ── - - def record_trade_result(self, strategy: str, pnl_pct: float): - """전략별 거래 결과 기록 (Kelly 동적 조정용).""" - if strategy not in self._strategy_wins: - return - if pnl_pct > 0: - self._strategy_wins[strategy] += 1 - self._strategy_win_pnl[strategy] += pnl_pct - else: - self._strategy_losses[strategy] += 1 - self._strategy_loss_pnl[strategy] += abs(pnl_pct) - - def update_kelly(self, vix_ema: float): - """Dynamic Kelly 스칼라 갱신. - - Quarter-Kelly(0.25) 기반, regime + VIX + 최근 승률 반영. - kelly_scalar = base(0.25) × regime_mult × vix_mult × performance_mult - """ - self._vix_ema = vix_ema - base_kelly = 0.25 # Quarter-Kelly - - # Regime 승수 - regime_mult = {"BULL": 1.2, "NEUTRAL": 1.0, "RANGE_BOUND": 0.8, "BEAR": 0.5}.get(self.regime, 1.0) - - # VIX 승수 (VIX 높을수록 보수적) - if vix_ema <= 16: - vix_mult = 1.1 - elif vix_ema <= 20: - vix_mult = 1.0 - elif vix_ema <= 25: - vix_mult = 0.8 - elif vix_ema <= 30: - vix_mult = 0.6 - else: - vix_mult = 0.4 - - # 최근 성과 승수 (전체 전략 합산 승률) - total_w = sum(self._strategy_wins.values()) - total_l = sum(self._strategy_losses.values()) - total_trades = total_w + total_l - if total_trades >= 10: - win_rate = total_w / total_trades - if win_rate >= 0.45: - perf_mult = 1.1 - elif win_rate >= 0.35: - perf_mult = 1.0 - elif win_rate >= 0.25: - perf_mult = 0.8 - else: - perf_mult = 0.6 - else: - perf_mult = 1.0 # 데이터 부족 시 기본값 - - self._kelly_scalar = base_kelly * regime_mult * vix_mult * perf_mult - # 범위 제한: 0.05 ~ 0.40 - self._kelly_scalar = min(0.40, max(0.05, self._kelly_scalar)) - - def get_kelly_scalar(self) -> float: - """현재 Dynamic Kelly 스칼라 (0.05 ~ 0.40).""" - return self._kelly_scalar - - -def _compute_adx(high: pd.Series, low: pd.Series, close: pd.Series, period: int = 14): - """순수 pandas로 ADX, +DI, -DI 계산 (ta 라이브러리 의존 없음).""" - plus_dm = high.diff().clip(lower=0) - minus_dm = (-low.diff()).clip(lower=0) - - # +DM과 -DM 중 큰 쪽만 유효 - mask_plus = plus_dm <= minus_dm - mask_minus = minus_dm <= plus_dm - plus_dm = plus_dm.copy() - minus_dm = minus_dm.copy() - plus_dm[mask_plus] = 0 - minus_dm[mask_minus] = 0 - - tr1 = high - low - tr2 = (high - close.shift()).abs() - tr3 = (low - close.shift()).abs() - tr = pd.concat([tr1, tr2, tr3], axis=1).max(axis=1) - - atr = tr.rolling(window=period).mean() - plus_di = 100 * (plus_dm.rolling(window=period).mean() / atr.replace(0, np.nan)) - minus_di = 100 * (minus_dm.rolling(window=period).mean() / atr.replace(0, np.nan)) - - dx = (plus_di - minus_di).abs() / (plus_di + minus_di).replace(0, np.nan) * 100 - adx = dx.rolling(window=period).mean() - - return adx, plus_di, minus_di +from simulation.models import ( + SimSystemState, SimPosition, SimOrder, SimSignal, + SimRiskMetrics, SimTradeRecord, SimEquityPoint, SimPerformanceSummary, +) + +from simulation.constants import ( + WATCHLIST, OnEventType, + REGIME_PARAMS, STOCK_REGIME_THRESHOLDS, REGIME_EXIT_PARAMS, + BASE_KELLY, REGIME_OVERRIDES, VIX_SIZING_SCALE, + REGIME_STRATEGY_WEIGHTS, INDEX_TREND_STRATEGY_WEIGHTS, + REGIME_DISPLAY_NAMES, STRATEGY_DISPLAY_NAMES, + REGIME_STRATEGY_COMPOSITION, + INVERSE_ETFS, SAFE_HAVEN_ETFS, + MULTI_STRATEGIES, REGIME_STRATEGY_MODES, +) + + +from simulation.allocator import StrategyAllocator, _compute_adx # noqa: F401 class SimulationEngine: diff --git a/ats/simulation/models.py b/ats/simulation/models.py new file mode 100644 index 0000000..80d3c1f --- /dev/null +++ b/ats/simulation/models.py @@ -0,0 +1,140 @@ +""" +Simulation engine Pydantic models. +Frontend TypeScript 인터페이스와 1:1 매칭. +Extracted from engine.py for modularity (C2 decomposition). +""" +from __future__ import annotations + +from typing import Optional + +from pydantic import BaseModel + + +class SimSystemState(BaseModel): + status: str = "STOPPED" + mode: str = "PAPER" + started_at: Optional[str] = None + market_phase: str = "CLOSED" + market_regime: str = "NEUTRAL" # BULL / BEAR / NEUTRAL (Phase 0) + next_scan_at: Optional[str] = None + total_equity: float = 0 + cash: float = 0 + invested: float = 0 + daily_pnl: float = 0 + daily_pnl_pct: float = 0 + position_count: int = 0 + max_positions: int = 10 + + +class SimPosition(BaseModel): + id: str + stock_code: str + stock_name: str + status: str # PENDING | ACTIVE | CLOSING | CLOSED + quantity: int + entry_price: float + current_price: float + pnl: float + pnl_pct: float + stop_loss: float + take_profit: float + trailing_stop: float + highest_price: float + entry_date: str + days_held: int + max_holding_days: int = 30 + weight_pct: float + trailing_activated: bool = False + side: str = "LONG" # "LONG" | "SHORT" (Arbitrage 양방향) + lowest_price: float = 0.0 # Short 트레일링용 최저가 추적 + pair_id: Optional[str] = None # Arbitrage 페어 연결 ID + strategy_tag: str = "momentum" # 진입 시 활성 전략 태그 (exit 라우팅용) + scale_count: int = 0 # 피라미딩 횟수 (max 1) + avg_entry_price: float = 0.0 # 가중평균 매입가 (스케일업 시 사용) + entry_signal_strength: int = 0 # 진입 시그널 강도 (0-100) + entry_regime: str = "" # 진입 시 시장 레짐 (BULL/NEUTRAL/BEAR/RANGE_BOUND) + entry_trend_strength: str = "" # 추세 강도 (STRONG/MODERATE/WEAK) + disparity_sold: bool = False # BULL 이격도 부분 청산 완료 여부 + stock_regime: str = "" # 진입 시 종목 개별 레짐 (STRONG_BULL~CRISIS) + + +class SimOrder(BaseModel): + id: str + stock_code: str + stock_name: str + side: str # BUY | SELL + order_type: str # LIMIT | MARKET + status: str # PENDING | FILLED | CANCELLED + price: float + filled_price: Optional[float] = None + quantity: int + filled_quantity: int = 0 + created_at: str + filled_at: Optional[str] = None + reason: str + + +class SimSignal(BaseModel): + id: str + stock_code: str + stock_name: str + type: str # BUY | SELL + price: float + reason: str + strength: int + detected_at: str + + +class SimRiskMetrics(BaseModel): + daily_pnl_pct: float = 0 + daily_loss_limit: float = -5.0 + mdd: float = 0 + mdd_limit: float = -15.0 + cash_ratio: float = 100.0 + min_cash_ratio: float = 30.0 + consecutive_stops: int = 0 + max_consecutive_stops: int = 3 + daily_trade_amount: float = 0 + max_daily_trade_amount: float = 30_000_000 + is_trading_halted: bool = False + halt_reason: Optional[str] = None + + +class SimTradeRecord(BaseModel): + id: str + stock_code: str + stock_name: str + entry_date: str + exit_date: str + entry_price: float + exit_price: float + quantity: int + pnl: float + pnl_pct: float + exit_reason: str + holding_days: int + strategy_tag: str = "momentum" + entry_signal_strength: int = 0 # 진입 시그널 강도 (0-100) + entry_regime: str = "" # 진입 시 시장 레짐 + entry_trend_strength: str = "" # 추세 강도 + stock_regime: str = "" # 진입 시 종목 개별 레짐 + + +class SimEquityPoint(BaseModel): + date: str + equity: float + drawdown_pct: float + + +class SimPerformanceSummary(BaseModel): + total_return_pct: float = 0 + total_trades: int = 0 + win_rate: float = 0 + avg_win_pct: float = 0 + avg_loss_pct: float = 0 + profit_factor: float = 0 + sharpe_ratio: float = 0 + max_drawdown_pct: float = 0 + avg_holding_days: float = 0 + best_trade_pct: float = 0 + worst_trade_pct: float = 0 From 837ca163909bb8a3d2b17c05c5766d006fe6279e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=8B=A0=EB=8C=80=EC=8A=B9?= Date: Sun, 15 Mar 2026 17:23:03 +0900 Subject: [PATCH 3/6] refactor: extract regime.py and risk_gates.py from engine.py (C2 Phase B) Phase B domain logic extraction: - regime.py: market regime, index trend, stock regime classification (~300 lines) - risk_gates.py: RG1-RG5, bearish divergence, S/R detection (~170 lines) All 58 tests pass. No behavioral changes. Co-Authored-By: Claude Opus 4.6 --- ats/simulation/engine.py | 529 ++--------------------------------- ats/simulation/regime.py | 383 +++++++++++++++++++++++++ ats/simulation/risk_gates.py | 171 +++++++++++ 3 files changed, 572 insertions(+), 511 deletions(-) create mode 100644 ats/simulation/regime.py create mode 100644 ats/simulation/risk_gates.py diff --git a/ats/simulation/engine.py b/ats/simulation/engine.py index 1314fe5..2410ee0 100644 --- a/ats/simulation/engine.py +++ b/ats/simulation/engine.py @@ -728,128 +728,12 @@ def _calculate_indicators(self, df: pd.DataFrame) -> pd.DataFrame: # ══════════════════════════════════════════ def _judge_market_regime(self) -> str: - """ - Phase 0: 복합 지표 기반 시장 체제 판단 (4단계). - - 복합 점수 = breadth(40%) + ADX 평균(25%) + VIX(20%) + BB bandwidth(15%) - BULL ≥ 65 | NEUTRAL 40-65 | RANGE_BOUND 25-40 (ADX<20) | BEAR < 25 - - 기존 breadth 단독 판단 대비 횡보장(RANGE_BOUND) 구분 추가. - """ - above_count = 0 - total_valid = 0 - adx_values = [] - bb_bandwidths = [] - - for w in self._watchlist: - code = w["code"] - df = self._ohlcv_cache.get(code) - if df is None or len(df) < 220: - continue - - close = df["close"].astype(float) - ma200 = close.rolling(window=200).mean() - - if pd.isna(ma200.iloc[-1]): - continue - - total_valid += 1 - if float(close.iloc[-1]) > float(ma200.iloc[-1]): - above_count += 1 - - # ADX 수집 (종목별 추세 강도) - if len(df) >= 30: - high = df["high"].astype(float) if "high" in df.columns else None - low = df["low"].astype(float) if "low" in df.columns else None - if high is not None and low is not None: - try: - adx, _, _ = _compute_adx(high, low, close, period=14) - last_adx = adx.iloc[-1] - if pd.notna(last_adx): - adx_values.append(float(last_adx)) - except Exception: - pass - - # BB Bandwidth 수집 (변동성 폭) - if len(df) >= 30: - ma20 = close.rolling(window=20).mean() - std20 = close.rolling(window=20).std() - last_ma20 = ma20.iloc[-1] - last_std = std20.iloc[-1] - if pd.notna(last_ma20) and pd.notna(last_std) and last_ma20 > 0: - bandwidth = float(last_std * 2 / last_ma20) * 100 # % 단위 - bb_bandwidths.append(bandwidth) - - if total_valid < 3: - return self._market_regime # 데이터 부족 → 현재 유지 - - # ── 복합 점수 계산 (0-100) ── - - # 1. Breadth 점수 (40%): 0-100 → 0-40 - breadth_pct = above_count / total_valid * 100 - breadth_score = breadth_pct * 0.40 # 0~40 - - # 2. ADX 평균 점수 (25%): ADX 높을수록 추세 강 → 높은 점수 - # ADX < 15 = 0점, ADX 15-25 = 선형, ADX > 40 = 만점 - if adx_values: - avg_adx = sum(adx_values) / len(adx_values) - adx_score = min(max((avg_adx - 15) / 25 * 25, 0), 25) # 0~25 - else: - avg_adx = 25.0 # 데이터 없으면 중립 - adx_score = 10.0 - - # 3. VIX 점수 (20%): VIX 낮을수록 강세 → 높은 점수 - # VIX < 14 = 20점, VIX 14-30 = 선형 역, VIX > 30 = 0점 - vix = self._vix_ema20 - vix_score = min(max((30 - vix) / 16 * 20, 0), 20) # 0~20 - - # 4. BB Bandwidth 점수 (15%): bandwidth 넓으면 추세/변동 → 점수 높음 - # 좁으면(횡보) 점수 낮음 - if bb_bandwidths: - avg_bw = sum(bb_bandwidths) / len(bb_bandwidths) - # 일반적 bandwidth: 2-10%. 좁은 < 3%, 넓은 > 6% - bw_score = min(max((avg_bw - 2) / 6 * 15, 0), 15) # 0~15 - else: - avg_bw = 4.0 - bw_score = 7.5 - - composite_score = breadth_score + adx_score + vix_score + bw_score - - # ── 레짐 결정 ── - if composite_score >= 65: - raw_regime = "BULL" - elif composite_score >= 40: - # NEUTRAL vs RANGE_BOUND 구분: ADX<20 AND BB bandwidth 하위20% - is_range_bound = ( - adx_values - and avg_adx < 20 - and bb_bandwidths - and avg_bw < 3.5 # 좁은 bandwidth - ) - raw_regime = "RANGE_BOUND" if is_range_bound else "NEUTRAL" - elif composite_score >= 25: - raw_regime = "NEUTRAL" if breadth_pct > 45 else "BEAR" - else: - raw_regime = "BEAR" - - return self._smooth_regime(raw_regime) + from simulation.regime import judge_market_regime + return judge_market_regime(self) def _smooth_regime(self, raw_regime: str) -> str: - """체제 전환 스무딩: N일 연속 동일 신호 시에만 전환.""" - if raw_regime == self._market_regime: - self._regime_candidate = raw_regime - self._regime_candidate_days = 0 - return self._market_regime - - if raw_regime == self._regime_candidate: - self._regime_candidate_days += 1 - if self._regime_candidate_days >= self._regime_confirmation_days: - return raw_regime # 확인 완료 → 체제 전환 - else: - self._regime_candidate = raw_regime - self._regime_candidate_days = 1 - - return self._market_regime # 아직 미확인 → 현재 유지 + from simulation.regime import smooth_regime + return smooth_regime(self, raw_regime) def _update_market_regime(self): """레짐 감지 + 레짐 고정 모드 오버라이드. @@ -895,181 +779,12 @@ def update_index_data(self, date: str, ohlcv: Dict): self._index_ohlcv = self._index_ohlcv[-260:] def _analyze_index_trend(self) -> Dict: - """지수 OHLCV에서 추세 시그널 분석. - - 복합 지표: MA 정렬 + RSI + ADX + MACD + VIX - Returns: - { - "trend": "STRONG_BULL" | "BULL" | "NEUTRAL" | "RANGE_BOUND" | "BEAR" | "CRISIS", - "ma_alignment": "ALIGNED_BULL" | "ALIGNED_BEAR" | "MIXED", - "momentum_score": float (0-100), - "volatility_state": "LOW" | "NORMAL" | "HIGH" | "EXTREME", - "signals": List[str], - } - """ - n = len(self._index_ohlcv) - if n < 50: - return {"trend": "NEUTRAL", "ma_alignment": "MIXED", - "momentum_score": 50.0, "volatility_state": "NORMAL", - "signals": ["지수 데이터 부족 (< 50일)"]} - - closes = pd.Series([d["close"] for d in self._index_ohlcv]) - highs = pd.Series([d["high"] for d in self._index_ohlcv]) - lows = pd.Series([d["low"] for d in self._index_ohlcv]) - signals = [] - - # ── MA Alignment ── - ma20 = closes.rolling(20).mean().iloc[-1] if n >= 20 else closes.mean() - ma50 = closes.rolling(50).mean().iloc[-1] if n >= 50 else closes.mean() - ma200 = closes.rolling(200).mean().iloc[-1] if n >= 200 else None - current_close = closes.iloc[-1] - - if ma200 is not None and not pd.isna(ma200): - if current_close > ma50 > ma200: - ma_state = "ALIGNED_BULL" - signals.append(f"지수 MA 정렬: Close > MA50 > MA200") - elif current_close < ma50 < ma200: - ma_state = "ALIGNED_BEAR" - signals.append(f"지수 MA 정렬: Close < MA50 < MA200") - else: - ma_state = "MIXED" - signals.append(f"지수 MA 혼합") - elif current_close > ma50: - ma_state = "ALIGNED_BULL" - signals.append(f"지수 Close > MA50 (MA200 미계산)") - else: - ma_state = "MIXED" - signals.append(f"지수 MA 혼합 (MA200 미계산)") - - # ── RSI(14) ── - delta = closes.diff() - gain = delta.where(delta > 0, 0.0).rolling(14).mean() - loss = (-delta.where(delta < 0, 0.0)).rolling(14).mean() - rs = gain.iloc[-1] / loss.iloc[-1] if loss.iloc[-1] > 0 else 100 - rsi = 100.0 - (100.0 / (1.0 + rs)) - signals.append(f"지수 RSI: {rsi:.1f}") - - # ── ADX(14) ── - try: - adx_series, _, _ = _compute_adx(highs, lows, closes, period=14) - adx = float(adx_series.iloc[-1]) if pd.notna(adx_series.iloc[-1]) else 20.0 - except Exception: - adx = 20.0 - signals.append(f"지수 ADX: {adx:.1f}") - - # ── MACD(12, 26, 9) ── - ema12 = closes.ewm(span=12, adjust=False).mean() - ema26 = closes.ewm(span=26, adjust=False).mean() - macd_line = ema12 - ema26 - signal_line = macd_line.ewm(span=9, adjust=False).mean() - macd_hist = macd_line.iloc[-1] - signal_line.iloc[-1] - macd_positive = macd_hist > 0 - signals.append(f"지수 MACD 히스토그램: {macd_hist:.2f} ({'양' if macd_positive else '음'})") - - # ── Momentum Score (0-100) ── - # RSI 정규화: 30-70 → 0-100 (중립 = 50) - rsi_norm = min(max((rsi - 30) / 40 * 40, 0), 40) # 0~40 - # ADX 정규화: 0-50 → 0-35 - adx_norm = min(max(adx / 50 * 35, 0), 35) # 0~35 - # MACD 보너스: 양전환 +25, 음전환 0 - macd_bonus = 25 if macd_positive else 0 - momentum_score = rsi_norm + adx_norm + macd_bonus - momentum_score = min(max(momentum_score, 0), 100) - - # ── Volatility State (VIX 기반) ── - vix = self._vix_ema20 - if vix < 16: - volatility_state = "LOW" - elif vix < 22: - volatility_state = "NORMAL" - elif vix < 30: - volatility_state = "HIGH" - else: - volatility_state = "EXTREME" - signals.append(f"VIX EMA20: {vix:.1f} → {volatility_state}") - - # ── Final Trend Classification ── - if ma_state == "ALIGNED_BULL" and adx > 30 and rsi > 55: - trend = "STRONG_BULL" - elif ma_state == "ALIGNED_BULL" or ( - (ma200 is None or current_close > ma200) and momentum_score > 55 - ): - trend = "BULL" - elif ma_state == "ALIGNED_BEAR" and volatility_state in ("HIGH", "EXTREME"): - trend = "CRISIS" - elif ma_state == "ALIGNED_BEAR": - trend = "BEAR" - elif adx < 20 and volatility_state in ("LOW", "NORMAL"): - trend = "RANGE_BOUND" # 저추세 + 저변동성 = 박스권 - else: - trend = "NEUTRAL" - signals.append(f"최종 지수 추세: {trend}") - - return { - "trend": trend, - "ma_alignment": ma_state, - "momentum_score": round(momentum_score, 1), - "volatility_state": volatility_state, - "signals": signals, - "rsi": round(rsi, 1), - "adx": round(adx, 1), - "macd_value": round(float(macd_line.iloc[-1]), 2), - "macd_signal": round(float(signal_line.iloc[-1]), 2), - } + from simulation.regime import analyze_index_trend + return analyze_index_trend(self) def _update_strategy_weights_from_index(self): - """지수 추세 분석 → 전략 비중 동적 오버라이드. - - 레짐 고정 모드: 고정 레짐의 가중치 강제 적용 (자동 감지 건너뜀). - multi 모드: 지수 데이터 있으면 INDEX_TREND_STRATEGY_WEIGHTS 적용. - 지수 데이터 부족하면 기존 REGIME_STRATEGY_WEIGHTS 유지 (fallback). - """ - # 레짐 전략 모드: 고정 레짐 가중치 강제 적용 - if self._regime_locked: - weights = INDEX_TREND_STRATEGY_WEIGHTS.get( - self._locked_regime, - INDEX_TREND_STRATEGY_WEIGHTS.get("NEUTRAL", {}) - ) - if self._strategy_allocator: - self._strategy_allocator.override_weights(weights) - # 지수 추세 분석은 여전히 실행 (표시용) - if len(self._index_ohlcv) >= 50: - self._index_trend = self._analyze_index_trend() - return - - if len(self._index_ohlcv) < 50: - return # 데이터 부족 → 기존 로직 유지 - - old_trend = self._index_trend.get("trend") if self._index_trend else None - old_weights = ( - dict(self._strategy_allocator.weights) - if self._strategy_allocator else {} - ) - - self._index_trend = self._analyze_index_trend() - trend = self._index_trend.get("trend", "NEUTRAL") - - weights = INDEX_TREND_STRATEGY_WEIGHTS.get( - trend, INDEX_TREND_STRATEGY_WEIGHTS["NEUTRAL"] - ) - - if self._strategy_allocator: - self._strategy_allocator.override_weights(weights) - self._phase_stats["index_trend_updates"] += 1 - - # 추세 변경 이력 기록 - if old_trend != trend: - ts = self._backtest_date or datetime.now().strftime("%Y-%m-%d %H:%M") - self._index_trend_history.append({ - "timestamp": ts, - "from_trend": old_trend, - "to_trend": trend, - "from_weights": old_weights, - "to_weights": dict(weights), - "trigger_signals": self._index_trend.get("signals", [])[-3:], - }) - if len(self._index_trend_history) > 20: - self._index_trend_history = self._index_trend_history[-20:] + from simulation.regime import update_strategy_weights_from_index + update_strategy_weights_from_index(self) def get_market_intelligence(self) -> Dict: """프론트엔드용 마켓 인텔리전스 데이터 반환.""" @@ -1258,72 +973,8 @@ def _estimate_trend_stage(self, df: pd.DataFrame) -> str: # ══════════════════════════════════════════ def _classify_stock_regime(self, df: pd.DataFrame) -> str: - """ - 종목 개별 레짐 분류 (0-100 복합 스코어). - - 기존 _confirm_trend(), _estimate_trend_stage() 결과를 종합하여 - 글로벌 레짐과 독립적으로 각 종목의 추세 상태를 6단계로 판정. - - 구성요소: - (1) MA 정렬 점수 (0-30): alignment_score 0~5 → 0~30 - (2) ADX 방향 점수 (0-20): UP 추세 + ADX 강도 - (3) RSI 위치 점수 (0-20): 강세/약세 위치 - (4) Price vs MA200 (0-15): 장기 추세 위치 - (5) Trend Stage 보너스 (0-15): EARLY/MID/LATE - """ - if len(df) < 200: - return "NEUTRAL" - - trend = self._confirm_trend(df) - stage = self._estimate_trend_stage(df) - curr = df.iloc[-1] - score = 0.0 - - # (1) MA 정렬 점수 (0-30): alignment_score 0~5 → 0~30 - alignment = trend.get("alignment_score", 0) - score += alignment * 6 # 0, 6, 12, 18, 24, 30 - - # (2) ADX 방향 점수 (0-20) - adx = trend.get("adx", 0) - direction = trend.get("direction", "FLAT") - if direction == "UP": - score += min(adx / 50 * 20, 20) # ADX 높을수록 가산 - elif direction == "DOWN": - score += max(0, 5 - adx / 50 * 5) # 하락+ADX 강 → 낮은 점수 - else: - score += 10 # FLAT → 중립 - - # (3) RSI 위치 점수 (0-20) - rsi = float(curr.get("rsi", 50)) if pd.notna(curr.get("rsi")) else 50 - if rsi >= 55: - score += min((rsi - 40) / 40 * 20, 20) # RSI 55~80 → 7.5~20 - elif rsi <= 35: - score += max(0, rsi / 35 * 5) # RSI 0~35 → 0~5 - else: - score += 10 # 35~55 → 중립 - - # (4) Price vs MA200 (0-15) - ma200 = float(curr.get("ma200", 0)) if pd.notna(curr.get("ma200")) else 0 - price = float(curr["close"]) - if ma200 > 0: - ratio = price / ma200 - if ratio > 1.0: - score += min((ratio - 1.0) * 100, 15) # 1%=1pt, max 15 - else: - score += max(0, 15 - (1.0 - ratio) * 100) - else: - score += 7 # 데이터 없으면 중립 - - # (5) Trend Stage 보너스 (0-15) - stage_bonus = {"EARLY": 15, "MID": 8, "LATE": 2} - score += stage_bonus.get(stage, 8) - - # 스코어 → 레짐 매핑 - score = max(0, min(100, score)) - for threshold, regime in STOCK_REGIME_THRESHOLDS: - if score >= threshold: - return regime - return "CRISIS" + from simulation.regime import classify_stock_regime + return classify_stock_regime(self, df) def _update_stock_regimes(self, force: bool = False): """워치리스트 종목의 개별 레짐 갱신. 성능을 위해 7일 주기로 실행.""" @@ -1355,76 +1006,8 @@ def _update_stock_regimes(self, force: bool = False): # ══════════════════════════════════════════ def _risk_gate_check(self) -> tuple: - """ - Phase 4: 리스크 게이트. 하나라도 실패 시 (False, reason) 반환. - - Phase 3.2 단계적 DD 대응: - DD > 10% → 포지션 사이즈 50% 감축 (dd_sizing_mult=0.5) - DD > 15% → 신규 진입 차단 - DD > 20% → 전 포지션 청산, 시스템 정지 - """ - total_equity = self._get_total_equity() - daily_pnl_pct = ( - (total_equity - self._daily_start_equity) / self._daily_start_equity * 100 - if self._daily_start_equity > 0 else 0 - ) - - # RG1: 일일 손실 -10% → 매매 중단 - if daily_pnl_pct <= -10.0: - return False, "RG1: 일일 손실 -10% 도달" - - # DD 단계적 대응 (Phase 3.2) - self._peak_equity = max(self._peak_equity, total_equity) - mdd = ( - (total_equity - self._peak_equity) / self._peak_equity * 100 - if self._peak_equity > 0 else 0 - ) - - # RG2c: DD > 20% → 전 포지션 청산 + 시스템 정지 - if mdd <= -20.0: - self._dd_level = 3 - self._force_liquidate_all("DD>20% 시스템 정지") - return False, "RG2c: DD -20% — 전 포지션 청산, 시스템 정지" - - # RG2b: DD > 15% → 신규 진입 차단 (기존 포지션은 유지) - if mdd <= -15.0: - self._dd_level = 2 - return False, "RG2b: DD -15% — 신규 진입 차단" - - # RG2a: DD > 10% → 사이징 50% 감축 (진입은 허용) - if mdd <= -10.0: - self._dd_level = 1 - self._dd_sizing_mult = 0.5 - else: - self._dd_level = 0 - self._dd_sizing_mult = 1.0 - - # RG3: 최대 포지션 수 (regime별) - active_count = len([p for p in self.positions.values() if p.status == "ACTIVE"]) - regime_params = REGIME_PARAMS.get(self._market_regime, REGIME_PARAMS["NEUTRAL"]) - if active_count >= regime_params["max_positions"]: - return False, f"RG3: 최대 보유 {regime_params['max_positions']}종목 도달" - - # RG4: 현금 비율 (활성 포지션 수에 따라 동적 적용) - cash_ratio = self.cash / total_equity if total_equity > 0 else 1.0 - effective_rg4 = self.min_cash_ratio - if self._allocator: - ac = sum(1 for p in self.positions.values() if p.status == "ACTIVE") - if ac <= 2: - effective_rg4 = max(0.50, self.min_cash_ratio - 0.20) - # RG4b: 레짐별 현금 비율 오버라이드 (BEAR: 50%, CRISIS: 70%) - regime_cash = REGIME_OVERRIDES.get(self._market_regime, {}).get("min_cash_override") - if regime_cash is not None: - effective_rg4 = max(effective_rg4, regime_cash) - - if cash_ratio < effective_rg4: - return False, f"RG4: 현금 비율 {effective_rg4*100:.0f}% 미만" - - # RG5: VIX > 30 공포 구간 → 신규 진입 차단 - if self._vix_ema20 > 30: - return False, f"RG5: VIX 공포구간 ({self._vix_ema20:.1f} > 30)" - - return True, None + from simulation.risk_gates import risk_gate_check + return risk_gate_check(self) def _force_liquidate_all(self, reason: str): """DD>20% 시 모든 ACTIVE 포지션 강제 청산.""" @@ -1455,93 +1038,17 @@ def force_liquidate_all_immediate(self) -> dict: return {"positions_closed": len(closed), "details": closed} def _detect_bearish_divergence(self, df: pd.DataFrame, lookback: int = 10) -> bool: - """ - 베어리시 다이버전스 감지: 가격은 고점 갱신인데 RSI는 고점 하락. - True → 진입 차단 (모멘텀 약화 신호). - """ - if len(df) < lookback + 2 or "rsi" not in df.columns: - return False - - recent = df.iloc[-lookback:] - price = recent["close"].astype(float) - rsi = recent["rsi"] - - if rsi.isna().any(): - return False - - price_peaks = [] - rsi_at_peaks = [] - for i in range(1, len(recent) - 1): - if float(price.iloc[i]) > float(price.iloc[i - 1]) and float(price.iloc[i]) > float(price.iloc[i + 1]): - price_peaks.append(float(price.iloc[i])) - rsi_at_peaks.append(float(rsi.iloc[i])) - - if len(price_peaks) >= 2: - if price_peaks[-1] > price_peaks[-2] and rsi_at_peaks[-1] < rsi_at_peaks[-2]: - return True - - return False + from simulation.risk_gates import detect_bearish_divergence + return detect_bearish_divergence(df, lookback) def _detect_support_resistance(self, df: pd.DataFrame, lookback: int = 40) -> dict: - """최근 N봉의 스윙 포인트를 클러스터링하여 S/R 레벨 반환. - RANGE_BOUND 레짐에서 MR 진입 시 가격이 지지선 근처인지 확인. - """ - if len(df) < lookback: - return {"support": [], "resistance": []} - - recent = df.tail(lookback) - levels: List[tuple] = [] - - # 3-candle 프랙탈 기반 스윙 포인트 - highs = recent["high"].astype(float).values - lows = recent["low"].astype(float).values - for i in range(1, len(recent) - 1): - if highs[i] > highs[i - 1] and highs[i] > highs[i + 1]: - levels.append(("R", float(highs[i]))) - if lows[i] < lows[i - 1] and lows[i] < lows[i + 1]: - levels.append(("S", float(lows[i]))) - - if not levels: - return {"support": [], "resistance": []} - - # 1.5% 이내 레벨 클러스터링 - clustered = self._cluster_levels(levels, tolerance=0.015) - return { - "support": [l for t, l in clustered if t == "S"], - "resistance": [l for t, l in clustered if t == "R"], - } + from simulation.risk_gates import detect_support_resistance + return detect_support_resistance(df, lookback) @staticmethod def _cluster_levels(levels: list, tolerance: float = 0.015) -> list: - """가격 레벨을 tolerance % 이내로 클러스터링. - 각 클러스터에서 가장 빈번한 타입과 평균 가격을 반환. - """ - if not levels: - return [] - - # 가격순 정렬 - sorted_levels = sorted(levels, key=lambda x: x[1]) - clusters: List[list] = [[sorted_levels[0]]] - - for item in sorted_levels[1:]: - last_cluster = clusters[-1] - avg_price = sum(l[1] for l in last_cluster) / len(last_cluster) - if abs(item[1] - avg_price) / avg_price <= tolerance: - last_cluster.append(item) - else: - clusters.append([item]) - - # 각 클러스터에서 대표값 추출 - result = [] - for cluster in clusters: - avg_price = sum(l[1] for l in cluster) / len(cluster) - # 다수 타입 결정 - s_count = sum(1 for t, _ in cluster if t == "S") - r_count = len(cluster) - s_count - dominant_type = "S" if s_count >= r_count else "R" - result.append((dominant_type, round(avg_price, 2))) - - return result + from simulation.risk_gates import cluster_levels + return cluster_levels(levels, tolerance) # ══════════════════════════════════════════ # 진입 시그널 스캔 (6-Phase 통합 파이프라인) diff --git a/ats/simulation/regime.py b/ats/simulation/regime.py new file mode 100644 index 0000000..13cf2f5 --- /dev/null +++ b/ats/simulation/regime.py @@ -0,0 +1,383 @@ +""" +Market regime detection: breadth, index trend, stock regime classification. +Extracted from engine.py for modularity (C2 decomposition). + +All functions receive the engine instance to access its state. +""" +from __future__ import annotations + +from datetime import datetime +from typing import TYPE_CHECKING, Dict + +import numpy as np +import pandas as pd + +from simulation.allocator import _compute_adx +from simulation.constants import ( + INDEX_TREND_STRATEGY_WEIGHTS, + STOCK_REGIME_THRESHOLDS, +) + +if TYPE_CHECKING: + from simulation.engine import SimulationEngine + + +def judge_market_regime(engine: SimulationEngine) -> str: + """ + Phase 0: 복합 지표 기반 시장 체제 판단 (4단계). + + 복합 점수 = breadth(40%) + ADX 평균(25%) + VIX(20%) + BB bandwidth(15%) + BULL ≥ 65 | NEUTRAL 40-65 | RANGE_BOUND 25-40 (ADX<20) | BEAR < 25 + """ + above_count = 0 + total_valid = 0 + adx_values = [] + bb_bandwidths = [] + + for w in engine._watchlist: + code = w["code"] + df = engine._ohlcv_cache.get(code) + if df is None or len(df) < 220: + continue + + close = df["close"].astype(float) + ma200 = close.rolling(window=200).mean() + + if pd.isna(ma200.iloc[-1]): + continue + + total_valid += 1 + if float(close.iloc[-1]) > float(ma200.iloc[-1]): + above_count += 1 + + # ADX 수집 (종목별 추세 강도) + if len(df) >= 30: + high = df["high"].astype(float) if "high" in df.columns else None + low = df["low"].astype(float) if "low" in df.columns else None + if high is not None and low is not None: + try: + adx, _, _ = _compute_adx(high, low, close, period=14) + last_adx = adx.iloc[-1] + if pd.notna(last_adx): + adx_values.append(float(last_adx)) + except Exception: + pass + + # BB Bandwidth 수집 (변동성 폭) + if len(df) >= 30: + ma20 = close.rolling(window=20).mean() + std20 = close.rolling(window=20).std() + last_ma20 = ma20.iloc[-1] + last_std = std20.iloc[-1] + if pd.notna(last_ma20) and pd.notna(last_std) and last_ma20 > 0: + bandwidth = float(last_std * 2 / last_ma20) * 100 # % 단위 + bb_bandwidths.append(bandwidth) + + if total_valid < 3: + return engine._market_regime # 데이터 부족 → 현재 유지 + + # ── 복합 점수 계산 (0-100) ── + + # 1. Breadth 점수 (40%): 0-100 → 0-40 + breadth_pct = above_count / total_valid * 100 + breadth_score = breadth_pct * 0.40 # 0~40 + + # 2. ADX 평균 점수 (25%): ADX 높을수록 추세 강 → 높은 점수 + if adx_values: + avg_adx = sum(adx_values) / len(adx_values) + adx_score = min(max((avg_adx - 15) / 25 * 25, 0), 25) # 0~25 + else: + avg_adx = 25.0 # 데이터 없으면 중립 + adx_score = 10.0 + + # 3. VIX 점수 (20%): VIX 낮을수록 강세 → 높은 점수 + vix = engine._vix_ema20 + vix_score = min(max((30 - vix) / 16 * 20, 0), 20) # 0~20 + + # 4. BB Bandwidth 점수 (15%) + if bb_bandwidths: + avg_bw = sum(bb_bandwidths) / len(bb_bandwidths) + bw_score = min(max((avg_bw - 2) / 6 * 15, 0), 15) # 0~15 + else: + avg_bw = 4.0 + bw_score = 7.5 + + composite_score = breadth_score + adx_score + vix_score + bw_score + + # ── 레짐 결정 ── + if composite_score >= 65: + raw_regime = "BULL" + elif composite_score >= 40: + is_range_bound = ( + adx_values + and avg_adx < 20 + and bb_bandwidths + and avg_bw < 3.5 + ) + raw_regime = "RANGE_BOUND" if is_range_bound else "NEUTRAL" + elif composite_score >= 25: + raw_regime = "NEUTRAL" if breadth_pct > 45 else "BEAR" + else: + raw_regime = "BEAR" + + return smooth_regime(engine, raw_regime) + + +def smooth_regime(engine: SimulationEngine, raw_regime: str) -> str: + """체제 전환 스무딩: N일 연속 동일 신호 시에만 전환.""" + if raw_regime == engine._market_regime: + engine._regime_candidate = raw_regime + engine._regime_candidate_days = 0 + return engine._market_regime + + if raw_regime == engine._regime_candidate: + engine._regime_candidate_days += 1 + if engine._regime_candidate_days >= engine._regime_confirmation_days: + return raw_regime # 확인 완료 → 체제 전환 + else: + engine._regime_candidate = raw_regime + engine._regime_candidate_days = 1 + + return engine._market_regime # 아직 미확인 → 현재 유지 + + +def analyze_index_trend(engine: SimulationEngine) -> Dict: + """지수 OHLCV에서 추세 시그널 분석. + + 복합 지표: MA 정렬 + RSI + ADX + MACD + VIX + Returns: + { + "trend": "STRONG_BULL" | "BULL" | "NEUTRAL" | "RANGE_BOUND" | "BEAR" | "CRISIS", + "ma_alignment": "ALIGNED_BULL" | "ALIGNED_BEAR" | "MIXED", + "momentum_score": float (0-100), + "volatility_state": "LOW" | "NORMAL" | "HIGH" | "EXTREME", + "signals": List[str], + } + """ + n = len(engine._index_ohlcv) + if n < 50: + return {"trend": "NEUTRAL", "ma_alignment": "MIXED", + "momentum_score": 50.0, "volatility_state": "NORMAL", + "signals": ["지수 데이터 부족 (< 50일)"]} + + closes = pd.Series([d["close"] for d in engine._index_ohlcv]) + highs = pd.Series([d["high"] for d in engine._index_ohlcv]) + lows = pd.Series([d["low"] for d in engine._index_ohlcv]) + signals = [] + + # ── MA Alignment ── + ma20 = closes.rolling(20).mean().iloc[-1] if n >= 20 else closes.mean() + ma50 = closes.rolling(50).mean().iloc[-1] if n >= 50 else closes.mean() + ma200 = closes.rolling(200).mean().iloc[-1] if n >= 200 else None + current_close = closes.iloc[-1] + + if ma200 is not None and not pd.isna(ma200): + if current_close > ma50 > ma200: + ma_state = "ALIGNED_BULL" + signals.append(f"지수 MA 정렬: Close > MA50 > MA200") + elif current_close < ma50 < ma200: + ma_state = "ALIGNED_BEAR" + signals.append(f"지수 MA 정렬: Close < MA50 < MA200") + else: + ma_state = "MIXED" + signals.append(f"지수 MA 혼합") + elif current_close > ma50: + ma_state = "ALIGNED_BULL" + signals.append(f"지수 Close > MA50 (MA200 미계산)") + else: + ma_state = "MIXED" + signals.append(f"지수 MA 혼합 (MA200 미계산)") + + # ── RSI(14) ── + delta = closes.diff() + gain = delta.where(delta > 0, 0.0).rolling(14).mean() + loss = (-delta.where(delta < 0, 0.0)).rolling(14).mean() + rs = gain.iloc[-1] / loss.iloc[-1] if loss.iloc[-1] > 0 else 100 + rsi = 100.0 - (100.0 / (1.0 + rs)) + signals.append(f"지수 RSI: {rsi:.1f}") + + # ── ADX(14) ── + try: + adx_series, _, _ = _compute_adx(highs, lows, closes, period=14) + adx = float(adx_series.iloc[-1]) if pd.notna(adx_series.iloc[-1]) else 20.0 + except Exception: + adx = 20.0 + signals.append(f"지수 ADX: {adx:.1f}") + + # ── MACD(12, 26, 9) ── + ema12 = closes.ewm(span=12, adjust=False).mean() + ema26 = closes.ewm(span=26, adjust=False).mean() + macd_line = ema12 - ema26 + signal_line = macd_line.ewm(span=9, adjust=False).mean() + macd_hist = macd_line.iloc[-1] - signal_line.iloc[-1] + macd_positive = macd_hist > 0 + signals.append(f"지수 MACD 히스토그램: {macd_hist:.2f} ({'양' if macd_positive else '음'})") + + # ── Momentum Score (0-100) ── + rsi_norm = min(max((rsi - 30) / 40 * 40, 0), 40) # 0~40 + adx_norm = min(max(adx / 50 * 35, 0), 35) # 0~35 + macd_bonus = 25 if macd_positive else 0 + momentum_score = rsi_norm + adx_norm + macd_bonus + momentum_score = min(max(momentum_score, 0), 100) + + # ── Volatility State (VIX 기반) ── + vix = engine._vix_ema20 + if vix < 16: + volatility_state = "LOW" + elif vix < 22: + volatility_state = "NORMAL" + elif vix < 30: + volatility_state = "HIGH" + else: + volatility_state = "EXTREME" + signals.append(f"VIX EMA20: {vix:.1f} → {volatility_state}") + + # ── Final Trend Classification ── + if ma_state == "ALIGNED_BULL" and adx > 30 and rsi > 55: + trend = "STRONG_BULL" + elif ma_state == "ALIGNED_BULL" or ( + (ma200 is None or current_close > ma200) and momentum_score > 55 + ): + trend = "BULL" + elif ma_state == "ALIGNED_BEAR" and volatility_state in ("HIGH", "EXTREME"): + trend = "CRISIS" + elif ma_state == "ALIGNED_BEAR": + trend = "BEAR" + elif adx < 20 and volatility_state in ("LOW", "NORMAL"): + trend = "RANGE_BOUND" + else: + trend = "NEUTRAL" + signals.append(f"최종 지수 추세: {trend}") + + return { + "trend": trend, + "ma_alignment": ma_state, + "momentum_score": round(momentum_score, 1), + "volatility_state": volatility_state, + "signals": signals, + "rsi": round(rsi, 1), + "adx": round(adx, 1), + "macd_value": round(float(macd_line.iloc[-1]), 2), + "macd_signal": round(float(signal_line.iloc[-1]), 2), + } + + +def update_strategy_weights_from_index(engine: SimulationEngine): + """지수 추세 분석 → 전략 비중 동적 오버라이드. + + 레짐 고정 모드: 고정 레짐의 가중치 강제 적용 (자동 감지 건너뜀). + multi 모드: 지수 데이터 있으면 INDEX_TREND_STRATEGY_WEIGHTS 적용. + 지수 데이터 부족하면 기존 REGIME_STRATEGY_WEIGHTS 유지 (fallback). + """ + # 레짐 전략 모드: 고정 레짐 가중치 강제 적용 + if engine._regime_locked: + weights = INDEX_TREND_STRATEGY_WEIGHTS.get( + engine._locked_regime, + INDEX_TREND_STRATEGY_WEIGHTS.get("NEUTRAL", {}) + ) + if engine._strategy_allocator: + engine._strategy_allocator.override_weights(weights) + # 지수 추세 분석은 여전히 실행 (표시용) + if len(engine._index_ohlcv) >= 50: + engine._index_trend = analyze_index_trend(engine) + return + + if len(engine._index_ohlcv) < 50: + return # 데이터 부족 → 기존 로직 유지 + + old_trend = engine._index_trend.get("trend") if engine._index_trend else None + old_weights = ( + dict(engine._strategy_allocator.weights) + if engine._strategy_allocator else {} + ) + + engine._index_trend = analyze_index_trend(engine) + trend = engine._index_trend.get("trend", "NEUTRAL") + + weights = INDEX_TREND_STRATEGY_WEIGHTS.get( + trend, INDEX_TREND_STRATEGY_WEIGHTS["NEUTRAL"] + ) + + if engine._strategy_allocator: + engine._strategy_allocator.override_weights(weights) + engine._phase_stats["index_trend_updates"] += 1 + + # 추세 변경 이력 기록 + if old_trend != trend: + ts = engine._backtest_date or datetime.now().strftime("%Y-%m-%d %H:%M") + engine._index_trend_history.append({ + "timestamp": ts, + "from_trend": old_trend, + "to_trend": trend, + "from_weights": old_weights, + "to_weights": dict(weights), + "trigger_signals": engine._index_trend.get("signals", [])[-3:], + }) + if len(engine._index_trend_history) > 20: + engine._index_trend_history = engine._index_trend_history[-20:] + + +def classify_stock_regime(engine: SimulationEngine, df: pd.DataFrame) -> str: + """ + 종목 개별 레짐 분류 (0-100 복합 스코어). + + 구성요소: + (1) MA 정렬 점수 (0-30): alignment_score 0~5 → 0~30 + (2) ADX 방향 점수 (0-20): UP 추세 + ADX 강도 + (3) RSI 위치 점수 (0-20): 강세/약세 위치 + (4) Price vs MA200 (0-15): 장기 추세 위치 + (5) Trend Stage 보너스 (0-15): EARLY/MID/LATE + """ + if len(df) < 200: + return "NEUTRAL" + + trend = engine._confirm_trend(df) + stage = engine._estimate_trend_stage(df) + curr = df.iloc[-1] + score = 0.0 + + # (1) MA 정렬 점수 (0-30) + alignment = trend.get("alignment_score", 0) + score += alignment * 6 # 0, 6, 12, 18, 24, 30 + + # (2) ADX 방향 점수 (0-20) + adx = trend.get("adx", 0) + direction = trend.get("direction", "FLAT") + if direction == "UP": + score += min(adx / 50 * 20, 20) + elif direction == "DOWN": + score += max(0, 5 - adx / 50 * 5) + else: + score += 10 + + # (3) RSI 위치 점수 (0-20) + rsi = float(curr.get("rsi", 50)) if pd.notna(curr.get("rsi")) else 50 + if rsi >= 55: + score += min((rsi - 40) / 40 * 20, 20) + elif rsi <= 35: + score += max(0, rsi / 35 * 5) + else: + score += 10 + + # (4) Price vs MA200 (0-15) + ma200 = float(curr.get("ma200", 0)) if pd.notna(curr.get("ma200")) else 0 + price = float(curr["close"]) + if ma200 > 0: + ratio = price / ma200 + if ratio > 1.0: + score += min((ratio - 1.0) * 100, 15) + else: + score += max(0, 15 - (1.0 - ratio) * 100) + else: + score += 7 + + # (5) Trend Stage 보너스 (0-15) + stage_bonus = {"EARLY": 15, "MID": 8, "LATE": 2} + score += stage_bonus.get(stage, 8) + + # 스코어 → 레짐 매핑 + score = max(0, min(100, score)) + for threshold, regime in STOCK_REGIME_THRESHOLDS: + if score >= threshold: + return regime + return "CRISIS" diff --git a/ats/simulation/risk_gates.py b/ats/simulation/risk_gates.py new file mode 100644 index 0000000..6948824 --- /dev/null +++ b/ats/simulation/risk_gates.py @@ -0,0 +1,171 @@ +""" +Risk gate checks: RG1-RG5, bearish divergence, S/R detection. +Extracted from engine.py for modularity (C2 decomposition). +""" +from __future__ import annotations + +from typing import TYPE_CHECKING, List + +import pandas as pd + +from simulation.constants import REGIME_OVERRIDES, REGIME_PARAMS + +if TYPE_CHECKING: + from simulation.engine import SimulationEngine + + +def risk_gate_check(engine: SimulationEngine) -> tuple: + """ + Phase 4: 리스크 게이트. 하나라도 실패 시 (False, reason) 반환. + + Phase 3.2 단계적 DD 대응: + DD > 10% → 포지션 사이즈 50% 감축 (dd_sizing_mult=0.5) + DD > 15% → 신규 진입 차단 + DD > 20% → 전 포지션 청산, 시스템 정지 + """ + total_equity = engine._get_total_equity() + daily_pnl_pct = ( + (total_equity - engine._daily_start_equity) / engine._daily_start_equity * 100 + if engine._daily_start_equity > 0 else 0 + ) + + # RG1: 일일 손실 -10% → 매매 중단 + if daily_pnl_pct <= -10.0: + return False, "RG1: 일일 손실 -10% 도달" + + # DD 단계적 대응 (Phase 3.2) + engine._peak_equity = max(engine._peak_equity, total_equity) + mdd = ( + (total_equity - engine._peak_equity) / engine._peak_equity * 100 + if engine._peak_equity > 0 else 0 + ) + + # RG2c: DD > 20% → 전 포지션 청산 + 시스템 정지 + if mdd <= -20.0: + engine._dd_level = 3 + engine._force_liquidate_all("DD>20% 시스템 정지") + return False, "RG2c: DD -20% — 전 포지션 청산, 시스템 정지" + + # RG2b: DD > 15% → 신규 진입 차단 (기존 포지션은 유지) + if mdd <= -15.0: + engine._dd_level = 2 + return False, "RG2b: DD -15% — 신규 진입 차단" + + # RG2a: DD > 10% → 사이징 50% 감축 (진입은 허용) + if mdd <= -10.0: + engine._dd_level = 1 + engine._dd_sizing_mult = 0.5 + else: + engine._dd_level = 0 + engine._dd_sizing_mult = 1.0 + + # RG3: 최대 포지션 수 (regime별) + active_count = len([p for p in engine.positions.values() if p.status == "ACTIVE"]) + regime_params = REGIME_PARAMS.get(engine._market_regime, REGIME_PARAMS["NEUTRAL"]) + if active_count >= regime_params["max_positions"]: + return False, f"RG3: 최대 보유 {regime_params['max_positions']}종목 도달" + + # RG4: 현금 비율 (활성 포지션 수에 따라 동적 적용) + cash_ratio = engine.cash / total_equity if total_equity > 0 else 1.0 + effective_rg4 = engine.min_cash_ratio + if engine._allocator: + ac = sum(1 for p in engine.positions.values() if p.status == "ACTIVE") + if ac <= 2: + effective_rg4 = max(0.50, engine.min_cash_ratio - 0.20) + # RG4b: 레짐별 현금 비율 오버라이드 (BEAR: 50%, CRISIS: 70%) + regime_cash = REGIME_OVERRIDES.get(engine._market_regime, {}).get("min_cash_override") + if regime_cash is not None: + effective_rg4 = max(effective_rg4, regime_cash) + + if cash_ratio < effective_rg4: + return False, f"RG4: 현금 비율 {effective_rg4*100:.0f}% 미만" + + # RG5: VIX > 30 공포 구간 → 신규 진입 차단 + if engine._vix_ema20 > 30: + return False, f"RG5: VIX 공포구간 ({engine._vix_ema20:.1f} > 30)" + + return True, None + + +def detect_bearish_divergence(df: pd.DataFrame, lookback: int = 10) -> bool: + """ + 베어리시 다이버전스 감지: 가격은 고점 갱신인데 RSI는 고점 하락. + True → 진입 차단 (모멘텀 약화 신호). + """ + if len(df) < lookback + 2 or "rsi" not in df.columns: + return False + + recent = df.iloc[-lookback:] + price = recent["close"].astype(float) + rsi = recent["rsi"] + + if rsi.isna().any(): + return False + + price_peaks = [] + rsi_at_peaks = [] + for i in range(1, len(recent) - 1): + if float(price.iloc[i]) > float(price.iloc[i - 1]) and float(price.iloc[i]) > float(price.iloc[i + 1]): + price_peaks.append(float(price.iloc[i])) + rsi_at_peaks.append(float(rsi.iloc[i])) + + if len(price_peaks) >= 2: + if price_peaks[-1] > price_peaks[-2] and rsi_at_peaks[-1] < rsi_at_peaks[-2]: + return True + + return False + + +def detect_support_resistance(df: pd.DataFrame, lookback: int = 40) -> dict: + """최근 N봉의 스윙 포인트를 클러스터링하여 S/R 레벨 반환.""" + if len(df) < lookback: + return {"support": [], "resistance": []} + + recent = df.tail(lookback) + levels: List[tuple] = [] + + # 3-candle 프랙탈 기반 스윙 포인트 + highs = recent["high"].astype(float).values + lows = recent["low"].astype(float).values + for i in range(1, len(recent) - 1): + if highs[i] > highs[i - 1] and highs[i] > highs[i + 1]: + levels.append(("R", float(highs[i]))) + if lows[i] < lows[i - 1] and lows[i] < lows[i + 1]: + levels.append(("S", float(lows[i]))) + + if not levels: + return {"support": [], "resistance": []} + + # 1.5% 이내 레벨 클러스터링 + clustered = cluster_levels(levels, tolerance=0.015) + return { + "support": [l for t, l in clustered if t == "S"], + "resistance": [l for t, l in clustered if t == "R"], + } + + +def cluster_levels(levels: list, tolerance: float = 0.015) -> list: + """가격 레벨을 tolerance % 이내로 클러스터링.""" + if not levels: + return [] + + sorted_levels = sorted(levels, key=lambda x: x[1]) + clusters: List[list] = [[sorted_levels[0]]] + + for item in sorted_levels[1:]: + last_cluster = clusters[-1] + avg_price = sum(l[1] for l in last_cluster) / len(last_cluster) + if abs(item[1] - avg_price) / avg_price <= tolerance: + last_cluster.append(item) + else: + clusters.append([item]) + + result = [] + for cluster in clusters: + avg_price = sum(l[1] for l in cluster) / len(cluster) + s_count = sum(1 for t, _ in cluster if t == "S") + r_count = len(cluster) - s_count + dominant_type = "S" if s_count >= r_count else "R" + result.append((dominant_type, round(avg_price, 2))) + + return result From 7300ab30ac7bfc828955f78264714dcf7c399c68 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=8B=A0=EB=8C=80=EC=8A=B9?= Date: Sun, 15 Mar 2026 17:28:52 +0900 Subject: [PATCH 4/6] refactor: extract regime, risk_gates, indicators, sizing from engine.py MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase B of C2 decomposition — domain logic extraction: - regime.py: market regime detection, index trend analysis, stock regime classification - risk_gates.py: RG1-RG5 checks, bearish divergence, S/R detection - indicators.py: MA/RSI/MACD/BB/ATR/ADX calculations, trend confirmation, stage estimation - sizing.py: VIX-based sizing multiplier All replaced with thin delegating wrappers in engine.py. 58/58 tests pass. Co-Authored-By: Claude Opus 4.6 --- ats/simulation/engine.py | 217 ++--------------------------------- ats/simulation/indicators.py | 205 +++++++++++++++++++++++++++++++++ ats/simulation/sizing.py | 36 ++++++ 3 files changed, 249 insertions(+), 209 deletions(-) create mode 100644 ats/simulation/indicators.py create mode 100644 ats/simulation/sizing.py diff --git a/ats/simulation/engine.py b/ats/simulation/engine.py index 2410ee0..0091294 100644 --- a/ats/simulation/engine.py +++ b/ats/simulation/engine.py @@ -637,91 +637,8 @@ async def _fetch_current_prices(self): # ══════════════════════════════════════════ def _calculate_indicators(self, df: pd.DataFrame) -> pd.DataFrame: - if df.empty or len(df) < self.ma_long: - return df - - # 거래량 0인 행 제거 (휴장일/비정상 데이터 — 지표 왜곡 방지) - if "volume" in df.columns: - df = df[df["volume"] > 0] - if len(df) < self.ma_long: - return df - - c = df["close"].astype(float) - v = df["volume"].astype(float) - h = df["high"].astype(float) - lo = df["low"].astype(float) - df = df.copy() - - # ── 기존 이평선 ── - df["ma_short"] = c.rolling(window=self.ma_short).mean() - df["ma_long"] = c.rolling(window=self.ma_long).mean() - - # ── Phase 1: 정배열용 이평선 ── - df["ma60"] = c.rolling(window=60).mean() - df["ma120"] = c.rolling(window=120).mean() - df["ma200"] = c.rolling(window=200).mean() - - - # ── Phase 1: ADX / DMI (14일) ── - adx_vals, plus_di_vals, minus_di_vals = _compute_adx(h, lo, c, period=14) - df["adx"] = adx_vals - df["plus_di"] = plus_di_vals - df["minus_di"] = minus_di_vals - - # ── Phase 2: 볼린저 밴드 (20, 2σ) ── - bb_ma = c.rolling(window=20).mean() - bb_std = c.rolling(window=20).std() - df["bb_upper"] = bb_ma + bb_std * 2 - df["bb_lower"] = bb_ma - bb_std * 2 - df["bb_width"] = ((df["bb_upper"] - df["bb_lower"]) / bb_ma.replace(0, np.nan)) - df["bb_middle"] = bb_ma - - # ── Phase 3: MACD (12/26/9) ── - ema_fast = c.ewm(span=12, adjust=False).mean() - ema_slow = c.ewm(span=26, adjust=False).mean() - df["macd_line"] = ema_fast - ema_slow - df["macd_signal"] = df["macd_line"].ewm(span=9, adjust=False).mean() - df["macd_hist"] = df["macd_line"] - df["macd_signal"] - - # ── RSI ── - delta = c.diff() - gain = delta.clip(lower=0) - loss = (-delta).clip(lower=0) - avg_gain = gain.rolling(window=self.rsi_period).mean() - avg_loss = loss.rolling(window=self.rsi_period).mean() - rs = avg_gain / avg_loss.replace(0, np.nan) - df["rsi"] = 100 - (100 / (1 + rs)) - - # ── 슬로우 RSI (28일) — 멀티 타임프레임 확인용 ── - delta_slow = c.diff() - gain_slow = delta_slow.clip(lower=0) - loss_slow = (-delta_slow).clip(lower=0) - avg_gain_slow = gain_slow.rolling(window=28).mean() - avg_loss_slow = loss_slow.rolling(window=28).mean() - rs_slow = avg_gain_slow / avg_loss_slow.replace(0, np.nan) - df["rsi_slow"] = 100 - (100 / (1 + rs_slow)) - - # ── 거래량 이동평균 ── - df["volume_ma"] = v.rolling(window=20).mean() - - # ── ATR (14-period) — 동적 트레일링 + 포지션 사이징용 ── - tr1 = h - lo - tr2 = (h - c.shift()).abs() - tr3 = (lo - c.shift()).abs() - tr = pd.concat([tr1, tr2, tr3], axis=1).max(axis=1) - df["atr"] = tr.rolling(window=14).mean() - df["atr_pct"] = df["atr"] / c # 가격 대비 ATR 비율 - - # ── Donchian Channel (20d) — STRONG_BULL Donchian 돌파용 ── - df["donchian_high"] = h.rolling(20, min_periods=20).max() - df["donchian_low"] = lo.rolling(20, min_periods=20).min() - - # ── 이격도 (Disparity) — BULL 부분 청산용 ── - ma20 = c.rolling(window=20).mean() - df["ma20"] = ma20 - df["disparity_20"] = c / ma20.replace(0, np.nan) - - return df + from simulation.indicators import calculate_indicators + return calculate_indicators(self, df) # ══════════════════════════════════════════ # Phase 0: 시장 체제 판단 (TradingLogicFlow.md) @@ -814,33 +731,8 @@ def get_market_intelligence(self) -> Dict: } def _get_vix_sizing_mult(self, strategy: str = "momentum") -> float: - """Phase 4.5: 전략별 VIX 사이징 배율. - - MR은 변동성 높을 때 기회 → VIX 높으면 사이즈 증가. - Defensive는 VIX 높을 때 활성화 → 할인 없음. - 나머지(momentum/smc/brt)는 기존 로직 유지. - """ - vix = self._vix_ema20 - if strategy == "mean_reversion": - if vix > 25: - return 1.2 - elif vix > 20: - return 1.0 - return 0.8 - if strategy == "defensive": - return 1.0 # defensive는 VIX 할인 없음 - if strategy == "volatility": - # volatility premium: VIX 높을수록 기회 크므로 사이즈 증가 - if vix > 30: - return 1.3 - elif vix > 25: - return 1.1 - return 0.9 - # 기존 로직 (momentum/smc/brt) - for (lo, hi), mult in VIX_SIZING_SCALE.items(): - if lo <= vix < hi: - return mult - return 0.3 # VIX 100+ fallback + from simulation.sizing import get_vix_sizing_mult + return get_vix_sizing_mult(self._vix_ema20, strategy) def _reduce_positions_for_regime(self): """레짐 다운그레이드 시 초과 포지션을 PnL 하위부터 ES7 청산.""" @@ -864,109 +756,16 @@ def _reduce_positions_for_regime(self): # ══════════════════════════════════════════ def _confirm_trend(self, df: pd.DataFrame) -> dict: - """ - Phase 1: 종목 수준 추세 방향 + 강도 판정. - Returns: {"direction": "UP"/"DOWN"/"FLAT", - "strength": "STRONG"/"MODERATE"/"WEAK", - "aligned": bool, "adx": float} - """ - default = {"direction": "FLAT", "strength": "WEAK", "aligned": False, "adx": 0} - if len(df) < 200: - return default - - curr = df.iloc[-1] - price = float(curr["close"]) - - # 정배열: 3/5 이상이면 정배열 인정 (기존 5/5 → 완화) - mas = [curr.get("ma_short"), curr.get("ma_long"), curr.get("ma60"), - curr.get("ma120"), curr.get("ma200")] - alignment_score = 0 - if all(pd.notna(m) for m in mas): - ma_vals = [float(m) for m in mas] - if price > ma_vals[0]: - alignment_score += 1 - for i in range(len(ma_vals) - 1): - if ma_vals[i] > ma_vals[i + 1]: - alignment_score += 1 - aligned = alignment_score >= 3 - else: - aligned = False - - # ADX/DMI - adx = float(curr.get("adx", 0)) if pd.notna(curr.get("adx")) else 0 - plus_di = float(curr.get("plus_di", 0)) if pd.notna(curr.get("plus_di")) else 0 - minus_di = float(curr.get("minus_di", 0)) if pd.notna(curr.get("minus_di")) else 0 - trend_exists = adx > 20 # 완화: 발전 중인 추세도 포착 (기존 25) - bullish_di = plus_di > minus_di - - # 종합: MA정렬 또는 ADX+DI 중 하나면 UP (1/2) - bull_count = sum([aligned, trend_exists and bullish_di]) - if bull_count >= 2: - direction = "UP" - elif aligned or (trend_exists and bullish_di): - direction = "UP" - elif not aligned and minus_di > plus_di: - direction = "DOWN" - else: - direction = "FLAT" - - strength = "STRONG" if adx > 40 else "MODERATE" if adx > 25 else "WEAK" - - return {"direction": direction, "strength": strength, "aligned": aligned, - "adx": adx, "alignment_score": alignment_score} + from simulation.indicators import confirm_trend + return confirm_trend(df) # ══════════════════════════════════════════ # Phase 2: 추세 위치 파악 (TradingLogicFlow.md) # ══════════════════════════════════════════ def _estimate_trend_stage(self, df: pd.DataFrame) -> str: - """ - Phase 2: EARLY / MID / LATE 판정. - 볼린저 밴드 스퀴즈 비율 + RSI + 52주 고점 근접도 종합. - """ - if len(df) < 50: - return "MID" - - curr = df.iloc[-1] - bb_width = float(curr.get("bb_width", 0)) if pd.notna(curr.get("bb_width")) else 0 - - # BB 폭 평균 (50일) - if "bb_width" in df.columns: - bb_avg_series = df["bb_width"].rolling(window=50).mean() - bb_width_avg = float(bb_avg_series.iloc[-1]) if pd.notna(bb_avg_series.iloc[-1]) else bb_width - else: - bb_width_avg = bb_width - if bb_width_avg == 0: - bb_width_avg = bb_width or 1.0 - - rsi = float(curr.get("rsi", 50)) if pd.notna(curr.get("rsi")) else 50 - squeeze_ratio = bb_width / bb_width_avg if bb_width_avg > 0 else 1.0 - - # 52주 고점 대비 - close_series = df["close"].astype(float) - window_52w = min(252, len(close_series)) - high_52w = float(close_series.rolling(window=window_52w).max().iloc[-1]) - price = float(curr["close"]) - pct_of_high = (price / high_52w * 100) if high_52w > 0 else 50 - - # 판정 - if squeeze_ratio < 0.8 or (squeeze_ratio < 1.2 and rsi < 65): - return "EARLY" - # LATE 판정: 점수 기반 (기존 OR 조건 → 복합 스코어링) - # 52주 고점 근접만으로 LATE 차단하면 상승 추세 대형주 진입 불가 - late_score = 0 - if squeeze_ratio > 2.0: - late_score += 2 # BB 과확장 (강한 과열 신호) - if rsi > 80: - late_score += 2 # 극단적 과매수 (강한 과열 신호) - if pct_of_high > 95: - late_score += 1 # 52주 고점 근접 (단독으로는 약한 신호) - if pct_of_high > 98: - late_score += 1 # 52주 최고점 근접 (추가 가중) - - if late_score >= 3: - return "LATE" - return "MID" + from simulation.indicators import estimate_trend_stage + return estimate_trend_stage(df) # ══════════════════════════════════════════ # Phase 2.5: 종목별 레짐 분류 (Per-Stock Regime) diff --git a/ats/simulation/indicators.py b/ats/simulation/indicators.py new file mode 100644 index 0000000..8706f3b --- /dev/null +++ b/ats/simulation/indicators.py @@ -0,0 +1,205 @@ +""" +Core technical indicator calculations: MA, RSI, MACD, BB, ATR, ADX. +Extracted from engine.py for modularity (C2 decomposition). +""" +from __future__ import annotations + +from typing import TYPE_CHECKING + +import numpy as np +import pandas as pd + +from simulation.allocator import _compute_adx + +if TYPE_CHECKING: + from simulation.engine import SimulationEngine + + +def calculate_indicators(engine: SimulationEngine, df: pd.DataFrame) -> pd.DataFrame: + """Add all technical indicator columns to OHLCV DataFrame.""" + if df.empty or len(df) < engine.ma_long: + return df + + # 거래량 0인 행 제거 (휴장일/비정상 데이터 — 지표 왜곡 방지) + if "volume" in df.columns: + df = df[df["volume"] > 0] + if len(df) < engine.ma_long: + return df + + c = df["close"].astype(float) + v = df["volume"].astype(float) + h = df["high"].astype(float) + lo = df["low"].astype(float) + df = df.copy() + + # ── 기존 이평선 ── + df["ma_short"] = c.rolling(window=engine.ma_short).mean() + df["ma_long"] = c.rolling(window=engine.ma_long).mean() + + # ── Phase 1: 정배열용 이평선 ── + df["ma60"] = c.rolling(window=60).mean() + df["ma120"] = c.rolling(window=120).mean() + df["ma200"] = c.rolling(window=200).mean() + + # ── Phase 1: ADX / DMI (14일) ── + adx_vals, plus_di_vals, minus_di_vals = _compute_adx(h, lo, c, period=14) + df["adx"] = adx_vals + df["plus_di"] = plus_di_vals + df["minus_di"] = minus_di_vals + + # ── Phase 2: 볼린저 밴드 (20, 2σ) ── + bb_ma = c.rolling(window=20).mean() + bb_std = c.rolling(window=20).std() + df["bb_upper"] = bb_ma + bb_std * 2 + df["bb_lower"] = bb_ma - bb_std * 2 + df["bb_width"] = ((df["bb_upper"] - df["bb_lower"]) / bb_ma.replace(0, np.nan)) + df["bb_middle"] = bb_ma + + # ── Phase 3: MACD (12/26/9) ── + ema_fast = c.ewm(span=12, adjust=False).mean() + ema_slow = c.ewm(span=26, adjust=False).mean() + df["macd_line"] = ema_fast - ema_slow + df["macd_signal"] = df["macd_line"].ewm(span=9, adjust=False).mean() + df["macd_hist"] = df["macd_line"] - df["macd_signal"] + + # ── RSI ── + delta = c.diff() + gain = delta.clip(lower=0) + loss = (-delta).clip(lower=0) + avg_gain = gain.rolling(window=engine.rsi_period).mean() + avg_loss = loss.rolling(window=engine.rsi_period).mean() + rs = avg_gain / avg_loss.replace(0, np.nan) + df["rsi"] = 100 - (100 / (1 + rs)) + + # ── 슬로우 RSI (28일) — 멀티 타임프레임 확인용 ── + delta_slow = c.diff() + gain_slow = delta_slow.clip(lower=0) + loss_slow = (-delta_slow).clip(lower=0) + avg_gain_slow = gain_slow.rolling(window=28).mean() + avg_loss_slow = loss_slow.rolling(window=28).mean() + rs_slow = avg_gain_slow / avg_loss_slow.replace(0, np.nan) + df["rsi_slow"] = 100 - (100 / (1 + rs_slow)) + + # ── 거래량 이동평균 ── + df["volume_ma"] = v.rolling(window=20).mean() + + # ── ATR (14-period) — 동적 트레일링 + 포지션 사이징용 ── + tr1 = h - lo + tr2 = (h - c.shift()).abs() + tr3 = (lo - c.shift()).abs() + tr = pd.concat([tr1, tr2, tr3], axis=1).max(axis=1) + df["atr"] = tr.rolling(window=14).mean() + df["atr_pct"] = df["atr"] / c # 가격 대비 ATR 비율 + + # ── Donchian Channel (20d) — STRONG_BULL Donchian 돌파용 ── + df["donchian_high"] = h.rolling(20, min_periods=20).max() + df["donchian_low"] = lo.rolling(20, min_periods=20).min() + + # ── 이격도 (Disparity) — BULL 부분 청산용 ── + ma20 = c.rolling(window=20).mean() + df["ma20"] = ma20 + df["disparity_20"] = c / ma20.replace(0, np.nan) + + return df + + +def confirm_trend(df: pd.DataFrame) -> dict: + """ + Phase 1: 종목 수준 추세 방향 + 강도 판정. + Returns: {"direction": "UP"/"DOWN"/"FLAT", + "strength": "STRONG"/"MODERATE"/"WEAK", + "aligned": bool, "adx": float, "alignment_score": int} + """ + default = {"direction": "FLAT", "strength": "WEAK", "aligned": False, "adx": 0} + if len(df) < 200: + return default + + curr = df.iloc[-1] + price = float(curr["close"]) + + # 정배열: 3/5 이상이면 정배열 인정 + mas = [curr.get("ma_short"), curr.get("ma_long"), curr.get("ma60"), + curr.get("ma120"), curr.get("ma200")] + alignment_score = 0 + if all(pd.notna(m) for m in mas): + ma_vals = [float(m) for m in mas] + if price > ma_vals[0]: + alignment_score += 1 + for i in range(len(ma_vals) - 1): + if ma_vals[i] > ma_vals[i + 1]: + alignment_score += 1 + aligned = alignment_score >= 3 + else: + aligned = False + + # ADX/DMI + adx = float(curr.get("adx", 0)) if pd.notna(curr.get("adx")) else 0 + plus_di = float(curr.get("plus_di", 0)) if pd.notna(curr.get("plus_di")) else 0 + minus_di = float(curr.get("minus_di", 0)) if pd.notna(curr.get("minus_di")) else 0 + trend_exists = adx > 20 + bullish_di = plus_di > minus_di + + # 종합 + bull_count = sum([aligned, trend_exists and bullish_di]) + if bull_count >= 2: + direction = "UP" + elif aligned or (trend_exists and bullish_di): + direction = "UP" + elif not aligned and minus_di > plus_di: + direction = "DOWN" + else: + direction = "FLAT" + + strength = "STRONG" if adx > 40 else "MODERATE" if adx > 25 else "WEAK" + + return {"direction": direction, "strength": strength, "aligned": aligned, + "adx": adx, "alignment_score": alignment_score} + + +def estimate_trend_stage(df: pd.DataFrame) -> str: + """ + Phase 2: EARLY / MID / LATE 판정. + 볼린저 밴드 스퀴즈 비율 + RSI + 52주 고점 근접도 종합. + """ + if len(df) < 50: + return "MID" + + curr = df.iloc[-1] + bb_width = float(curr.get("bb_width", 0)) if pd.notna(curr.get("bb_width")) else 0 + + # BB 폭 평균 (50일) + if "bb_width" in df.columns: + bb_avg_series = df["bb_width"].rolling(window=50).mean() + bb_width_avg = float(bb_avg_series.iloc[-1]) if pd.notna(bb_avg_series.iloc[-1]) else bb_width + else: + bb_width_avg = bb_width + if bb_width_avg == 0: + bb_width_avg = bb_width or 1.0 + + rsi = float(curr.get("rsi", 50)) if pd.notna(curr.get("rsi")) else 50 + squeeze_ratio = bb_width / bb_width_avg if bb_width_avg > 0 else 1.0 + + # 52주 고점 대비 + close_series = df["close"].astype(float) + window_52w = min(252, len(close_series)) + high_52w = float(close_series.rolling(window=window_52w).max().iloc[-1]) + price = float(curr["close"]) + pct_of_high = (price / high_52w * 100) if high_52w > 0 else 50 + + # 판정 + if squeeze_ratio < 0.8 or (squeeze_ratio < 1.2 and rsi < 65): + return "EARLY" + # LATE 판정: 점수 기반 + late_score = 0 + if squeeze_ratio > 2.0: + late_score += 2 + if rsi > 80: + late_score += 2 + if pct_of_high > 95: + late_score += 1 + if pct_of_high > 98: + late_score += 1 + + if late_score >= 3: + return "LATE" + return "MID" diff --git a/ats/simulation/sizing.py b/ats/simulation/sizing.py new file mode 100644 index 0000000..45ee2f2 --- /dev/null +++ b/ats/simulation/sizing.py @@ -0,0 +1,36 @@ +""" +Position sizing: VIX-based sizing multiplier and related pure functions. +Extracted from engine.py for modularity (C2 decomposition). +""" +from __future__ import annotations + +from simulation.constants import VIX_SIZING_SCALE + + +def get_vix_sizing_mult(vix_ema20: float, strategy: str = "momentum") -> float: + """Phase 4.5: 전략별 VIX 사이징 배율. + + MR은 변동성 높을 때 기회 → VIX 높으면 사이즈 증가. + Defensive는 VIX 높을 때 활성화 → 할인 없음. + 나머지(momentum/smc/brt)는 기존 로직 유지. + """ + if strategy == "mean_reversion": + if vix_ema20 > 25: + return 1.2 + elif vix_ema20 > 20: + return 1.0 + return 0.8 + if strategy == "defensive": + return 1.0 # defensive는 VIX 할인 없음 + if strategy == "volatility": + # volatility premium: VIX 높을수록 기회 크므로 사이즈 증가 + if vix_ema20 > 30: + return 1.3 + elif vix_ema20 > 25: + return 1.1 + return 0.9 + # 기존 로직 (momentum/smc/brt) + for (lo, hi), mult in VIX_SIZING_SCALE.items(): + if lo <= vix_ema20 < hi: + return mult + return 0.3 # VIX 100+ fallback From 90fcb5ed6d5ff75ca6d1474654cc8b4b6c79084d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=8B=A0=EB=8C=80=EC=8A=B9?= Date: Sun, 15 Mar 2026 18:00:25 +0900 Subject: [PATCH 5/6] refactor: extract all 7 strategy modules from engine.py MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase C of C2 decomposition — strategy extraction: - strategies/momentum.py: 6-Phase pipeline scan + 7-tier exit cascade - strategies/smc.py: SMC 4-Layer scoring + CHoCH exits - strategies/mean_reversion.py: MR indicators, scoring, scan + exits - strategies/breakout_retest.py: BRT 4-Layer + retest zone scoring - strategies/arbitrage.py: pair discovery, basis gate, spread scoring - strategies/defensive.py: safe-haven/inverse ETF rotation - strategies/volatility.py: VIX premium capture Engine.py reduced from 5208 to 2159 lines. 58/58 tests pass. Co-Authored-By: Claude Opus 4.6 --- ats/simulation/engine.py | 3305 +----------------- ats/simulation/strategies/__init__.py | 1 + ats/simulation/strategies/arbitrage.py | 1041 ++++++ ats/simulation/strategies/breakout_retest.py | 704 ++++ ats/simulation/strategies/defensive.py | 158 + ats/simulation/strategies/mean_reversion.py | 499 +++ ats/simulation/strategies/momentum.py | 483 +++ ats/simulation/strategies/smc.py | 403 +++ ats/simulation/strategies/volatility.py | 154 + 9 files changed, 3571 insertions(+), 3177 deletions(-) create mode 100644 ats/simulation/strategies/__init__.py create mode 100644 ats/simulation/strategies/arbitrage.py create mode 100644 ats/simulation/strategies/breakout_retest.py create mode 100644 ats/simulation/strategies/defensive.py create mode 100644 ats/simulation/strategies/mean_reversion.py create mode 100644 ats/simulation/strategies/momentum.py create mode 100644 ats/simulation/strategies/smc.py create mode 100644 ats/simulation/strategies/volatility.py diff --git a/ats/simulation/engine.py b/ats/simulation/engine.py index 0091294..51aeb54 100644 --- a/ats/simulation/engine.py +++ b/ats/simulation/engine.py @@ -1031,3053 +1031,189 @@ def _scan_entries_multi(self): # ── Phase 3.3: Defensive 전략 (인버스 ETF) ── def _scan_entries_defensive(self): - """ - Defensive 전략: BEAR/RANGE_BOUND 레짐 시 인버스 ETF + 안전자산 매수. - - 조건: - - 레짐이 BEAR/CRISIS 또는 (RANGE_BOUND/NEUTRAL AND VIX > threshold) - - 인버스 ETF / 안전자산 OHLCV 데이터 존재 - - 이미 보유 중이 아닌 것 - """ - # 레짐별 VIX 진입 임계값 (BEAR: 20, 기본: 25) - _def_ro = REGIME_OVERRIDES.get(self._market_regime, {}) - vix_threshold = _def_ro.get("defensive_vix_threshold", 25) - - # STRONG_BULL/BULL에서는 진입하지 않음 - if self._market_regime in ("STRONG_BULL", "BULL"): - return - if self._market_regime == "NEUTRAL" and self._vix_ema20 < vix_threshold: - return - - # 마켓에 맞는 인버스 ETF 목록 - market_key = "sp500" # 기본값 - if "kospi" in self.market_id.lower(): - market_key = "kospi" - elif "nasdaq" in self.market_id.lower(): - market_key = "nasdaq" - - # 인버스 ETF + CRISIS 안전자산 합산 - defensive_tickers = list(INVERSE_ETFS.get(market_key, [])) - if _def_ro.get("safe_haven_enabled"): - for item in SAFE_HAVEN_ETFS.get(market_key, []): - ticker = item["ticker"] - if ticker not in defensive_tickers: - defensive_tickers.append(ticker) - - if not defensive_tickers: - return - - for ticker in defensive_tickers: - # 이미 보유 중이면 스킵 - if ticker in self.positions and self.positions[ticker].status == "ACTIVE": - continue - - df = self._ohlcv_cache.get(ticker) - if df is None or len(df) < 20: - continue - - price = float(df.iloc[-1]["close"]) - if price <= 0: - continue - - # 시그널 생성 (고정 strength — defensive는 레짐 기반) - strength = 70 if self._market_regime in ("BEAR", "CRISIS") else 50 - if self._vix_ema20 > 30: - strength += 10 - # CRISIS 안전자산은 추가 강도 - is_safe_haven = any( - item["ticker"] == ticker - for item in SAFE_HAVEN_ETFS.get(market_key, []) - ) - if is_safe_haven and self._market_regime == "CRISIS": - strength += 15 - - ticker_name = f"SafeHaven_{ticker}" if is_safe_haven else f"Inv_{ticker}" - - self._signal_counter += 1 - signal = SimSignal( - id=f"sim-sig-{self._signal_counter:04d}", - stock_code=ticker, - stock_name=ticker_name, - type="BUY", - price=price, - strength=strength, - reason=f"Defensive: {self._market_regime} regime, VIX={self._vix_ema20:.1f}", - detected_at=self._get_current_iso(), - ) - self._execute_buy( - signal, - trend_strength="MODERATE", - trend_stage="MID", - alignment_score=3, - ) - - def _check_exits_defensive(self): - """ - Defensive 전략 청산: 레짐이 BULL로 전환되면 청산. - 또는 일반 ES1 손절(-5%) 적용. - """ - to_close: List[str] = [] - - for code, pos in self.positions.items(): - if pos.status != "ACTIVE": - continue - if self._exit_tag_filter and pos.strategy_tag != self._exit_tag_filter: - continue - - current_price = self._current_prices.get(code, pos.current_price) - entry_price = pos.entry_price - pnl_pct = (current_price - entry_price) / entry_price - - exit_reason = None - exit_type = None - - # ES1: 하드 손절 -5% - if pnl_pct <= -0.05: - exit_reason = "ES1: 손절 -5%" - exit_type = "STOP_LOSS" - - # 레짐이 BULL로 전환 → 인버스 청산 - elif self._market_regime == "BULL": - exit_reason = "DEF_REGIME: BULL 전환 청산" - exit_type = "REGIME_EXIT" - - # 익절: +10% (인버스는 보수적 TP) - elif pnl_pct >= 0.10: - exit_reason = "DEF_TP: 익절 +10%" - exit_type = "TAKE_PROFIT" - - # 트레일링: +5% 이상이면 2×ATR 트레일링 - elif pnl_pct >= 0.05: - df = self._ohlcv_cache.get(code) - if df is not None and "atr" in df.columns and len(df) > 0: - atr_val = float(df.iloc[-1].get("atr", 0)) - if atr_val > 0: - trail_stop = pos.highest_price - 2.0 * atr_val - if current_price <= trail_stop: - exit_reason = f"DEF_TRAIL: 트레일링 (ATR×2.0)" - exit_type = "TRAILING_STOP" - - if exit_reason: - to_close.append(code) - self._close_position(code, current_price, exit_reason, exit_type) - - for code in to_close: - if code in self.positions: - del self.positions[code] - - # ───────────────────────────────────────────────────── - # Phase 6: Volatility Premium Strategy - # ───────────────────────────────────────────────────── - - def _scan_entries_volatility(self): - """ - VIX Mean Reversion: VIX 급등 후 하락 반전 시 SPY/QQQ 매수. - 변동성 프리미엄 수확 — VIX가 평균 회귀할 때 주가 반등 포착. - - 진입 조건: - - VIX EMA20 > 22 (변동성 상승 확인) - - VIX 3일 연속 하락 (하락 반전) - - RSI(VIX 대리: 시장 RSI < 45) — 시장이 아직 과매도 영역 - - 청산: - - VIX EMA20 < 18 (정상화 완료) OR 20일 보유 OR -5% SL - """ - # VIX 데이터 필요 - if self._vix_ema20 is None or self._vix_ema20 <= 0: - return - - # 진입 조건: VIX 높고 하락 중 - if self._vix_ema20 < 22: - return - - # VIX 3일 연속 하락 체크 (VIX 히스토리 필요) - vix_history = getattr(self, '_vix_history', []) - if len(vix_history) < 4: - return - - vix_declining = all( - vix_history[-i] < vix_history[-i-1] - for i in range(1, 4) - ) - if not vix_declining: - return - - # 리스크 게이트 - can_trade, _ = self._risk_gate_check() - if not can_trade: - return - - # 타겟: 대형 ETF 또는 시장 대표 종목 (워치리스트에서 유동성 높은 종목) - vol_targets = ["SPY", "QQQ", "AAPL", "MSFT", "AMZN", "GOOGL", "META", "NVDA"] - for code in vol_targets: - if code in self.positions and self.positions[code].status == "ACTIVE": - continue - - df = self._ohlcv_cache.get(code) - if df is None or len(df) < 20: - continue - - df = self._calculate_indicators(df.copy()) - if df.empty: - continue - self._ohlcv_cache[code] = df - - curr = df.iloc[-1] - price = self._current_prices.get(code, float(curr["close"])) - - # 시장 RSI < 50 (아직 반등 여지 있음) - rsi = float(curr.get("rsi", 50)) if pd.notna(curr.get("rsi")) else 50 - if rsi > 50: - continue - - # 시그널 강도: VIX 높을수록 + RSI 낮을수록 강함 - strength = min(int(30 + (self._vix_ema20 - 22) * 5 + (50 - rsi)), 100) - - self._signal_counter += 1 - signal = SimSignal( - id=f"sim-sig-{self._signal_counter:04d}", - stock_code=code, - stock_name=self._stock_names.get(code, code), - type="BUY", - price=price, - reason=f"VOL_PREMIUM VIX={self._vix_ema20:.1f} RSI={rsi:.0f}", - strength=strength, - detected_at=self._get_current_iso(), - ) - - if self._collect_mode: - self._collected_signals.append((self.strategy_mode, signal, "MODERATE", "MID", 3)) - else: - self._execute_buy(signal, "MODERATE", "MID", 3) - - def _check_exits_volatility(self): - """Volatility Premium 청산: VIX 정상화 OR 20일 보유 OR -5% SL.""" - to_close: List[str] = [] - - for code, pos in self.positions.items(): - if pos.status != "ACTIVE" or pos.strategy_tag != "volatility": - continue - - current_price = self._current_prices.get(code, pos.current_price) - entry_price = pos.entry_price - pnl_pct = (current_price - entry_price) / entry_price - - exit_reason = None - exit_type = None - - # ES1: -5% 손절 - if pnl_pct <= -0.05: - exit_reason = "ES_VOL SL -5%" - exit_type = "STOP_LOSS" - - # VIX 정상화 청산 (VIX < 18) - elif self._vix_ema20 is not None and self._vix_ema20 < 18: - exit_reason = f"ES_VOL VIX 정상화 ({self._vix_ema20:.1f})" - exit_type = "VOLATILITY_TP" - - # 익절: +8% - elif pnl_pct >= 0.08: - exit_reason = "ES_VOL TP +8%" - exit_type = "TAKE_PROFIT" - - # 20일 보유 초과 - elif pos.days_held > 20: - exit_reason = "ES_VOL 보유기간 20일 초과" - exit_type = "MAX_HOLDING" - - # 트레일링: +4%에서 활성 - elif pnl_pct >= 0.04: - df = self._ohlcv_cache.get(code) - if df is not None and "atr" in df.columns: - atr_val = float(df.iloc[-1].get("atr", 0)) if pd.notna(df.iloc[-1].get("atr")) else 0 - if atr_val > 0: - trail_stop = pos.highest_price - 2.0 * atr_val - if current_price <= trail_stop: - exit_reason = "ES_VOL 트레일링" - exit_type = "TRAILING_STOP" - - if exit_reason: - to_close.append(code) - self._execute_sell(pos, current_price, exit_reason, exit_type or "") - - for code in to_close: - if code in self.positions: - del self.positions[code] - - def _scan_entries_momentum(self): - """ - 6-Phase 통합 파이프라인 (기존 Momentum Swing): - Phase 0 (시장 체제) → Phase 4 (리스크 게이트) → 종목별 Phase 1→2→3 - """ - # ── Phase 0: 시장 체제 판단 ── - self._update_market_regime() - # BEAR 체제: 제한적 거래 허용 (max_positions=2, max_weight=5%) - # 하드블록 대신 REGIME_PARAMS가 RG3에서 포지션 수 제한 - - # ── Phase 4: 리스크 게이트 (사전 체크) ── - can_trade, block_reason = self._risk_gate_check() - if not can_trade: - self._phase_stats["phase4_risk_blocks"] += 1 - self._add_risk_event("WARNING", f"진입 차단: {block_reason}") - return - - total_equity = self._get_total_equity() - regime_params = REGIME_PARAMS.get(self._market_regime, REGIME_PARAMS["NEUTRAL"]) - - active_count = len([p for p in self.positions.values() if p.status == "ACTIVE"]) - - if active_count >= regime_params["max_positions"]: - return - - new_signals: List[tuple] = [] # (signal, trend_strength, trend_stage) - - for w in self._watchlist: - code = w["code"] - - # STRONG_BULL 피라미딩: 기존 보유 종목도 조건부 추가 매수 허용 - is_pyramid = False - _pyr_ro = REGIME_OVERRIDES.get(self._market_regime, {}) - if code in self.positions and self.positions[code].status in ("ACTIVE", "PENDING"): - pos_existing = self.positions[code] - if (_pyr_ro.get("pyramiding_enabled") - and pos_existing.strategy_tag == "momentum" - and pos_existing.scale_count < _pyr_ro.get("pyramiding_max", 1) - and pos_existing.days_held >= 5): - cur_px = self._current_prices.get(code, pos_existing.current_price) - eff_entry = pos_existing.avg_entry_price if pos_existing.avg_entry_price > 0 else pos_existing.entry_price - pnl_ratio = (cur_px - eff_entry) / eff_entry if eff_entry > 0 else 0 - if pnl_ratio >= _pyr_ro.get("pyramiding_pnl_min", 0.05): - is_pyramid = True # 피라미딩 조건 충족, fall through - else: - continue - else: - continue - - df = self._ohlcv_cache.get(code) - if df is None or len(df) < self.ma_long + 5: - continue - - df = self._calculate_indicators(df.copy()) - if df.empty or len(df) < 2: - continue - - self._phase_stats["total_scans"] += 1 - - # ── Phase 1: 추세 확인 ── - trend = self._confirm_trend(df) - if trend["direction"] != "UP": - self._phase_stats["phase1_trend_rejects"] += 1 - if code in self._debug_tickers: - print( - f"[DIAG] {code} Phase1 REJECT: direction={trend['direction']} " - f"adx={trend['adx']:.1f} aligned={trend['aligned']}" - ) - continue # FLAT/DOWN 종목 스킵 - - # ── Phase 2: 추세 위치 파악 ── - stage = self._estimate_trend_stage(df) - if stage == "LATE": - self._phase_stats["phase2_late_rejects"] += 1 - if code in self._debug_tickers: - _curr = df.iloc[-1] - _rsi = float(_curr.get("rsi", 0)) if pd.notna(_curr.get("rsi")) else 0 - _close = float(_curr["close"]) - _52w_h = float(df["close"].astype(float).rolling(min(252, len(df))).max().iloc[-1]) - _pct_h = (_close / _52w_h * 100) if _52w_h > 0 else 0 - print( - f"[DIAG] {code} Phase2 LATE REJECT: rsi={_rsi:.1f} " - f"pct_of_52w_high={_pct_h:.1f}%" - ) - continue # 말기 종목 진입 스킵 - - # ── Phase 3: 진입 시그널 ── - curr = df.iloc[-1] - prev = df.iloc[-2] - - primary = [] - confirmations = [] - - # PS1: 골든크로스 - if ( - pd.notna(curr["ma_short"]) - and pd.notna(curr["ma_long"]) - and pd.notna(prev["ma_short"]) - and pd.notna(prev["ma_long"]) - ): - if prev["ma_short"] <= prev["ma_long"] and curr["ma_short"] > curr["ma_long"]: - primary.append("PS1") - - # PS2: MACD 골든크로스 + 기울기 필터 - if pd.notna(curr.get("macd_hist")) and pd.notna(prev.get("macd_hist")): - if prev["macd_hist"] <= 0 and curr["macd_hist"] > 0: - # 3봉 기울기 양수 확인 (감속 크로스 필터링) - if len(df) >= 4: - hist_3ago = float(df.iloc[-3].get("macd_hist", 0)) if pd.notna(df.iloc[-3].get("macd_hist")) else 0 - slope = float(curr["macd_hist"]) - hist_3ago - if slope > 0: - primary.append("PS2") - else: - primary.append("PS2") - - # PS3: MA 풀백 진입 (추세 지속 시그널) - # 확립된 상승 추세에서 MA20 지지 확인 후 반등 → 대형 주도주 포착 - if not primary: - if ( - pd.notna(curr["ma_short"]) - and pd.notna(curr["ma_long"]) - and pd.notna(curr.get("ma60")) - and len(df) >= 5 - ): - ma_short_val = float(curr["ma_short"]) - ma_long_val = float(curr["ma_long"]) - ma60_val = float(curr["ma60"]) - price = float(curr["close"]) - prev_price = float(prev["close"]) - - # 조건1: 확립된 상승 정배열 (MA5 > MA20 > MA60) - uptrend = (ma_short_val > ma_long_val > ma60_val) - - if uptrend: - # 조건2: 최근 3봉 내 MA20 근처까지 풀백 (2% 이내 접근) - recent_lows = df["low"].astype(float).iloc[-4:-1] - pullback_zone = ma_long_val * 1.02 - ma20_proximity = any( - low <= pullback_zone for low in recent_lows - ) - - # 조건3: 현재 종가 > MA20 (지지 확인 후 반등) - above_ma20 = price > ma_long_val - - # 조건4: 상승 봉 (현재 종가 > 전일 종가) - bounce_confirm = price > prev_price - - if ma20_proximity and above_ma20 and bounce_confirm: - primary.append("PS3") - self._phase_stats["phase3_ps3_pullback"] += 1 - - # PS4: Donchian Channel 돌파 (STRONG_BULL 전용 — 독립 시그널) - _mom_ro = REGIME_OVERRIDES.get(self._market_regime, {}) - ps4_donchian = False - if _mom_ro.get("donchian_entry") and "donchian_high" in df.columns: - prev_donchian = prev.get("donchian_high") if pd.notna(prev.get("donchian_high")) else None - if prev_donchian is not None and float(curr["close"]) > float(prev_donchian): - ps4_donchian = True - primary.append("PS4") - self._phase_stats.setdefault("phase3_ps4_donchian", 0) - self._phase_stats["phase3_ps4_donchian"] += 1 - - if not primary: - self._phase_stats["phase3_no_primary"] += 1 - if code in self._debug_tickers: - _rsi = float(curr.get("rsi", 0)) if pd.notna(curr.get("rsi")) else 0 - _ma_s = float(curr.get("ma_short", 0)) if pd.notna(curr.get("ma_short")) else 0 - _ma_l = float(curr.get("ma_long", 0)) if pd.notna(curr.get("ma_long")) else 0 - _ma60 = float(curr.get("ma60", 0)) if pd.notna(curr.get("ma60")) else 0 - _macd_h = float(curr.get("macd_hist", 0)) if pd.notna(curr.get("macd_hist")) else 0 - print( - f"[DIAG] {code} Phase3 NO PRIMARY: ma5={_ma_s:.0f} ma20={_ma_l:.0f} " - f"ma60={_ma60:.0f} ma5>ma20={_ma_s > _ma_l} rsi={_rsi:.1f} " - f"macd_hist={_macd_h:.4f}" - ) - continue - - # CF1: RSI 적정 범위 (52-78) - if pd.notna(curr["rsi"]) and self.rsi_lower <= curr["rsi"] <= self.rsi_upper: - confirmations.append("CF1") - - # CF2: 거래량 돌파 - if pd.notna(curr["volume_ma"]) and curr["volume_ma"] > 0: - if float(curr["volume"]) >= curr["volume_ma"] * self.volume_multiplier: - confirmations.append("CF2") - - # CF3: 슬로우 RSI 멀티 타임프레임 확인 (28일) - if pd.notna(curr.get("rsi_slow")) and 45 <= float(curr["rsi_slow"]) <= 70: - confirmations.append("CF3") - - # PS3 전용: 추세 지속 진입은 완화된 확인 임계값 사용 - # 입증된 상승 추세이므로 RSI/거래량 기준을 낮춰도 안전 - if "PS3" in primary and not confirmations: - # CF1_R: RSI 42-82 (기존 52-78 → 완화) - if pd.notna(curr["rsi"]) and 42 <= float(curr["rsi"]) <= 82: - confirmations.append("CF1_R") - - # CF2_R: 거래량 >= MA20 × 1.0 (기존 1.5 → 완화, 대형주 안정 거래량 반영) - if pd.notna(curr["volume_ma"]) and curr["volume_ma"] > 0: - if float(curr["volume"]) >= curr["volume_ma"] * 1.0: - confirmations.append("CF2_R") - - if not confirmations: - self._phase_stats["phase3_no_confirm"] += 1 - if code in self._debug_tickers: - _rsi = float(curr.get("rsi", 0)) if pd.notna(curr.get("rsi")) else 0 - _vol = float(curr["volume"]) - _vol_ma = float(curr["volume_ma"]) if pd.notna(curr["volume_ma"]) else 1 - print( - f"[DIAG] {code} Phase3 NO CONFIRM: primary={primary} " - f"rsi={_rsi:.1f} vol_ratio={_vol / _vol_ma:.2f}" - ) - continue - - # 베어리시 다이버전스 필터 (가격↑ RSI↓ → 모멘텀 약화) - if self._detect_bearish_divergence(df): - self._phase_stats["divergence_blocks"] += 1 - continue - - - - # 시그널 강도 계산 (연속 스코어링) - adx = trend.get("adx", 0) - rsi = float(curr.get("rsi", 50)) if pd.notna(curr.get("rsi")) else 50 - vol_ratio = float(curr["volume"]) / float(curr["volume_ma"]) if pd.notna(curr["volume_ma"]) and float(curr["volume_ma"]) > 0 else 1.0 - - # PS3는 추세 지속 시그널이므로 개시 시그널(PS1/PS2) 대비 낮은 강도 - ps3_penalty = -10 if "PS3" in primary else 0 - ps4_bonus = 20 if ps4_donchian else 0 # Donchian 돌파 시 +20 - base_strength = len(primary) * 25 + len(confirmations) * 15 + ps3_penalty + ps4_bonus - trend_bonus = min(int(adx * 0.5), 25) # ADX 연속값 → 최대 25점 - stage_bonus = 15 if stage == "EARLY" else 8 if stage == "MID" else 0 - rsi_quality = max(0, int(10 - abs(rsi - 55) * 0.5)) # RSI 55 이상대 - volume_bonus = min(int((vol_ratio - 1.5) * 10), 10) if vol_ratio > 1.5 else 0 - strength = min(max(base_strength + trend_bonus + stage_bonus + rsi_quality + volume_bonus, 10), 100) - - current_price = self._current_prices.get(code, float(curr["close"])) - - self._signal_counter += 1 - signal = SimSignal( - id=f"sim-sig-{self._signal_counter:04d}", - stock_code=code, - stock_name=w["name"], - type="BUY", - price=current_price, - reason=f"{'PYR_' if is_pyramid else ''}{'+'.join(primary)} {'+'.join(confirmations)} [trend={trend['strength']}, stage={stage}]", - strength=min(strength, 100), - detected_at=self._get_current_iso(), - ) - new_signals.append((signal, trend["strength"], stage, trend.get("alignment_score", 3))) - - if code in self._debug_tickers: - print( - f"[DIAG] {code} ✅ SIGNAL: primary={primary} confirm={confirmations} " - f"strength={strength} stage={stage} price={current_price:.0f}" - ) - - # 시그널 강도순 정렬 후 매수 실행 - new_signals.sort(key=lambda x: x[0].strength, reverse=True) - - for sig, trend_strength, trend_stage, align_score in new_signals: - if active_count >= regime_params["max_positions"]: - break - self.signals.append(sig) - if len(self.signals) > 100: - self.signals = self.signals[-100:] - - self._execute_buy(sig, trend_strength=trend_strength, - trend_stage=trend_stage, alignment_score=align_score) - self._phase_stats["entries_executed"] += 1 - active_count += 1 - - # ══════════════════════════════════════════ - # SMC 4-Layer 진입 스캔 - # ══════════════════════════════════════════ - - def _scan_entries_smc(self): - """ - SMC 4-Layer 스코어링 기반 진입 스캔. - Phase 0 (시장 체제) + Phase 4 (리스크 게이트) → SMC 스코어링 → 매수 실행. - """ - # ── Phase 0: 시장 체제 판단 ── - self._update_market_regime() - - # ── Phase 4: 리스크 게이트 (사전 체크) ── - can_trade, block_reason = self._risk_gate_check() - if not can_trade: - self._phase_stats["phase4_risk_blocks"] += 1 - self._add_risk_event("WARNING", f"SMC 진입 차단: {block_reason}") - return - - total_equity = self._get_total_equity() - regime_params = REGIME_PARAMS.get(self._market_regime, REGIME_PARAMS["NEUTRAL"]) - - active_count = len([p for p in self.positions.values() if p.status == "ACTIVE"]) - - if active_count >= regime_params["max_positions"]: - return - - new_signals: List[tuple] = [] - - for w in self._watchlist: - code = w["code"] - - - if code in self.positions and self.positions[code].status in ("ACTIVE", "PENDING"): - continue - - df = self._ohlcv_cache.get(code) - if df is None or len(df) < 50: - continue - - df = self._calculate_indicators_smc(df.copy()) - if df.empty or len(df) < 2: - continue - - self._phase_stats["total_scans"] += 1 - - curr = df.iloc[-1] - - # ── SMC 4-Layer 스코어링 ── - score_smc = self._score_smc_bias(df) - score_vol = self._score_volatility(df) - score_obv = self._score_obv_signal(df) - score_mom = self._score_momentum_signal(df) - total_score = score_smc + score_vol + score_obv + score_mom - - if total_score < self._smc_entry_threshold: - self._phase_stats["phase3_no_primary"] += 1 - continue - - current_price = self._current_prices.get(code, float(curr["close"])) - - self._signal_counter += 1 - signal = SimSignal( - id=f"sim-sig-{self._signal_counter:04d}", - stock_code=code, - stock_name=w["name"], - type="BUY", - price=current_price, - reason=f"SMC_{total_score} [L1:{score_smc} L2:{score_vol} L3a:{score_obv} L3b:{score_mom}]", - strength=min(total_score, 100), - detected_at=self._get_current_iso(), - ) - new_signals.append((signal, "MODERATE", "MID", 3)) - - # SMC 통계 - self._phase_stats["smc_total_score"] += total_score - self._phase_stats["smc_entries"] += 1 - - # 시그널 강도순 정렬 후 매수 실행 - new_signals.sort(key=lambda x: x[0].strength, reverse=True) - - for sig, trend_strength, trend_stage, align_score in new_signals: - if active_count >= regime_params["max_positions"]: - break - self.signals.append(sig) - if len(self.signals) > 100: - self.signals = self.signals[-100:] - - self._execute_buy(sig, trend_strength=trend_strength, - trend_stage=trend_stage, alignment_score=align_score) - self._phase_stats["entries_executed"] += 1 - active_count += 1 - - def _calculate_indicators_smc(self, df: pd.DataFrame) -> pd.DataFrame: - """기존 지표 + SMC + OBV 통합 계산.""" - df = self._calculate_indicators(df) - if df.empty: - return df - - # SMC: Swing Points, BOS/CHoCH, Order Blocks, FVG - from analytics.indicators import calculate_smc - df = calculate_smc(df, swing_length=self._smc_cfg.swing_length) - - # OBV (On Balance Volume) - c = df["close"].astype(float) - v = df["volume"].astype(float) - df["obv"] = (np.sign(c.diff()).fillna(0) * v).cumsum() - df["obv_ema5"] = df["obv"].ewm(span=5, adjust=False).mean() - df["obv_ema20"] = df["obv"].ewm(span=20, adjust=False).mean() - - return df - - def _score_smc_bias(self, df: pd.DataFrame) -> int: - """Layer 1: SMC Bias 스코어 (0~40).""" - if len(df) < 10: - return 0 - - score = 0 - curr = df.iloc[-1] - price = float(curr["close"]) - - lookback = min(20, len(df)) - recent = df.iloc[-lookback:] - markers = recent[recent["marker"].notna()] - - if not markers.empty: - last_marker = markers.iloc[-1]["marker"] - if last_marker == "BOS_BULL": - score += 25 - elif last_marker == "CHOCH_BULL": - score += 20 - - # OB 근접도 - ob_rows = recent[recent["ob_top"].notna()] - if not ob_rows.empty: - last_ob = ob_rows.iloc[-1] - ob_top = float(last_ob["ob_top"]) - ob_bottom = float(last_ob["ob_bottom"]) - ob_range = ob_top - ob_bottom if ob_top > ob_bottom else 1.0 - if ob_bottom <= price <= ob_top: - score += 10 - elif price < ob_top and price > ob_bottom - ob_range * 0.5: - score += 5 - - # FVG 미티게이션 - if self._smc_cfg.fvg_mitigation: - fvg_rows = recent[(recent["fvg_type"] == "bull") & recent["fvg_top"].notna()] - if not fvg_rows.empty: - last_fvg = fvg_rows.iloc[-1] - fvg_top = float(last_fvg["fvg_top"]) - fvg_bottom = float(last_fvg["fvg_bottom"]) - if fvg_bottom <= price <= fvg_top: - score += 5 - - return min(score, self._smc_cfg.weight_smc) - - def _score_volatility(self, df: pd.DataFrame) -> int: - """Layer 2: Volatility Setup 스코어 (0~20).""" - if len(df) < 50: - return 0 - - score = 0 - curr = df.iloc[-1] - - bb_width = float(curr.get("bb_width", 0)) if pd.notna(curr.get("bb_width")) else 0 - bb_avg_series = df["bb_width"].rolling(window=50).mean() - bb_width_avg = float(bb_avg_series.iloc[-1]) if pd.notna(bb_avg_series.iloc[-1]) else bb_width - if bb_width_avg > 0: - squeeze_ratio = bb_width / bb_width_avg - else: - squeeze_ratio = 1.0 - - if squeeze_ratio < 0.8: - score += 15 - elif squeeze_ratio < 1.0: - score += 8 - - atr_pct = float(curr.get("atr_pct", 0)) if pd.notna(curr.get("atr_pct")) else 0 - atr_avg = df["atr_pct"].rolling(window=50).mean() - atr_avg_val = float(atr_avg.iloc[-1]) if pd.notna(atr_avg.iloc[-1]) else atr_pct - if atr_avg_val > 0 and 0.5 <= atr_pct / atr_avg_val <= 1.5: - score += 5 - - return min(score, self._smc_cfg.weight_bb) - - def _score_obv_signal(self, df: pd.DataFrame) -> int: - """Layer 3a: OBV 스코어 (0~20).""" - if len(df) < 25: - return 0 - - score = 0 - curr = df.iloc[-1] - - obv_ema5 = float(curr.get("obv_ema5", 0)) if pd.notna(curr.get("obv_ema5")) else 0 - obv_ema20 = float(curr.get("obv_ema20", 0)) if pd.notna(curr.get("obv_ema20")) else 0 - - if obv_ema5 > obv_ema20: - score += 10 - if len(df) >= 6: - obv_5ago = float(df.iloc[-6].get("obv_ema5", 0)) if pd.notna(df.iloc[-6].get("obv_ema5")) else 0 - if obv_ema5 > obv_5ago: - score += 5 - - curr_vol = float(curr.get("volume", 0)) if pd.notna(curr.get("volume")) else 0 - vol_ma = float(curr.get("volume_ma", 1)) if pd.notna(curr.get("volume_ma")) else 1 - if vol_ma > 0 and curr_vol >= vol_ma * 1.3: - score += 5 - - return min(score, self._smc_cfg.weight_obv) - - def _score_momentum_signal(self, df: pd.DataFrame) -> int: - """Layer 3b: ADX/MACD 모멘텀 스코어 (0~20).""" - if len(df) < 30: - return 0 - - score = 0 - curr = df.iloc[-1] - prev = df.iloc[-2] - - adx = float(curr.get("adx", 0)) if pd.notna(curr.get("adx")) else 0 - plus_di = float(curr.get("plus_di", 0)) if pd.notna(curr.get("plus_di")) else 0 - minus_di = float(curr.get("minus_di", 0)) if pd.notna(curr.get("minus_di")) else 0 - - if adx > 25 and plus_di > minus_di: - score += 10 - elif adx > 20 and plus_di > minus_di: - score += 5 - - macd_hist = float(curr.get("macd_hist", 0)) if pd.notna(curr.get("macd_hist")) else 0 - prev_macd = float(prev.get("macd_hist", 0)) if pd.notna(prev.get("macd_hist")) else 0 - - if prev_macd <= 0 and macd_hist > 0: - score += 10 - elif macd_hist > 0 and macd_hist > prev_macd: - score += 5 - - return min(score, self._smc_cfg.weight_momentum) - - # ══════════════════════════════════════════ - # SMC 청산 로직 - # ══════════════════════════════════════════ - - def _check_exits_smc(self): - """ - SMC 전용 청산 체크. - ES1(-5%) > ATR SL > ATR TP > CHoCH > ES3 트레일링 > ES5 보유기간 > ES7 리밸런스 - """ - to_close: List[str] = [] - - for code, pos in self.positions.items(): - if pos.status != "ACTIVE": - continue - if self._exit_tag_filter and pos.strategy_tag != self._exit_tag_filter: - continue - # 글로벌 레짐 기반 청산 파라미터 (종목별 레짐은 analytics용) - regime_exit = REGIME_EXIT_PARAMS.get(self._market_regime, REGIME_EXIT_PARAMS["NEUTRAL"]) - - current_price = self._current_prices.get(code, pos.current_price) - entry_price = pos.entry_price - pnl_pct = (current_price - entry_price) / entry_price - - exit_reason = None - exit_type = None - - # ATR 조회 - atr_val = None - df = self._ohlcv_cache.get(code) - if df is not None and len(df) > 14: - if "atr" not in df.columns: - df = self._calculate_indicators(df.copy()) - self._ohlcv_cache[code] = df - last_atr = df.iloc[-1].get("atr") - if pd.notna(last_atr): - atr_val = float(last_atr) - - # ES1: 손절 -5% (GAP DOWN 보호: _execute_sell에서 fill price 조정) - if current_price <= entry_price * (1 + self.stop_loss_pct): - exit_reason = "ES1 손절 -5%" - exit_type = "STOP_LOSS" - - # ATR SL: entry - ATR * mult (2일 쿨다운) - elif atr_val and atr_val > 0: - atr_sl_price = entry_price - atr_val * self._smc_cfg.atr_sl_mult - floor_sl_price = entry_price * (1 + self.stop_loss_pct) - effective_sl = max(atr_sl_price, floor_sl_price) - - if current_price <= effective_sl and effective_sl > floor_sl_price: - exit_reason = "ES_SMC ATR SL" - exit_type = "ATR_STOP_LOSS" - - # ATR TP - if not exit_reason: - atr_tp_price = entry_price + atr_val * self._smc_cfg.atr_tp_mult - if current_price >= atr_tp_price: - exit_reason = "ES_SMC ATR TP" - exit_type = "ATR_TAKE_PROFIT" - - # CHoCH Exit: 추세 반전 감지 (Phase 5: PnL 게이트 추가) - # 데이터: CHoCH exits 9/23 trades, -$2,352 → 조기 청산이 수익 기회 파괴 - # 수정: PnL < -2% (손실 확대 방지) 또는 PnL > +5% (수익 보호)만 CHoCH 청산 - # -2%~+5% "발전 구간"에서는 CHoCH 무시 → 트레이드 성숙 대기 - if not exit_reason and self._smc_cfg.choch_exit and df is not None and len(df) > 10: - choch_pnl_gate = pnl_pct < -0.02 or pnl_pct > 0.05 - if choch_pnl_gate: - df_smc = self._calculate_indicators_smc(df.copy()) - recent_markers = df_smc.iloc[-5:] - for _, row in recent_markers.iterrows(): - if row.get("marker") == "CHOCH_BEAR": - exit_reason = "ES_CHOCH 추세반전" - exit_type = "CHOCH_EXIT" - break - - # ES3: 트레일링 스탑 - if not exit_reason: - trail_pct = self.trailing_stop_pct - if pnl_pct >= regime_exit["trail_activation"]: - if not pos.trailing_activated: - pos.trailing_activated = True - trailing_stop_price = pos.highest_price * (1 + trail_pct) - if current_price <= trailing_stop_price: - exit_reason = "ES3 트레일링스탑" - exit_type = "TRAILING_STOP" - - # ES5: 보유기간 초과 - if not exit_reason and pos.days_held > regime_exit["max_holding"]: - exit_reason = "ES5 보유기간 초과" - exit_type = "MAX_HOLDING" - - # ES7: 리밸런스 청산 (PnL 게이트: 수익 중이면 유예) - if not exit_reason and code in self._rebalance_exit_codes: - if pos.days_held < 3 or pnl_pct <= -0.02: - exit_reason = "ES7 리밸런스 청산" - exit_type = "REBALANCE_EXIT" - self._rebalance_exit_codes.discard(code) - elif pnl_pct > 0.02: - # 수익 +2%+ → 다음 리밸런스까지 유예 - self._rebalance_exit_codes.discard(code) - else: - exit_reason = "ES7 리밸런스 청산" - exit_type = "REBALANCE_EXIT" - self._rebalance_exit_codes.discard(code) - - if exit_reason: - to_close.append(code) - # Phase 통계 - exit_stat_map = { - "EMERGENCY_STOP": "es0_emergency_stop", - "STOP_LOSS": "es1_stop_loss", - "ATR_STOP_LOSS": "es_smc_sl", - "ATR_TAKE_PROFIT": "es_smc_tp", - "CHOCH_EXIT": "es_choch_exit", - "TRAILING_STOP": "es3_trailing_stop", - "MAX_HOLDING": "es5_max_holding", - "REBALANCE_EXIT": "es7_rebalance_exit", - } - stat_key = exit_stat_map.get(exit_type or "") - if stat_key and stat_key in self._phase_stats: - self._phase_stats[stat_key] += 1 - self._execute_sell(pos, current_price, exit_reason, exit_type or "") - else: - # 트레일링 최고가 갱신 - if current_price > pos.highest_price: - pos.highest_price = current_price - - for code in to_close: - del self.positions[code] - - # ══════════════════════════════════════════ - # Mean Reversion 지표/진입/청산 로직 - # ══════════════════════════════════════════ - - def _calculate_indicators_mean_reversion(self, df: pd.DataFrame) -> pd.DataFrame: - """기존 지표 + MA200 + Stochastic + 연속하락일 계산.""" - df = self._calculate_indicators(df) - if df.empty: - return df - - c = df["close"].astype(float) - h = df["high"].astype(float) - lo = df["low"].astype(float) - - # Stochastic %K/%D - k_period = self._mr_cfg.stochastic_k_period - d_period = self._mr_cfg.stochastic_d_period - lowest_low = lo.rolling(window=k_period).min() - highest_high = h.rolling(window=k_period).max() - denom = (highest_high - lowest_low).replace(0, np.nan) - df["stoch_k"] = 100 * (c - lowest_low) / denom - df["stoch_d"] = df["stoch_k"].rolling(window=d_period).mean() - - # 연속 하락일 카운터 - daily_return = c.pct_change() - is_down = (daily_return < 0).astype(int) - consec = [] - count = 0 - for val in is_down: - if val == 1: - count += 1 - else: - count = 0 - consec.append(count) - df["consecutive_down_days"] = consec - - # Phase 5: MA50 for MR TP target - if len(df) >= 50: - df["ma50"] = c.rolling(window=50).mean() - - return df - - def _score_mr_signal(self, df: pd.DataFrame) -> int: - """Layer 1: MR Signal (0~weight_signal). Graduated RSI + BB proximity + MA200.""" - if len(df) < 200: - return 0 - - score = 0 - curr = df.iloc[-1] - price = float(curr["close"]) - - # Graduated RSI scoring (바이너리 → 단계적) - rsi = float(curr.get("rsi", 50)) if pd.notna(curr.get("rsi")) else 50 - if rsi < 25: - score += 20 # 강한 과매도 - elif rsi < 35: - score += 15 # 중간 과매도 - elif rsi < 42: - score += 10 # 경미한 과매도 - - # Graduated BB Lower proximity (breach + 근접) - bb_lower = float(curr.get("bb_lower", 0)) if pd.notna(curr.get("bb_lower")) else 0 - if bb_lower > 0: - if price < bb_lower: - score += 15 # BB 하단 돌파 (강한 시그널) - elif price < bb_lower * 1.01: - score += 8 # BB 하단 1% 이내 근접 - - # MA200 위 = 장기 상승 추세 안에서의 pullback (건강한 MR) - ma200 = float(curr.get("ma200", 0)) if pd.notna(curr.get("ma200")) else 0 - if ma200 > 0 and price > ma200: - score += 5 - - return min(score, self._mr_cfg.weight_signal) - - def _score_mr_volatility(self, df: pd.DataFrame) -> int: - """Layer 2: Volatility & Volume (0~weight_volatility). Graduated scoring.""" - if len(df) < 30: - return 0 - - score = 0 - curr = df.iloc[-1] - - # BB Width 확장 (변동성 증가 = 평균 회귀 기회) - bb_width = float(curr.get("bb_width", 0)) if pd.notna(curr.get("bb_width")) else 0 - bb_width_avg = df["bb_width"].rolling(window=20).mean() - bb_avg_val = float(bb_width_avg.iloc[-1]) if pd.notna(bb_width_avg.iloc[-1]) else bb_width - if bb_avg_val > 0: - if bb_width > bb_avg_val * 1.5: - score += 12 # 강한 변동성 확장 - elif bb_width > bb_avg_val * 1.2: - score += 8 # 보통 확장 - elif bb_width > bb_avg_val * 1.0: - score += 4 # 약간 확장 - - # Graduated volume scoring (2.0x → 1.5x/1.2x 단계) - curr_vol = float(curr.get("volume", 0)) if pd.notna(curr.get("volume")) else 0 - vol_ma = float(curr.get("volume_ma", 1)) if pd.notna(curr.get("volume_ma")) else 1 - if vol_ma > 0: - vol_ratio = curr_vol / vol_ma - if vol_ratio > 2.0: - score += 10 # 강한 볼륨 스파이크 (capitulation) - elif vol_ratio > self._mr_cfg.volume_spike_mult: - score += 7 # 보통 스파이크 - elif vol_ratio > 1.2: - score += 4 # 약한 볼륨 증가 - - # ATR 확장 (패닉 셀오프 감지) - atr = float(curr.get("atr", 0)) if pd.notna(curr.get("atr")) else 0 - atr_ma = df["atr"].rolling(window=20).mean() - atr_avg_val = float(atr_ma.iloc[-1]) if pd.notna(atr_ma.iloc[-1]) else atr - if atr_avg_val > 0 and atr > atr_avg_val * 1.3: - score += 8 - - return min(score, self._mr_cfg.weight_volatility) - - def _score_mr_confirmation(self, df: pd.DataFrame) -> int: - """Layer 3: Confirmation (0~weight_confirmation). MACD slope + Stochastic + 연속하락.""" - if len(df) < 30: - return 0 - - score = 0 - curr = df.iloc[-1] - prev = df.iloc[-2] - - # MACD: zero cross (+10) OR slope positive (+5) - macd_hist = float(curr.get("macd_hist", 0)) if pd.notna(curr.get("macd_hist")) else 0 - prev_macd = float(prev.get("macd_hist", 0)) if pd.notna(prev.get("macd_hist")) else 0 - if prev_macd <= 0 and macd_hist > 0: - score += 10 # zero cross (강한 반전 시그널) - elif macd_hist > prev_macd and macd_hist < 0: - score += 5 # slope positive (하락세 둔화) - - # Stochastic: graduated (K<20 → +10, K<30 → +5, K<20 GC bonus +5) - stoch_k = float(curr.get("stoch_k", 50)) if pd.notna(curr.get("stoch_k")) else 50 - stoch_d = float(curr.get("stoch_d", 50)) if pd.notna(curr.get("stoch_d")) else 50 - prev_stoch_k = float(prev.get("stoch_k", 50)) if pd.notna(prev.get("stoch_k")) else 50 - prev_stoch_d = float(prev.get("stoch_d", 50)) if pd.notna(prev.get("stoch_d")) else 50 - if stoch_k < 20: - score += 8 # 강한 과매도 - if prev_stoch_k <= prev_stoch_d and stoch_k > stoch_d: - score += 4 # golden cross 보너스 - elif stoch_k < 30: - score += 5 # 보통 과매도 - - # 연속 하락일: graduated (>=2 → +5, >=3 → +8, >=5 → +10) - consec_down = int(curr.get("consecutive_down_days", 0)) - if consec_down >= 5: - score += 10 # 장기 하락 (강한 MR 후보) - elif consec_down >= self._mr_cfg.consecutive_down_days + 1: - score += 8 # 3일 연속 하락 - elif consec_down >= self._mr_cfg.consecutive_down_days: - score += 5 # 2일 연속 하락 - - return min(score, self._mr_cfg.weight_confirmation) - - def _scan_entries_mean_reversion(self): - """ - Mean Reversion 3-Layer 스코어링 기반 진입 스캔. - Phase 0 (시장 체제) + Phase 4 (리스크 게이트) → 레짐 필터 → MR 스코어링 → 매수 실행. - """ - # ── Phase 0: 시장 체제 판단 ── - self._update_market_regime() - - # ── Phase 4: 리스크 게이트 ── - can_trade, block_reason = self._risk_gate_check() - if not can_trade: - self._phase_stats["phase4_risk_blocks"] += 1 - self._add_risk_event("WARNING", f"MR 진입 차단: {block_reason}") - return - - total_equity = self._get_total_equity() - regime_params = REGIME_PARAMS.get(self._market_regime, REGIME_PARAMS["NEUTRAL"]) - - active_count = len([p for p in self.positions.values() if p.status == "ACTIVE"]) - - if active_count >= regime_params["max_positions"]: - return - - new_signals: List[tuple] = [] - - for w in self._watchlist: - code = w["code"] - - # 기존 보유 종목 체크 — MR 수익 +3%, 3일+ 보유, 미스케일 → 추가 진입 허용 - is_scale = False - if code in self.positions and self.positions[code].status in ("ACTIVE", "PENDING"): - pos = self.positions[code] - if (pos.strategy_tag == "mean_reversion" - and pos.scale_count < 1 - and pos.days_held >= 3): - cur_px = self._current_prices.get(code, pos.current_price) - eff_entry = pos.avg_entry_price if pos.avg_entry_price > 0 else pos.entry_price - if (cur_px - eff_entry) / eff_entry >= 0.03: - is_scale = True # Fall through to scoring - else: - continue - else: - continue - - df = self._ohlcv_cache.get(code) - if df is None or len(df) < 200: - continue - - df = self._calculate_indicators_mean_reversion(df.copy()) - if df.empty or len(df) < 2: - continue - - self._phase_stats["total_scans"] += 1 - curr = df.iloc[-1] - - # 레짐 필터: ADX < 25 (비추세) OR 극도 과매도 - # NEUTRAL 레짐: 더 엄격한 ADX 제한 (25→22) - _mr_ro = REGIME_OVERRIDES.get(self._market_regime, {}) - effective_adx_limit = _mr_ro.get("mr_adx_limit", self._mr_cfg.adx_trending_limit) - adx = float(curr.get("adx", 0)) if pd.notna(curr.get("adx")) else 0 - rsi = float(curr.get("rsi", 50)) if pd.notna(curr.get("rsi")) else 50 - if adx >= effective_adx_limit and rsi >= self._mr_cfg.extreme_oversold_rsi: - self._phase_stats["phase1_trend_rejects"] += 1 - continue - - # RANGE_BOUND 레짐: 지지선 근처에서만 MR 진입 - if _mr_ro.get("sr_zone_entry") and not is_scale: - sr = self._detect_support_resistance(df, lookback=_mr_ro.get("box_lookback", 40)) - _current_px = self._current_prices.get(code, float(curr["close"])) - atr_buf = float(curr.get("atr", 0)) if pd.notna(curr.get("atr")) else 0 - buffer = atr_buf * _mr_ro.get("sr_atr_buffer", 1.5) - near_support = any(abs(_current_px - s) < buffer for s in sr["support"]) if sr["support"] else False - if not near_support and sr["support"]: - continue # 지지선 근처가 아니면 진입 차단 - - # Phase 5: 반전 확인 캔들 — 전일 양봉 필수 (스케일업은 면제) - if not is_scale and len(df) >= 2: - prev = df.iloc[-2] - prev_close = float(prev["close"]) if pd.notna(prev.get("close")) else 0 - prev_open = float(prev["open"]) if pd.notna(prev.get("open")) else 0 - if prev_close <= prev_open: # 전일 음봉 → 반전 미확인 - continue - - # 3-Layer 스코어링 - score_signal = self._score_mr_signal(df) - score_vol = self._score_mr_volatility(df) - score_confirm = self._score_mr_confirmation(df) - total_score = score_signal + score_vol + score_confirm - - if total_score < self._mr_cfg.entry_threshold: - self._phase_stats["phase3_no_primary"] += 1 - continue - - current_price = self._current_prices.get(code, float(curr["close"])) - - # 스케일업: 시그널 강도 50% 감소, 라벨 변경 - scale_label = "MR_SCALE" if is_scale else "MR" - effective_strength = min(int(total_score * 0.5), 50) if is_scale else min(total_score, 100) - - self._signal_counter += 1 - signal = SimSignal( - id=f"sim-sig-{self._signal_counter:04d}", - stock_code=code, - stock_name=w["name"], - type="BUY", - price=current_price, - reason=f"{scale_label}_{total_score} [L1:{score_signal} L2:{score_vol} L3:{score_confirm}]", - strength=effective_strength, - detected_at=self._get_current_iso(), - ) - new_signals.append((signal, "MODERATE", "MID", 3)) - - self._phase_stats["mr_total_score"] += total_score - self._phase_stats["mr_entries"] += 1 - - # 시그널 강도순 정렬 후 매수 실행 - new_signals.sort(key=lambda x: x[0].strength, reverse=True) - - for sig, trend_strength, trend_stage, align_score in new_signals: - if active_count >= regime_params["max_positions"]: - break - self.signals.append(sig) - if len(self.signals) > 100: - self.signals = self.signals[-100:] - - self._execute_buy(sig, trend_strength=trend_strength, - trend_stage=trend_stage, alignment_score=align_score) - self._phase_stats["entries_executed"] += 1 - active_count += 1 - - def _check_exits_mean_reversion(self): - """ - Mean Reversion 전용 7-Priority 청산 체크. - ES1(-5%) > ATR SL > MR TP(MA20/RSI>60) > BB Mid > Trailing > Overbought > Max Holding > ES7 - """ - to_close: List[str] = [] - - for code, pos in self.positions.items(): - if pos.status != "ACTIVE": - continue - if self._exit_tag_filter and pos.strategy_tag != self._exit_tag_filter: - continue - # 글로벌 레짐 기반 청산 파라미터 (종목별 레짐은 analytics용) - regime_exit = REGIME_EXIT_PARAMS.get(self._market_regime, REGIME_EXIT_PARAMS["NEUTRAL"]) - - - current_price = self._current_prices.get(code, pos.current_price) - entry_price = pos.entry_price - # 스케일업된 포지션은 가중평균 매입가 기준 PnL 계산 - effective_entry = pos.avg_entry_price if pos.avg_entry_price > 0 else entry_price - pnl_pct = (current_price - effective_entry) / effective_entry - - exit_reason = None - exit_type = None - - # ATR / RSI / BB 조회 - atr_val = None - rsi_val = None - bb_mid = None - ma20 = None - df = self._ohlcv_cache.get(code) - if df is not None and len(df) > 14: - if "atr" not in df.columns or "stoch_k" not in df.columns: - df = self._calculate_indicators_mean_reversion(df.copy()) - self._ohlcv_cache[code] = df - last = df.iloc[-1] - if pd.notna(last.get("atr")): - atr_val = float(last["atr"]) - if pd.notna(last.get("rsi")): - rsi_val = float(last["rsi"]) - if pd.notna(last.get("bb_middle")): - bb_mid = float(last["bb_middle"]) - if pd.notna(last.get("ma_long")): - ma20 = float(last["ma_long"]) - - # ES1: 손절 -5% (GAP DOWN 보호: _execute_sell에서 fill price 조정) - if current_price <= entry_price * (1 + self.stop_loss_pct): - exit_reason = "ES1 손절 -5%" - exit_type = "STOP_LOSS" - - # ATR SL (2일 쿨다운) - elif atr_val and atr_val > 0: - atr_sl_price = entry_price - atr_val * self._mr_cfg.atr_sl_mult - floor_sl = entry_price * (1 + self.stop_loss_pct) - effective_sl = max(atr_sl_price, floor_sl) - if current_price <= effective_sl and effective_sl > floor_sl: - exit_reason = "ES_MR ATR SL" - exit_type = "ATR_STOP_LOSS" - - # Phase 5.4: MR TP 상향 — MA50+RSI>55 (더 큰 반등 포착) - # 기존 MA20+RSI>50 → 너무 일찍 청산 (2-3% 수익), SL -5%와 R:R 불균형 - ma50 = None - if df is not None and len(df) > 50: - last = df.iloc[-1] - if pd.notna(last.get("ma50")): - ma50 = float(last["ma50"]) - elif pd.notna(last.get("ma60")): - ma50 = float(last["ma60"]) # MA50 없으면 MA60 대체 - - if not exit_reason and ma50 and rsi_val is not None: - if current_price > ma50 and rsi_val > 55: - exit_reason = "ES_MR TP (MA50+RSI>55)" - exit_type = "MEAN_REVERSION_TP" - - # RSI > 65 제거 — 너무 이른 청산. 대신 RSI > 70만 유지 (아래 overbought) - - # 수익 보호: pnl >= 5% 이면 MA20 단독으로도 청산 (최소 수익 확보) - if not exit_reason and ma20 and pnl_pct >= 0.05 and current_price > ma20: - exit_reason = "ES_MR TP (MA20 profit lock 5%)" - exit_type = "MEAN_REVERSION_TP" - - # ES3: 트레일링 스탑 (MR → 5%에서 활성화, 기존 4%) - if not exit_reason: - trail_pct = self.trailing_stop_pct - if pnl_pct >= 0.05: - if not pos.trailing_activated: - pos.trailing_activated = True - trailing_stop_price = pos.highest_price * (1 + trail_pct) - if current_price <= trailing_stop_price: - exit_reason = "ES3 트레일링스탑" - exit_type = "TRAILING_STOP" - - # Overbought: RSI > 70 - if not exit_reason and rsi_val is not None and rsi_val > self._mr_cfg.rsi_overbought: - exit_reason = "ES_MR 과매수(RSI>70)" - exit_type = "OVERBOUGHT_EXIT" - - # ES_TIME_DECAY: 글로벌 레짐 기반 시간감쇄 강제 청산 - _ro_mr = REGIME_OVERRIDES.get(self._market_regime, {}) - if not exit_reason and _ro_mr.get("time_decay_enabled"): - decay_days = _ro_mr.get("time_decay_days", 10) - decay_pnl = _ro_mr.get("time_decay_pnl_min", 0.02) - if pos.days_held >= decay_days and pnl_pct < decay_pnl: - exit_reason = f"ES_TIME_DECAY: {pos.days_held}일 보유, PnL {pnl_pct:.1%} < {decay_pnl:.0%}" - exit_type = "TIME_DECAY" - self._phase_stats.setdefault("es_neutral_time_decay", 0) - self._phase_stats["es_neutral_time_decay"] += 1 - - # ES_BOX_BREAK: RANGE_BOUND 레짐 박스 이탈 즉시 청산 - if not exit_reason and _ro_mr.get("box_breakout_exit") and df is not None: - _box_lb = _ro_mr.get("box_lookback", 40) - if len(df) >= _box_lb: - recent_box = df.tail(_box_lb) - box_high = float(recent_box["high"].max()) - box_low = float(recent_box["low"].min()) - if current_price > box_high * 1.01 or current_price < box_low * 0.99: - exit_reason = f"ES_BOX_BREAK: 박스({box_low:.0f}-{box_high:.0f}) 이탈" - exit_type = "BOX_BREAKOUT_EXIT" - self._phase_stats.setdefault("es_range_box_breakout", 0) - self._phase_stats["es_range_box_breakout"] += 1 - - # ES5: 수익률 연동 보유기간 (MR 전용) - if not exit_reason: - base_max = self._mr_cfg.max_holding_days # 20 - if pnl_pct >= 0.10: - effective_max = 60 # 큰 수익: 트레일링 스탑이 관리 - elif pnl_pct >= 0.05: - effective_max = 35 # 좋은 수익: 적정 확장 - elif pnl_pct >= 0.02: - effective_max = 25 # 소폭 수익: 완만 확장 - else: - effective_max = base_max # 손실/평: 20일 유지 - if pos.days_held > effective_max: - exit_reason = f"ES5 보유기간 초과 ({effective_max}일)" - exit_type = "MAX_HOLDING" - - # ES7: 리밸런스 청산 (PnL 게이트: 수익 중이면 유예) - if not exit_reason and code in self._rebalance_exit_codes: - if pos.days_held < 3 or pnl_pct <= -0.02: - exit_reason = "ES7 리밸런스 청산" - exit_type = "REBALANCE_EXIT" - self._rebalance_exit_codes.discard(code) - elif pnl_pct > 0.02: - # 수익 +2%+ → 다음 리밸런스까지 유예 - self._rebalance_exit_codes.discard(code) - else: - exit_reason = "ES7 리밸런스 청산" - exit_type = "REBALANCE_EXIT" - self._rebalance_exit_codes.discard(code) - - if exit_reason: - to_close.append(code) - exit_stat_map = { - "EMERGENCY_STOP": "es0_emergency_stop", - "STOP_LOSS": "es1_stop_loss", - "ATR_STOP_LOSS": "es_mr_sl", - "MEAN_REVERSION_TP": "es_mr_tp", - "BB_MID_REVERT": "es_mr_bb", - "TRAILING_STOP": "es3_trailing_stop", - "OVERBOUGHT_EXIT": "es_mr_ob", - "MAX_HOLDING": "es5_max_holding", - "REBALANCE_EXIT": "es7_rebalance_exit", - } - stat_key = exit_stat_map.get(exit_type or "") - if stat_key and stat_key in self._phase_stats: - self._phase_stats[stat_key] += 1 - self._execute_sell(pos, current_price, exit_reason, exit_type or "") - else: - if current_price > pos.highest_price: - pos.highest_price = current_price - - for code in to_close: - del self.positions[code] - - # ══════════════════════════════════════════ - # Breakout-Retest 지표/진입/청산 로직 - # ══════════════════════════════════════════ - - def _calculate_indicators_breakout_retest(self, df: pd.DataFrame) -> pd.DataFrame: - """기존 지표 + SMC + OBV + ADX 통합 계산 (breakout_retest 전용).""" - df = self._calculate_indicators(df) - if df.empty: - return df - - # SMC: Swing Points, BOS/CHoCH, Order Blocks, FVG - from analytics.indicators import calculate_smc - df = calculate_smc(df, swing_length=self._brt_cfg.swing_length) - - # OBV (On Balance Volume) - c = df["close"].astype(float) - v = df["volume"].astype(float) - df["obv"] = (np.sign(c.diff()).fillna(0) * v).cumsum() - df["obv_ema5"] = df["obv"].ewm(span=5, adjust=False).mean() - df["obv_ema20"] = df["obv"].ewm(span=20, adjust=False).mean() - - return df - - def _score_brt_structure(self, df: pd.DataFrame) -> int: - """Layer 1: SMC 구조 스코어 (0~weight_structure). BOS + 유동성 스윕.""" - if len(df) < 10: - return 0 - - score = 0 - lookback = min(20, len(df)) - recent = df.iloc[-lookback:] - - markers = recent[recent["marker"].notna()] - if not markers.empty: - last_marker = markers.iloc[-1]["marker"] - if last_marker == "BOS_BULL": - score += 20 - elif last_marker == "CHOCH_BULL": - score += 15 - - # 유동성 스윕 - swing_lows = recent[recent["is_swing_low"] == True] - if not swing_lows.empty and len(df) >= 7: - last_sl = float(swing_lows.iloc[-1]["low"]) - recent_7 = df.iloc[-7:] - if (recent_7["low"] < last_sl).any(): - score += 10 - - return min(score, self._brt_cfg.weight_structure) - - def _score_brt_volatility(self, df: pd.DataFrame) -> int: - """Layer 2: BB/ATR 변동성 스코어 (0~weight_volatility).""" - lookback = self._brt_cfg.bb_squeeze_lookback - if len(df) < max(lookback, 50): - return 0 - - score = 0 - bb_width = df["bb_width"].dropna() - if len(bb_width) < lookback: - return 0 - - current_width = float(bb_width.iloc[-1]) - min_width = float(bb_width.iloc[-lookback:].min()) - bb_ema = float(bb_width.ewm(span=self._brt_cfg.bb_squeeze_ema).mean().iloc[-1]) - - if min_width > 0 and current_width <= min_width * 1.1: - score += 15 - elif bb_ema > 0 and current_width < bb_ema: - score += 8 - - # ATR 압축 - atr_pct = df["atr_pct"].dropna() - if len(atr_pct) >= 50: - atr_avg = float(atr_pct.rolling(50).mean().iloc[-1]) - curr_atr = float(atr_pct.iloc[-1]) - if atr_avg > 0 and curr_atr < atr_avg * 0.8: - score += 5 - - return min(score, self._brt_cfg.weight_volatility) - - def _score_brt_obv(self, df: pd.DataFrame) -> int: - """Layer 3: OBV 돌파 스코어 (0~weight_volume).""" - obv = df["obv"].dropna() - lb = self._brt_cfg.obv_break_lookback - if len(obv) < lb + 1: - return 0 - - score = 0 - curr_obv = float(obv.iloc[-1]) - prev_obv_high = float(obv.iloc[-lb - 1:-1].max()) - - if curr_obv > prev_obv_high: - score += 15 - obv_ema5 = float(df["obv_ema5"].iloc[-1]) if pd.notna(df["obv_ema5"].iloc[-1]) else 0 - obv_ema20 = float(df["obv_ema20"].iloc[-1]) if pd.notna(df["obv_ema20"].iloc[-1]) else 0 - if obv_ema5 > obv_ema20: - score += 10 - - return min(score, self._brt_cfg.weight_volume) - - def _score_brt_momentum(self, df: pd.DataFrame) -> int: - """Layer 4: ADX/MACD 모멘텀 스코어 (0~weight_momentum).""" - rising_bars = self._brt_cfg.adx_rising_bars - if len(df) < rising_bars + 2: - return 0 - - score = 0 - curr = df.iloc[-1] - prev = df.iloc[-2] - - adx_series = df["adx"].dropna() - if len(adx_series) >= rising_bars + 1: - curr_adx = float(adx_series.iloc[-1]) - plus_di = float(curr.get("plus_di", 0)) if pd.notna(curr.get("plus_di")) else 0 - minus_di = float(curr.get("minus_di", 0)) if pd.notna(curr.get("minus_di")) else 0 - - if curr_adx > self._brt_cfg.adx_threshold and plus_di > minus_di: - score += 8 - rising = True - for i in range(1, rising_bars + 1): - if float(adx_series.iloc[-i]) <= float(adx_series.iloc[-i - 1]): - rising = False - break - if rising: - score += 7 - - macd_hist = float(curr.get("macd_hist", 0)) if pd.notna(curr.get("macd_hist")) else 0 - prev_macd = float(prev.get("macd_hist", 0)) if pd.notna(prev.get("macd_hist")) else 0 - - if prev_macd <= 0 and macd_hist > 0: - score += 10 - elif macd_hist > 0 and macd_hist > prev_macd: - score += 5 - - return min(score, self._brt_cfg.weight_momentum) - - def _check_brt_six_conditions(self, df: pd.DataFrame) -> tuple: - """6조건 검증 (sim). 최소 4개 필요.""" - met = [] - curr = df.iloc[-1] - cfg = self._brt_cfg - - # C1: Volatility Squeeze - bb_width = df["bb_width"].dropna() - if len(bb_width) >= cfg.bb_squeeze_lookback: - curr_w = float(bb_width.iloc[-1]) - min_w = float(bb_width.iloc[-cfg.bb_squeeze_lookback:].min()) - if min_w > 0 and curr_w <= min_w * 1.2: - met.append("C1_SQUEEZE") - - # C2: Liquidity Sweep - swing_lows = df[df.get("is_swing_low", pd.Series(dtype=bool)) == True] - if not swing_lows.empty and len(df) >= 7: - last_sl = float(swing_lows.iloc[-1]["low"]) - recent = df.iloc[-7:] - if (recent["low"] < last_sl).any(): - met.append("C2_LIQ_SWEEP") - - # C3: Displacement - body = abs(float(curr["close"]) - float(curr["open"])) - atr = float(curr.get("atr", 0)) if pd.notna(curr.get("atr")) else 0 - if atr > 0 and body > atr * cfg.displacement_atr_mult: - met.append("C3_DISPLACEMENT") - - # C4: OBV Break - obv = df["obv"].dropna() - if len(obv) > cfg.obv_break_lookback: - if float(obv.iloc[-1]) > float(obv.iloc[-cfg.obv_break_lookback - 1:-1].max()): - met.append("C4_OBV_BREAK") - - # C5: ADX > threshold & rising - adx_s = df["adx"].dropna() - if len(adx_s) >= cfg.adx_rising_bars + 1: - if float(adx_s.iloc[-1]) > cfg.adx_threshold: - rising = all( - float(adx_s.iloc[-i]) > float(adx_s.iloc[-i - 1]) - for i in range(1, cfg.adx_rising_bars + 1) - ) - if rising: - met.append("C5_ADX_RISING") - - # C6: FVG Formation - fvg_recent = df.iloc[-3:] - if not fvg_recent[fvg_recent.get("fvg_type", pd.Series(dtype=str)) == "bull"].empty: - met.append("C6_FVG") - - return len(met) >= 3, met # Phase 5: 4/6→3/6 진입 기준 완화 - - def _apply_brt_fakeout_filters(self, df: pd.DataFrame) -> tuple: - """3개 페이크아웃 필터 (sim). (통과 여부, 차단 사유).""" - curr = df.iloc[-1] - cfg = self._brt_cfg - - # ERR01: 저거래량 - volume = float(curr.get("volume", 0)) - vol_ma = float(curr.get("volume_ma", 1)) if pd.notna(curr.get("volume_ma")) else 1 - if vol_ma > 0 and volume < vol_ma * cfg.min_volume_ratio: - return False, "ERR01_LOW_VOLUME" - - # ERR02: 긴 윗꼬리 - close = float(curr["close"]) - open_p = float(curr["open"]) - high = float(curr["high"]) - body = abs(close - open_p) - upper_wick = high - max(close, open_p) - if body > 0 and upper_wick / body > cfg.max_wick_body_ratio: - return False, "ERR02_WICK_TRAP" - - # ERR03: MACD/RSI 다이버전스 - if cfg.divergence_check and len(df) >= 10: - price_curr = float(df["close"].iloc[-1]) - price_prev_max = float(df["close"].iloc[-10:-1].max()) - macd_curr = float(curr.get("macd_hist", 0)) if pd.notna(curr.get("macd_hist")) else 0 - macd_prev_max = float(df["macd_hist"].iloc[-10:-1].max()) if "macd_hist" in df.columns else 0 - rsi_curr = float(curr.get("rsi", 50)) if pd.notna(curr.get("rsi")) else 50 - rsi_prev_max = float(df["rsi"].iloc[-10:-1].max()) if "rsi" in df.columns else 50 - - if price_curr > price_prev_max and (macd_curr < macd_prev_max * 0.8 or rsi_curr < rsi_prev_max * 0.9): - return False, "ERR03_DIVERGENCE" - - return True, None - - def _capture_brt_retest_zones(self, df: pd.DataFrame, breakout_price: float, breakout_atr: float) -> Dict[str, Any]: - """돌파 시점 FVG/OB/레벨 존을 캡처해서 상태 dict로 반환.""" - cfg = self._brt_cfg - state: Dict[str, Any] = { - "phase": "WAITING_RETEST", - "breakout_price": breakout_price, - "breakout_atr": breakout_atr, - "bars_since_breakout": 0, - "breakout_score": 0, - "fvg_top": 0.0, "fvg_bottom": 0.0, - "ob_top": 0.0, "ob_bottom": 0.0, - "breakout_level": 0.0, - "zone_top": 0.0, "zone_bottom": 0.0, - "zone_type": "LEVEL", - "conditions_met": [], - } - - recent = df.iloc[-20:] - - # FVG 존 캡처 - if cfg.use_fvg_zone: - fvg_rows = recent[(recent.get("fvg_type", pd.Series(dtype=str)) == "bull") & recent["fvg_top"].notna()] - if not fvg_rows.empty: - last_fvg = fvg_rows.iloc[-1] - state["fvg_top"] = float(last_fvg["fvg_top"]) - state["fvg_bottom"] = float(last_fvg["fvg_bottom"]) - - # OB 존 캡처 - if cfg.use_ob_zone: - ob_rows = recent[recent["ob_top"].notna()] - if not ob_rows.empty: - last_ob = ob_rows.iloc[-1] - state["ob_top"] = float(last_ob["ob_top"]) - state["ob_bottom"] = float(last_ob["ob_bottom"]) - - # 돌파 레벨 (마지막 swing high) - if cfg.use_breakout_level: - swing_highs = recent[recent.get("is_swing_high", pd.Series(dtype=bool)) == True] - if not swing_highs.empty: - state["breakout_level"] = float(swing_highs.iloc[-1]["high"]) - - # 복합 존 계산 - zone_candidates = [] - if state["fvg_bottom"] > 0: - zone_candidates.append((state["fvg_bottom"], state["fvg_top"])) - if state["ob_bottom"] > 0: - zone_candidates.append((state["ob_bottom"], state["ob_top"])) - if state["breakout_level"] > 0: - buffer = breakout_atr * cfg.retest_zone_atr_buffer - zone_candidates.append((state["breakout_level"] - buffer, state["breakout_level"])) - - if zone_candidates: - state["zone_bottom"] = min(z[0] for z in zone_candidates) - state["zone_top"] = max(z[1] for z in zone_candidates) - state["zone_type"] = "COMPOSITE" - else: - buffer = breakout_atr * cfg.retest_zone_atr_buffer - state["zone_bottom"] = breakout_price - breakout_atr - buffer - state["zone_top"] = breakout_price - state["zone_type"] = "LEVEL" - - return state - - def _scan_entries_breakout_retest(self): - """ - Breakout-Retest 2-Phase 진입 스캔. - Pass 1: IDLE 티커 → Phase A 돌파 감지 - Pass 2: WAITING_RETEST 티커 → Phase B 리테스트 확인 - """ - # ── Phase 0: 시장 체제 판단 ── - self._update_market_regime() - - # ── Phase 4: 리스크 게이트 ── - can_trade, block_reason = self._risk_gate_check() - if not can_trade: - self._phase_stats["phase4_risk_blocks"] += 1 - self._add_risk_event("WARNING", f"BRT 진입 차단: {block_reason}") - return - - total_equity = self._get_total_equity() - regime_params = REGIME_PARAMS.get(self._market_regime, REGIME_PARAMS["NEUTRAL"]) - - active_count = len([p for p in self.positions.values() if p.status == "ACTIVE"]) - - # ── Pass 1: IDLE 티커에서 돌파 감지 ── - for w in self._watchlist: - code = w["code"] - - - if code in self.positions and self.positions[code].status in ("ACTIVE", "PENDING"): - continue - - # 이미 WAITING_RETEST 상태면 Pass 1 스킵 - if code in self._breakout_states and self._breakout_states[code].get("phase") == "WAITING_RETEST": - continue - - df = self._ohlcv_cache.get(code) - if df is None or len(df) < max(self._brt_cfg.bb_squeeze_lookback, 50): - continue - - df = self._calculate_indicators_breakout_retest(df.copy()) - if df.empty or len(df) < 2: - continue - self._ohlcv_cache[code] = df - - self._phase_stats["total_scans"] += 1 - - # 4-Layer 스코어링 - score_s = self._score_brt_structure(df) - score_v = self._score_brt_volatility(df) - score_o = self._score_brt_obv(df) - score_m = self._score_brt_momentum(df) - total_score = score_s + score_v + score_o + score_m - - if total_score < self._brt_cfg.breakout_threshold: - self._phase_stats["phase3_no_primary"] += 1 - continue - - # 6조건 검증 - conditions_ok, met_list = self._check_brt_six_conditions(df) - if not conditions_ok: - self._phase_stats["phase3_no_confirm"] += 1 - continue - - # 3개 페이크아웃 필터 - filter_ok, block_reason = self._apply_brt_fakeout_filters(df) - if not filter_ok: - self._phase_stats["brt_fakeout_blocked"] += 1 - continue - - # 돌파 확인 → WAITING_RETEST 전이 - curr = df.iloc[-1] - breakout_price = float(curr["close"]) - breakout_atr = float(curr.get("atr", breakout_price * 0.03)) if pd.notna(curr.get("atr")) else breakout_price * 0.03 - - state = self._capture_brt_retest_zones(df, breakout_price, breakout_atr) - state["breakout_score"] = total_score - state["conditions_met"] = met_list - self._breakout_states[code] = state - self._phase_stats["brt_breakouts_detected"] += 1 - - self._add_risk_event( - "INFO", - f"돌파 감지: {w['name']} score={total_score} [S:{score_s} V:{score_v} O:{score_o} M:{score_m}]" - ) - - # ── Pass 2: WAITING_RETEST 티커에서 리테스트 진입 확인 ── - new_signals: List[tuple] = [] - expired_codes = [] - - for code, state in list(self._breakout_states.items()): - if state.get("phase") != "WAITING_RETEST": - continue - if code in self.positions and self.positions[code].status in ("ACTIVE", "PENDING"): - continue - - df = self._ohlcv_cache.get(code) - if df is None or len(df) < 2: - continue - - # 지표가 이미 계산되어 있지 않으면 재계산 - if "obv" not in df.columns: - df = self._calculate_indicators_breakout_retest(df.copy()) - self._ohlcv_cache[code] = df - - curr = df.iloc[-1] - price = float(curr["close"]) - low = float(curr["low"]) - - state["bars_since_breakout"] = state.get("bars_since_breakout", 0) + 1 - - # 만료 체크 - if state["bars_since_breakout"] > self._brt_cfg.retest_max_bars: - state["phase"] = "IDLE" - expired_codes.append(code) - self._phase_stats["brt_retests_expired"] += 1 - continue - - # 존 하단 이탈 → 실패 - if price < state["zone_bottom"]: - state["phase"] = "IDLE" - expired_codes.append(code) - continue - - # 존 도달 확인 - in_zone = low <= state["zone_top"] and price >= state["zone_bottom"] - if not in_zone: - continue - - # ── 확인 조건 (3개 중 2개 이상) ── - confirmations = 0 - confirm_parts = [] - - # 1. 거래량 감소 - volume = float(curr.get("volume", 0)) - vol_ma = float(curr.get("volume_ma", 1)) if pd.notna(curr.get("volume_ma")) else 1 - if vol_ma > 0 and volume < vol_ma * self._brt_cfg.retest_volume_decay: - confirmations += 1 - confirm_parts.append("VOL_DECAY") - - # 2. 반등 캔들 - open_p = float(curr["open"]) - body = abs(price - open_p) - lower_wick = min(price, open_p) - low - bullish_rejection = body > 0 and lower_wick > body * self._brt_cfg.retest_rejection_wick_ratio - bullish_close = price > open_p - if bullish_rejection or bullish_close: - confirmations += 1 - confirm_parts.append("REJECTION" if bullish_rejection else "BULL_CLOSE") - - # 3. RSI 지지 - rsi = float(curr.get("rsi", 50)) if pd.notna(curr.get("rsi")) else 50 - if rsi >= self._brt_cfg.retest_rsi_floor: - confirmations += 1 - confirm_parts.append(f"RSI_{int(rsi)}") - - if confirmations < 2: - continue - - # 존 스코어링 - zone_score = self._score_brt_retest_zone(df, state) - if zone_score < self._brt_cfg.retest_zone_threshold: - continue - - # ── 리테스트 진입 확인 ── - strength = min(state.get("breakout_score", 60) + zone_score // 2, 100) - stock_name = self._stock_names.get(code, code) - - self._signal_counter += 1 - signal = SimSignal( - id=f"sim-sig-{self._signal_counter:04d}", - stock_code=code, - stock_name=stock_name, - type="BUY", - price=self._current_prices.get(code, price), - reason=f"BRT_RETEST_{strength} [BKO:{state.get('breakout_score', 0)} ZONE:{zone_score} {'+'.join(confirm_parts)}]", - strength=strength, - detected_at=self._get_current_iso(), - ) - new_signals.append((signal, "MODERATE", "MID", 3)) + from simulation.strategies.defensive import scan_entries + scan_entries(self) - state["phase"] = "IDLE" # 사용된 상태 리셋 - self._phase_stats["brt_retests_entered"] += 1 + def _check_exits_defensive(self): + from simulation.strategies.defensive import check_exits + check_exits(self) - # 만료된 상태 정리 - for code in expired_codes: - if code in self._breakout_states and self._breakout_states[code].get("phase") == "IDLE": - del self._breakout_states[code] + # ───────────────────────────────────────────────────── + # Phase 6: Volatility Premium Strategy + # ───────────────────────────────────────────────────── - # 시그널 강도순 정렬 후 매수 실행 - new_signals.sort(key=lambda x: x[0].strength, reverse=True) + def _scan_entries_volatility(self): + from simulation.strategies.volatility import scan_entries + scan_entries(self) - for sig, trend_strength, trend_stage, align_score in new_signals: - if active_count >= regime_params["max_positions"]: - break - self.signals.append(sig) - if len(self.signals) > 100: - self.signals = self.signals[-100:] + def _check_exits_volatility(self): + from simulation.strategies.volatility import check_exits + check_exits(self) - self._execute_buy(sig, trend_strength=trend_strength, - trend_stage=trend_stage, alignment_score=align_score) - self._phase_stats["entries_executed"] += 1 - active_count += 1 + def _scan_entries_momentum(self): + from simulation.strategies.momentum import scan_entries + scan_entries(self) - def _score_brt_retest_zone(self, df: pd.DataFrame, state: Dict[str, Any]) -> int: - """리테스트 존 근접도 스코어링 (0-100).""" - price = float(df.iloc[-1]["close"]) - score = 0 - cfg = self._brt_cfg - - # FVG 근접도 - fvg_b = state.get("fvg_bottom", 0) - fvg_t = state.get("fvg_top", 0) - if fvg_b > 0 and cfg.use_fvg_zone: - if fvg_b <= price <= fvg_t: - score += cfg.fvg_zone_weight - elif price < fvg_t and price > fvg_b - state.get("breakout_atr", 0) * 0.3: - score += cfg.fvg_zone_weight // 2 - - # OB 근접도 - ob_b = state.get("ob_bottom", 0) - ob_t = state.get("ob_top", 0) - if ob_b > 0 and cfg.use_ob_zone: - if ob_b <= price <= ob_t: - score += cfg.ob_zone_weight - elif price < ob_t and price > ob_b - state.get("breakout_atr", 0) * 0.3: - score += cfg.ob_zone_weight // 2 - - # 돌파 레벨 근접도 - bl = state.get("breakout_level", 0) - if bl > 0 and cfg.use_breakout_level: - buffer = state.get("breakout_atr", 0) * cfg.retest_zone_atr_buffer - if bl - buffer <= price <= bl + buffer: - score += cfg.level_zone_weight - - return min(score, 100) + # ══════════════════════════════════════════ + # SMC 4-Layer 진입 스캔 + # ══════════════════════════════════════════ - def _check_exits_breakout_retest(self): - """ - Breakout-Retest 전용 청산 체크. - ES1(-5%) > ATR SL(1.5x) > ATR TP(3.0x) > CHoCH > ES3 트레일링 > Zone Break > ES5 보유기간 > ES7 리밸런스 - """ - to_close: List[str] = [] + def _scan_entries_smc(self): + from simulation.strategies.smc import scan_entries + scan_entries(self) - for code, pos in self.positions.items(): - if pos.status != "ACTIVE": - continue - if self._exit_tag_filter and pos.strategy_tag != self._exit_tag_filter: - continue - # 글로벌 레짐 기반 청산 파라미터 (종목별 레짐은 analytics용) - regime_exit = REGIME_EXIT_PARAMS.get(self._market_regime, REGIME_EXIT_PARAMS["NEUTRAL"]) + def _calculate_indicators_smc(self, df: pd.DataFrame) -> pd.DataFrame: + from simulation.strategies.smc import calculate_indicators_smc + return calculate_indicators_smc(self, df) + def _score_smc_bias(self, df: pd.DataFrame) -> int: + from simulation.strategies.smc import score_smc_bias + return score_smc_bias(self, df) - current_price = self._current_prices.get(code, pos.current_price) - entry_price = pos.entry_price - pnl_pct = (current_price - entry_price) / entry_price + def _score_volatility(self, df: pd.DataFrame) -> int: + from simulation.strategies.smc import score_volatility + return score_volatility(self, df) - exit_reason = None - exit_type = None + def _score_obv_signal(self, df: pd.DataFrame) -> int: + from simulation.strategies.smc import score_obv_signal + return score_obv_signal(self, df) - # ATR 조회 - atr_val = None - df = self._ohlcv_cache.get(code) - if df is not None and len(df) > 14: - if "atr" not in df.columns: - df = self._calculate_indicators(df.copy()) - self._ohlcv_cache[code] = df - last_atr = df.iloc[-1].get("atr") - if pd.notna(last_atr): - atr_val = float(last_atr) + def _score_momentum_signal(self, df: pd.DataFrame) -> int: + from simulation.strategies.smc import score_momentum_signal + return score_momentum_signal(self, df) - # ES1: 손절 -5% (GAP DOWN 보호: _execute_sell에서 fill price 조정) - if current_price <= entry_price * (1 + self.stop_loss_pct): - exit_reason = "ES1 손절 -5%" - exit_type = "STOP_LOSS" - - # ES_BRT_SL: ATR × 1.5 (2일 쿨다운) - elif atr_val and atr_val > 0: - atr_sl_price = entry_price - atr_val * self._brt_cfg.atr_sl_mult - floor_sl_price = entry_price * (1 + self.stop_loss_pct) - effective_sl = max(atr_sl_price, floor_sl_price) - - if current_price <= effective_sl and effective_sl > floor_sl_price: - exit_reason = "ES_BRT ATR SL (1.5x)" - exit_type = "ATR_STOP_LOSS" - - # ES_BRT_TP: ATR × 3.0 - if not exit_reason: - atr_tp_price = entry_price + atr_val * self._brt_cfg.atr_tp_mult - if current_price >= atr_tp_price: - exit_reason = "ES_BRT ATR TP (3.0x)" - exit_type = "ATR_TAKE_PROFIT" - - # ES_CHOCH: 추세 반전 감지 (Phase 5: PnL 게이트) - if not exit_reason and self._brt_cfg.choch_exit and df is not None and len(df) > 10: - choch_pnl_gate = pnl_pct < -0.02 or pnl_pct > 0.05 - if choch_pnl_gate: - df_calc = self._calculate_indicators_breakout_retest(df.copy()) - recent_markers = df_calc.iloc[-5:] - for _, row in recent_markers.iterrows(): - if row.get("marker") == "CHOCH_BEAR": - exit_reason = "ES_CHOCH 추세반전" - exit_type = "CHOCH_EXIT" - break - - # ES3: 트레일링 스탑 (+5% 활성화, ATR × 2.0) - if not exit_reason: - if pnl_pct >= self._brt_cfg.trailing_activation_pct: - if not pos.trailing_activated: - pos.trailing_activated = True - # ATR 기반 트레일링 - trail_pct = self.trailing_stop_pct # 기본 -4% - if atr_val and atr_val > 0: - atr_trail = -(atr_val * self._brt_cfg.trailing_atr_mult) / entry_price - trail_pct = max(atr_trail, self.trailing_stop_pct) - trailing_stop_price = pos.highest_price * (1 + trail_pct) - if current_price <= trailing_stop_price: - exit_reason = "ES3 트레일링스탑" - exit_type = "TRAILING_STOP" - - # ES_ZONE_BREAK: 리테스트 존 무효화 (존 하단 이탈 시 청산) - if not exit_reason and code in self._breakout_states: - brt_state = self._breakout_states[code] - zone_bottom = brt_state.get("zone_bottom", 0) - if zone_bottom > 0 and current_price < zone_bottom: - exit_reason = "ES_ZONE_BREAK 존 무효화" - exit_type = "ZONE_BREAK" - - # ES5: 보유기간 초과 - max_hold = min(self._brt_cfg.max_holding_days, regime_exit["max_holding"]) - if not exit_reason and pos.days_held > max_hold: - exit_reason = "ES5 보유기간 초과" - exit_type = "MAX_HOLDING" - - # ES7: 리밸런스 청산 (PnL 게이트: 수익 중이면 유예) - if not exit_reason and code in self._rebalance_exit_codes: - if pos.days_held < 3 or pnl_pct <= -0.02: - exit_reason = "ES7 리밸런스 청산" - exit_type = "REBALANCE_EXIT" - self._rebalance_exit_codes.discard(code) - elif pnl_pct > 0.02: - # 수익 +2%+ → 다음 리밸런스까지 유예 - self._rebalance_exit_codes.discard(code) - else: - exit_reason = "ES7 리밸런스 청산" - exit_type = "REBALANCE_EXIT" - self._rebalance_exit_codes.discard(code) - - if exit_reason: - to_close.append(code) - # Phase 통계 - exit_stat_map = { - "EMERGENCY_STOP": "es0_emergency_stop", - "STOP_LOSS": "es1_stop_loss", - "ATR_STOP_LOSS": "es_brt_sl", - "ATR_TAKE_PROFIT": "es_brt_tp", - "CHOCH_EXIT": "es_choch_exit", - "TRAILING_STOP": "es3_trailing_stop", - "ZONE_BREAK": "es_zone_break", - "MAX_HOLDING": "es5_max_holding", - "REBALANCE_EXIT": "es7_rebalance_exit", - } - stat_key = exit_stat_map.get(exit_type or "") - if stat_key and stat_key in self._phase_stats: - self._phase_stats[stat_key] += 1 - self._execute_sell(pos, current_price, exit_reason, exit_type or "") - else: - # 트레일링 최고가 갱신 - if current_price > pos.highest_price: - pos.highest_price = current_price + # ══════════════════════════════════════════ + # SMC 청산 로직 + # ══════════════════════════════════════════ - for code in to_close: - del self.positions[code] + def _check_exits_smc(self): + from simulation.strategies.smc import check_exits + check_exits(self) # ══════════════════════════════════════════ - # Arbitrage: Statistical Pairs (Long+Short 양방향) - # 이론 참조: futuresStrategy.md (Z-Score), BlackScholesEquation.md (IV/RV), - # future_trading_stratedy.md (Dynamic ATR), Kelly Criterion.md + # Mean Reversion 지표/진입/청산 로직 # ══════════════════════════════════════════ - def _discover_pairs(self) -> List[Dict]: - """ - 워치리스트 내 페어 자동 발견 — v2. - 동일 섹터 우선 + 크로스섹터 허용 (BUG-7). - Phase Stats 누적 (BUG-1), 쿨다운 중 페어 스킵 (BUG-3). - """ - import itertools + def _calculate_indicators_mean_reversion(self, df: pd.DataFrame) -> pd.DataFrame: + from simulation.strategies.mean_reversion import calculate_indicators_mean_reversion + return calculate_indicators_mean_reversion(self, df) - cfg = self._arb_cfg - watchlist = self._watchlist - pairs: List[Dict] = [] + def _score_mr_signal(self, df: pd.DataFrame) -> int: + from simulation.strategies.mean_reversion import score_mr_signal + return score_mr_signal(self, df) - # v2: 쿨다운 중 페어 키 집합 (BUG-3) - cooldown_keys = set(self._arb_pair_cooldown.keys()) + def _score_mr_volatility(self, df: pd.DataFrame) -> int: + from simulation.strategies.mean_reversion import score_mr_volatility + return score_mr_volatility(self, df) - # ── 후보 조합 생성 ── - combos: List[tuple] = [] + def _score_mr_confirmation(self, df: pd.DataFrame) -> int: + from simulation.strategies.mean_reversion import score_mr_confirmation + return score_mr_confirmation(self, df) - # 1) 동일 섹터 내 조합 (우선) - sector_map: Dict[str, List[Dict]] = {} - for w in watchlist: - sector = w.get("sector", "") - if sector: - sector_map.setdefault(sector, []).append(w) + def _scan_entries_mean_reversion(self): + from simulation.strategies.mean_reversion import scan_entries + scan_entries(self) - for sector, stocks in sector_map.items(): - if len(stocks) < 2: - continue - for w_a, w_b in itertools.combinations(stocks, 2): - combos.append((w_a, w_b, sector)) - - # 2) 크로스섹터 조합 (v2 BUG-7) - if cfg.cross_sector_pairs: - all_stocks = [w for w in watchlist if w.get("sector", "")] - for w_a, w_b in itertools.combinations(all_stocks, 2): - if w_a.get("sector", "") != w_b.get("sector", ""): - combos.append((w_a, w_b, "cross")) - - # v2: 누적 통계 (BUG-1) — 리셋하지 않고 += 누적 - self._phase_stats["arb_pairs_scanned"] = ( - self._phase_stats.get("arb_pairs_scanned", 0) + len(combos) - ) + def _check_exits_mean_reversion(self): + from simulation.strategies.mean_reversion import check_exits + check_exits(self) - for w_a, w_b, sector_label in combos: - code_a, code_b = w_a["code"], w_b["code"] + # ══════════════════════════════════════════ + # Breakout-Retest 지표/진입/청산 로직 + # (extracted to simulation/strategies/breakout_retest.py) + # ══════════════════════════════════════════ - # v2: 쿨다운 중 페어 스킵 (BUG-3) - pair_key = f"{min(code_a,code_b)}-{max(code_a,code_b)}" - if pair_key in cooldown_keys: - continue + def _calculate_indicators_breakout_retest(self, df: pd.DataFrame) -> pd.DataFrame: + from simulation.strategies.breakout_retest import calculate_indicators_breakout_retest + return calculate_indicators_breakout_retest(self, df) - df_a = self._ohlcv_cache.get(code_a) - df_b = self._ohlcv_cache.get(code_b) + def _score_brt_structure(self, df: pd.DataFrame) -> int: + from simulation.strategies.breakout_retest import score_brt_structure + return score_brt_structure(self, df) - if df_a is None or df_b is None or len(df_a) < cfg.correlation_lookback or len(df_b) < cfg.correlation_lookback: - continue + def _score_brt_volatility(self, df: pd.DataFrame) -> int: + from simulation.strategies.breakout_retest import score_brt_volatility + return score_brt_volatility(self, df) - # 날짜 정렬 후 최근 N일 종가 추출 - close_a = df_a["close"].astype(float).tail(cfg.correlation_lookback) - close_b = df_b["close"].astype(float).tail(cfg.correlation_lookback) + def _score_brt_obv(self, df: pd.DataFrame) -> int: + from simulation.strategies.breakout_retest import score_brt_obv + return score_brt_obv(self, df) - if len(close_a) != len(close_b): - min_len = min(len(close_a), len(close_b)) - close_a = close_a.tail(min_len).reset_index(drop=True) - close_b = close_b.tail(min_len).reset_index(drop=True) + def _score_brt_momentum(self, df: pd.DataFrame) -> int: + from simulation.strategies.breakout_retest import score_brt_momentum + return score_brt_momentum(self, df) - if len(close_a) < 30: - continue + def _check_brt_six_conditions(self, df: pd.DataFrame) -> tuple: + from simulation.strategies.breakout_retest import check_brt_six_conditions + return check_brt_six_conditions(self, df) - # 상관계수 체크 (v2: 0.60 threshold) - corr = close_a.corr(close_b) - if pd.isna(corr) or corr < cfg.correlation_min: - self._phase_stats["arb_correlation_rejects"] = ( - self._phase_stats.get("arb_correlation_rejects", 0) + 1 - ) - continue + def _apply_brt_fakeout_filters(self, df: pd.DataFrame) -> tuple: + from simulation.strategies.breakout_retest import apply_brt_fakeout_filters + return apply_brt_fakeout_filters(self, df) - # 스프레드 계산: log(price_A / price_B) - spread = np.log(close_a.values / close_b.values) - spread = spread[~np.isnan(spread)] - if len(spread) < cfg.zscore_lookback: - continue + def _capture_brt_retest_zones(self, df: pd.DataFrame, breakout_price: float, breakout_atr: float) -> Dict[str, Any]: + from simulation.strategies.breakout_retest import capture_brt_retest_zones + return capture_brt_retest_zones(self, df, breakout_price, breakout_atr) - # 반감기(half-life) 계산: OLS Δspread ~ spread_lag - spread_lag = spread[:-1] - spread_diff = np.diff(spread) - if len(spread_lag) < 10 or np.std(spread_lag) < 1e-10: - continue + def _scan_entries_breakout_retest(self): + from simulation.strategies.breakout_retest import scan_entries + scan_entries(self) - try: - beta = np.cov(spread_diff, spread_lag)[0, 1] / np.var(spread_lag) - if beta >= 0: # 비수렴 - continue - halflife = -np.log(2) / beta - if halflife > cfg.halflife_max or halflife < 1: - continue - except (ValueError, ZeroDivisionError): - continue + def _score_brt_retest_zone(self, df: pd.DataFrame, state: Dict[str, Any]) -> int: + from simulation.strategies.breakout_retest import score_brt_retest_zone + return score_brt_retest_zone(self, df, state) - # 스프레드 통계 - spread_mean = float(np.mean(spread)) - spread_std = float(np.std(spread)) - if spread_std < 1e-10: - continue + def _check_exits_breakout_retest(self): + from simulation.strategies.breakout_retest import check_exits + check_exits(self) - current_zscore = (spread[-1] - spread_mean) / spread_std - # 중복 페어 방지 (동일 섹터에서 이미 발견된 경우) - existing_keys = {f"{min(p['code_a'],p['code_b'])}-{max(p['code_a'],p['code_b'])}" for p in pairs} - if pair_key in existing_keys: - continue + # ══════════════════════════════════════════ + # Arbitrage: Statistical Pairs (Long+Short 양방향) + # 이론 참조: futuresStrategy.md (Z-Score), BlackScholesEquation.md (IV/RV), + # future_trading_stratedy.md (Dynamic ATR), Kelly Criterion.md + # ══════════════════════════════════════════ - pairs.append({ - "code_a": code_a, - "code_b": code_b, - "name_a": w_a["name"], - "name_b": w_b["name"], - "sector": sector_label, - "correlation": round(float(corr), 4), - "halflife": round(float(halflife), 1), - "spread_mean": spread_mean, - "spread_std": spread_std, - "current_zscore": round(float(current_zscore), 3), - "spread_series": spread, - "close_a": close_a, - "close_b": close_b, - }) - - # v2: 누적 (BUG-1) - self._phase_stats["arb_spreads_detected"] = ( - self._phase_stats.get("arb_spreads_detected", 0) + len(pairs) - ) - return pairs + def _discover_pairs(self) -> List[Dict]: + from simulation.strategies.arbitrage import discover_pairs + return discover_pairs(self) def _load_fixed_pairs(self) -> List[Dict]: - """ - v5: 설정 기반 고정 ETF 페어 로드. - _discover_pairs() 대체. correlation_min 필터링 없음 (사전 검증된 페어). - """ - cfg = self._arb_cfg - pairs: List[Dict] = [] - cooldown_keys = set(self._arb_pair_cooldown.keys()) - - for pair_def in self._arb_fixed_pair_defs: - code_a = pair_def.get("code_a", "") - code_b = pair_def.get("code_b", "") - if not code_a or not code_b: - continue - - # 쿨다운 체크 - pair_key = f"{min(code_a,code_b)}-{max(code_a,code_b)}" - if pair_key in cooldown_keys: - continue - - df_a = self._ohlcv_cache.get(code_a) - df_b = self._ohlcv_cache.get(code_b) - - if df_a is None or df_b is None: - continue - if len(df_a) < cfg.zscore_lookback or len(df_b) < cfg.zscore_lookback: - continue - - close_a = df_a["close"].astype(float).tail(cfg.correlation_lookback) - close_b = df_b["close"].astype(float).tail(cfg.correlation_lookback) - - min_len = min(len(close_a), len(close_b)) - if min_len < cfg.zscore_lookback: - continue - close_a = close_a.tail(min_len).reset_index(drop=True) - close_b = close_b.tail(min_len).reset_index(drop=True) - - # 상관계수 (참고용, 필터링 없음) - corr = close_a.corr(close_b) - if pd.isna(corr): - corr = 0.0 - - # 스프레드: log(price_A / price_B) - spread = np.log(close_a.values / close_b.values) - spread = spread[~np.isnan(spread)] - if len(spread) < cfg.zscore_lookback: - continue - - # 반감기 - spread_lag = spread[:-1] - spread_diff = np.diff(spread) - halflife = 10.0 # default - if len(spread_lag) >= 10 and np.std(spread_lag) > 1e-10: - try: - beta = np.cov(spread_diff, spread_lag)[0, 1] / np.var(spread_lag) - if beta < 0: - halflife = min(-np.log(2) / beta, cfg.halflife_max) - except (ValueError, ZeroDivisionError): - pass - - # 스프레드 통계 - spread_mean = float(np.mean(spread)) - spread_std = float(np.std(spread)) - if spread_std < 1e-10: - continue - - current_zscore = (spread[-1] - spread_mean) / spread_std - - pairs.append({ - "code_a": code_a, - "code_b": code_b, - "name_a": pair_def.get("name_a", code_a), - "name_b": pair_def.get("name_b", code_b), - "sector": pair_def.get("sector", "ETF"), - "correlation": round(float(corr), 4), - "halflife": round(float(halflife), 1), - "spread_mean": spread_mean, - "spread_std": spread_std, - "current_zscore": round(float(current_zscore), 3), - "spread_series": spread, - "close_a": close_a, - "close_b": close_b, - }) - - self._phase_stats["arb_fixed_pairs_loaded"] = ( - self._phase_stats.get("arb_fixed_pairs_loaded", 0) + len(pairs) - ) - self._phase_stats["arb_spreads_detected"] = ( - self._phase_stats.get("arb_spreads_detected", 0) + len(pairs) - ) - return pairs + from simulation.strategies.arbitrage import load_fixed_pairs + return load_fixed_pairs(self) def _check_basis_gate(self) -> bool: - """ - v5: 콘탱고/백워데이션 Basis Gate. - True = 차익거래 윈도우 OPEN, False = CLOSED. - - US: Basis = (ES=F − SPY) / SPY × 100 → Z-Score - KOSPI: ^KS200 실현변동성 Z-Score 프록시 - """ - cfg = self._arb_cfg - if not cfg.basis_gate_enabled: - self._arb_basis_window_open = True - return True - - if not self._arb_basis_signals: - # Basis signal 설정이 없으면 게이트 비활성 (항상 열림) - self._arb_basis_window_open = True - return True - - for sig in self._arb_basis_signals: - spot_code = sig.get("spot_code", "") - futures_code = sig.get("futures_code", "") - ma_period = sig.get("basis_ma_period", 20) - z_threshold = sig.get("basis_zscore_threshold", 1.5) - use_premium = sig.get("use_premium_estimate", False) - - if use_premium or not futures_code: - # KOSPI: 변동성 프록시 - spot_ticker = sig.get("spot_ticker", "") - # ^KS200 데이터는 spot_code 또는 spot_ticker로 조회 - df_spot = self._ohlcv_cache.get(spot_code) - if df_spot is None: - df_spot = self._ohlcv_cache.get(spot_ticker) - if df_spot is None or len(df_spot) < ma_period * 3: - # 데이터 부족 시 게이트 열기 (거래 허용) - self._arb_basis_window_open = True - return True - - close = df_spot["close"].astype(float) - returns = close.pct_change().dropna() - if len(returns) < ma_period: - self._arb_basis_window_open = True - return True - - # 실현 변동성 (연환산) - realized_vol = returns.rolling(ma_period).std() * np.sqrt(252) - realized_vol = realized_vol.dropna() - if len(realized_vol) < ma_period * 3: - self._arb_basis_window_open = True - return True - - vol_ma = realized_vol.rolling(ma_period * 3).mean() - vol_std = realized_vol.rolling(ma_period * 3).std() - vol_ma_last = vol_ma.iloc[-1] - vol_std_last = vol_std.iloc[-1] - - if pd.isna(vol_ma_last) or pd.isna(vol_std_last) or vol_std_last < 1e-10: - self._arb_basis_window_open = True - return True - - vol_zscore = (realized_vol.iloc[-1] - vol_ma_last) / vol_std_last - is_open = abs(float(vol_zscore)) > z_threshold - - self._arb_basis_data = { - "type": "volatility_proxy", - "realized_vol": round(float(realized_vol.iloc[-1]), 4), - "vol_zscore": round(float(vol_zscore), 3), - "threshold": z_threshold, - "window_open": is_open, - } - else: - # US: Basis = (Futures - Spot) / Spot × 100 - df_spot = self._ohlcv_cache.get(spot_code) - df_futures = self._ohlcv_cache.get(futures_code) - if df_spot is None or df_futures is None: - self._arb_basis_window_open = True - return True - if len(df_spot) < ma_period * 2 or len(df_futures) < ma_period * 2: - self._arb_basis_window_open = True - return True - - spot_close = df_spot["close"].astype(float) - fut_close = df_futures["close"].astype(float) - - # 날짜 정렬 보장: 길이 맞추기 - min_len = min(len(spot_close), len(fut_close)) - spot_close = spot_close.tail(min_len).reset_index(drop=True) - fut_close = fut_close.tail(min_len).reset_index(drop=True) - - # Basis 계산: (Futures - Spot) / Spot × 100 - # ES=F는 SPY의 약 10배이므로 스케일 조정 - basis = (fut_close - spot_close * 10) / (spot_close * 10) * 100 - - if len(basis) < ma_period: - self._arb_basis_window_open = True - return True - - basis_ma = basis.rolling(ma_period).mean() - basis_std = basis.rolling(ma_period).std() - - basis_ma_last = basis_ma.iloc[-1] - basis_std_last = basis_std.iloc[-1] - - if pd.isna(basis_ma_last) or pd.isna(basis_std_last) or basis_std_last < 1e-10: - self._arb_basis_window_open = True - return True - - basis_zscore = (basis.iloc[-1] - basis_ma_last) / basis_std_last - is_open = abs(float(basis_zscore)) > z_threshold - - self._arb_basis_data = { - "type": "futures_basis", - "basis_pct": round(float(basis.iloc[-1]), 4), - "basis_zscore": round(float(basis_zscore), 3), - "threshold": z_threshold, - "window_open": is_open, - } - - self._arb_basis_window_open = is_open - if is_open: - self._phase_stats["arb_basis_window_opens"] = ( - self._phase_stats.get("arb_basis_window_opens", 0) + 1 - ) - return is_open - - self._arb_basis_window_open = True - return True + from simulation.strategies.arbitrage import check_basis_gate + return check_basis_gate(self) def _score_arb_correlation(self, pair: Dict) -> int: - """ - Layer 1: 상관관계 품질 (0 ~ weight_correlation). - graduated scoring + 안정성 + 추세. - """ - cfg = self._arb_cfg - score = 0 - corr = pair["correlation"] - - # Graduated 상관계수 점수 - if corr > 0.85: - score += 20 - elif corr > 0.75: - score += 15 - elif corr > 0.70: - score += 10 - - # 상관관계 안정성: rolling 20일 std - close_a = pair["close_a"] - close_b = pair["close_b"] - if len(close_a) >= 30: - rolling_corr = close_a.rolling(20).corr(close_b).dropna() - if len(rolling_corr) > 5: - corr_std = float(rolling_corr.std()) - if corr_std < 0.1: - score += 10 - elif corr_std < 0.15: - score += 5 - - # 최근 5일 corr 상승 추세 - recent_corr = rolling_corr.tail(5) - if len(recent_corr) >= 5: - if float(recent_corr.iloc[-1]) > float(recent_corr.iloc[0]): - score += 5 - - return min(score, cfg.weight_correlation) + from simulation.strategies.arbitrage import score_arb_correlation + return score_arb_correlation(self, pair) def _score_arb_spread(self, pair: Dict) -> int: - """ - Layer 2: 스프레드 이탈도 (0 ~ weight_spread). - Z-Score graduated + half-life + IV/RV 비교 (BlackScholes 참조). - """ - cfg = self._arb_cfg - score = 0 - zscore = abs(pair["current_zscore"]) - - # Graduated Z-Score 점수 - if zscore > 2.5: - score += 20 - elif zscore > 2.0: - score += 15 - elif zscore > 1.5: - score += 10 - - # 반감기 보너스 (빠른 회귀 = 높은 점수) - halflife = pair["halflife"] - if halflife < 10: - score += 10 - elif halflife < 20: - score += 5 - - # IV vs RV 비교 (Black-Scholes 영감): 스프레드 실현변동성 분석 - spread_series = pair["spread_series"] - if len(spread_series) >= 60: - # 실현변동성 (RV): 최근 20일 vs 장기 60일 - recent_rv = float(np.std(spread_series[-20:])) - long_rv = float(np.std(spread_series[-60:])) - if long_rv > 0 and recent_rv > long_rv: - score += 5 # 변동성 확장 = 회귀 기회↑ - - # 스프레드 극단 횟수 (반복 패턴 = 높은 신뢰) - if len(spread_series) >= 60 and pair["spread_std"] > 0: - historical_z = (spread_series[-60:] - pair["spread_mean"]) / pair["spread_std"] - extreme_count = np.sum(np.abs(historical_z) > 1.5) - if extreme_count >= 3: - score += 5 - - return min(score, cfg.weight_spread) + from simulation.strategies.arbitrage import score_arb_spread + return score_arb_spread(self, pair) def _score_arb_volume(self, pair: Dict) -> int: - """ - Layer 3: 거래량 + EV 확인 (0 ~ weight_volume). - EV Engine (futuresStrategy.md): EV = P(W) × Avg.W - P(L) × Avg.L > 0 - """ - cfg = self._arb_cfg - score = 0 - - # 양쪽 종목 거래량 확인 - df_a = self._ohlcv_cache.get(pair["code_a"]) - df_b = self._ohlcv_cache.get(pair["code_b"]) - - if df_a is not None and df_b is not None and len(df_a) >= 20 and len(df_b) >= 20: - vol_a = float(df_a["volume"].iloc[-1]) - vol_b = float(df_b["volume"].iloc[-1]) - vol_ma_a = float(df_a["volume"].tail(20).mean()) - vol_ma_b = float(df_b["volume"].tail(20).mean()) - - # 양쪽 vol > MA20 - if vol_ma_a > 0 and vol_ma_b > 0: - if vol_a > vol_ma_a and vol_b > vol_ma_b: - score += 10 - elif vol_a > vol_ma_a or vol_b > vol_ma_b: - score += 5 - - # 이탈 방향 종목 거래량 급증 (1.5x) - zscore = pair["current_zscore"] - if zscore > 0 and vol_ma_a > 0 and vol_a > vol_ma_a * 1.5: - score += 5 # A가 고평가 → A에 거래량 급증 = 의미있는 이탈 - elif zscore < 0 and vol_ma_b > 0 and vol_b > vol_ma_b * 1.5: - score += 5 # B가 고평가 → B에 거래량 급증 - - # EV > 0 검증 (과거 유사 스프레드 회귀의 승률/손익비) - ev_positive = self._calculate_arb_ev(pair) - if ev_positive: - score += 10 - - return min(score, cfg.weight_volume) + from simulation.strategies.arbitrage import score_arb_volume + return score_arb_volume(self, pair) def _calculate_arb_ev(self, pair: Dict) -> bool: - """ - Expected Value 검증 (futuresStrategy.md). - EV = P(W) × Avg.W - P(L) × Avg.L - 과거 스프레드 데이터에서 유사 Z-Score 진입의 가상 결과를 시뮬레이션. - """ - spread_series = pair["spread_series"] - if len(spread_series) < 60: - return True # 데이터 부족 시 통과 (보수적) - - mean = pair["spread_mean"] - std = pair["spread_std"] - if std < 1e-10: - return True - - zscore_series = (spread_series - mean) / std - entry_threshold = self._arb_cfg.zscore_entry - exit_threshold = self._arb_cfg.zscore_exit - - wins = [] - losses = [] - - i = 0 - while i < len(zscore_series) - 5: - z = zscore_series[i] - if abs(z) >= entry_threshold: - # 진입 시뮬레이션: 이후 5~20일 내 |z| < exit_threshold 도달 여부 - direction = -1 if z > 0 else 1 # z>0이면 축소 베팅 - for j in range(1, min(21, len(zscore_series) - i)): - future_z = zscore_series[i + j] - pnl_z = direction * (z - future_z) # Z-Score 변화량 - if abs(future_z) < exit_threshold: - wins.append(float(pnl_z)) - break - else: - # 20일 내 미회귀 = 손실 - pnl_z = direction * (z - zscore_series[min(i + 20, len(zscore_series) - 1)]) - if pnl_z > 0: - wins.append(float(pnl_z)) - else: - losses.append(float(abs(pnl_z))) - i += 10 # 겹치지 않게 점프 - else: - i += 1 - - if not wins and not losses: - return True # 데이터 부족 - - total = len(wins) + len(losses) - if total < 3: - return True # 충분하지 않으면 통과 - - p_win = len(wins) / total - avg_win = sum(wins) / len(wins) if wins else 0 - avg_loss = sum(losses) / len(losses) if losses else 0 - p_loss = 1 - p_win - - ev = p_win * avg_win - p_loss * avg_loss - return ev > 0 + from simulation.strategies.arbitrage import calculate_arb_ev + return calculate_arb_ev(self, pair) def _size_arb_pair(self, price_a: float, price_b: float, score: int) -> tuple: - """ - v2: 페어 전용 dollar-neutral 사이징 (BUG-6). - 양쪽 동일 금액 기준. 스코어 기반 승수. - Returns: (qty_a, qty_b) - """ - equity = self._get_total_equity() - max_alloc = equity * self._arb_cfg.max_weight_per_pair # 10% - half_alloc = max_alloc / 2 # 각 leg 5% - - # 스코어 기반 사이징 (0.7~1.2) - score_mult = 0.7 + (score - 60) / 100.0 * 0.5 # 60점=0.7, 100점=0.9 - score_mult = max(0.7, min(score_mult, 1.2)) - - alloc = half_alloc * score_mult - - # 현금 제약: 최소 현금 비율 유지 - min_cash = equity * self.min_cash_ratio - available = self.cash - min_cash - if available <= 0: - return (0, 0) - # Long 매수 + Short 마진(50%) 필요 금액 - total_needed = alloc + alloc * 0.5 # Long full + Short margin - if total_needed > available: - alloc = available / 1.5 # 역산 - - qty_a = max(1, int(alloc / price_a)) if price_a > 0 else 0 - qty_b = max(1, int(alloc / price_b)) if price_b > 0 else 0 - return (qty_a, qty_b) + from simulation.strategies.arbitrage import size_arb_pair + return size_arb_pair(self, price_a, price_b, score) def _scan_entries_arbitrage(self): - """ - Statistical Pairs Arbitrage 양방향 진입 스캔 — v5. - Z-Score 기반 통계적 진입 (futuresStrategy.md 참조). - Long + Short 동시 진입. - v2: 쿨다운 차감, dollar-neutral 사이징, 진입 시 entry_z 저장. - v3: 워밍업 버퍼, ARB MDD 서킷브레이커 (-10%), 최소 보유일. - v4: MDD 복구, 진입 완화, 페어 재발견 3일, 쿨다운 5일. - v5: Fixed ETF Pair Mode + Basis Gate (콘탱고/백워데이션). - """ - cfg = self._arb_cfg - - # ── v2: 쿨다운 차감 (BUG-3) ── - expired_keys = [] - for key in list(self._arb_pair_cooldown.keys()): - self._arb_pair_cooldown[key] -= 1 - if self._arb_pair_cooldown[key] <= 0: - expired_keys.append(key) - for key in expired_keys: - del self._arb_pair_cooldown[key] - - # ── v3: 워밍업 버퍼 (첫 N거래일 진입 금지) ── - self._arb_day_count += 1 - if self._arb_day_count <= cfg.warmup_buffer_days: - return - - # ── v4: ARB MDD 서킷브레이커 + 복구 메커니즘 ── - equity = self._get_total_equity() - if hasattr(self, '_peak_equity') and self._peak_equity > 0: - mdd_pct = (equity - self._peak_equity) / self._peak_equity - if mdd_pct <= -cfg.arb_mdd_limit: - # MDD 한도 초과 → 신규 진입 차단 - if not self._arb_mdd_halted: - self._arb_mdd_halted = True - self._arb_mdd_halt_days = 0 - self._arb_mdd_halt_days += 1 - # v4: 시간 기반 자동 복구 (halt_max_days 초과 시 재개) - halt_max = getattr(cfg, 'arb_mdd_halt_max_days', 20) - if self._arb_mdd_halt_days >= halt_max: - # 현재 equity를 새 baseline으로 설정 (peak 리셋) - self._peak_equity = equity - self._arb_mdd_halted = False - self._arb_mdd_halt_days = 0 - else: - return - elif self._arb_mdd_halted: - self._arb_mdd_halt_days += 1 - # v4: DD가 recovery 수준(-5%)으로 회복 OR 시간 초과 - recovery_threshold = getattr(cfg, 'arb_mdd_recovery', 0.05) - halt_max = getattr(cfg, 'arb_mdd_halt_max_days', 20) - if mdd_pct > -recovery_threshold or self._arb_mdd_halt_days >= halt_max: - if self._arb_mdd_halt_days >= halt_max: - self._peak_equity = equity - self._arb_mdd_halted = False - self._arb_mdd_halt_days = 0 - else: - return # 아직 recovery 미달 & 시간 미초과 → 계속 차단 - - # ── v5: Basis Gate (콘탱고/백워데이션 체크) ── - if not self._check_basis_gate(): - self._phase_stats["arb_basis_gate_blocks"] = ( - self._phase_stats.get("arb_basis_gate_blocks", 0) + 1 - ) - return - - # ── Phase 0: 시장 체제 판단 ── - self._update_market_regime() - - # ── Phase 4: 리스크 게이트 ── - gate_pass, gate_reason = self._risk_gate_check() - if not gate_pass: - return - - # 현재 활성 페어 수 체크 - active_pair_ids = set() - for pos in self.positions.values(): - if pos.status == "ACTIVE" and pos.pair_id: - active_pair_ids.add(pos.pair_id) - if len(active_pair_ids) >= cfg.max_pairs: - return - - # 이미 보유 중인 종목 코드 - held_codes = set(self.positions.keys()) - - # v5: 페어 발견 — use_fixed_pairs 분기 - rediscovery_days = getattr(cfg, 'pair_rediscovery_days', 3) - current_date = self._get_current_date_str() - days_since_discovery = 999 - if self._arb_last_discovery and current_date: - try: - from datetime import datetime as _dt - d1 = _dt.strptime(self._arb_last_discovery.replace("-", "")[:8], "%Y%m%d") - d2 = _dt.strptime(current_date.replace("-", "")[:8], "%Y%m%d") - days_since_discovery = (d2 - d1).days - except ValueError: - days_since_discovery = 999 - - if not self._arb_pairs or days_since_discovery >= rediscovery_days: - if cfg.use_fixed_pairs and self._arb_fixed_pair_defs: - self._arb_pairs = self._load_fixed_pairs() - else: - self._arb_pairs = self._discover_pairs() - self._arb_last_discovery = current_date - - if not self._arb_pairs: - return - - # 스캔 누적 (BUG-1) - self._phase_stats["total_scans"] = self._phase_stats.get("total_scans", 0) + 1 - - # 각 페어 스코어링 및 진입 - for pair in self._arb_pairs: - if len(active_pair_ids) >= cfg.max_pairs: - break - - code_a = pair["code_a"] - code_b = pair["code_b"] - - # 이미 보유 중인 종목은 스킵 - if code_a in held_codes or code_b in held_codes: - continue - - # v4: 실시간 Z-Score 재계산 (페어 발견 시점 값이 아닌 현재 가격 기준) - df_a = self._ohlcv_cache.get(code_a) - df_b = self._ohlcv_cache.get(code_b) - if df_a is not None and df_b is not None and len(df_a) >= cfg.zscore_lookback and len(df_b) >= cfg.zscore_lookback: - close_a = df_a["close"].astype(float).tail(cfg.correlation_lookback) - close_b = df_b["close"].astype(float).tail(cfg.correlation_lookback) - min_len = min(len(close_a), len(close_b)) - if min_len >= cfg.zscore_lookback: - close_a = close_a.tail(min_len).reset_index(drop=True) - close_b = close_b.tail(min_len).reset_index(drop=True) - spread = np.log(close_a.values / close_b.values) - spread_recent = spread[-cfg.zscore_lookback:] - s_mean = float(np.mean(spread_recent)) - s_std = float(np.std(spread_recent)) - if s_std > 1e-10: - zscore = (spread[-1] - s_mean) / s_std - else: - zscore = 0.0 - else: - zscore = pair.get("current_zscore", 0) - else: - zscore = pair.get("current_zscore", 0) - - # Z-Score 임계값 미달 → 스킵 - if abs(zscore) < cfg.zscore_entry: - continue - - # 3-Layer 스코어링 - score_l1 = self._score_arb_correlation(pair) - score_l2 = self._score_arb_spread(pair) - score_l3 = self._score_arb_volume(pair) - total_score = score_l1 + score_l2 + score_l3 - - self._phase_stats["arb_total_score"] = ( - self._phase_stats.get("arb_total_score", 0) + total_score - ) - - # v5: 고정 페어 모드 → etf_entry_threshold 사용 - threshold = cfg.etf_entry_threshold if cfg.use_fixed_pairs else cfg.entry_threshold - if total_score < threshold: - continue - - # ── 양방향 진입 결정 ── - pair_id = f"arb-{code_a}-{code_b}-{current_date}" - - if zscore > 0: - # Z > 0: A 고평가 → Short A, B 저평가 → Long B - long_code, long_name = code_b, pair["name_b"] - short_code, short_name = code_a, pair["name_a"] - else: - # Z < 0: A 저평가 → Long A, B 고평가 → Short B - long_code, long_name = code_a, pair["name_a"] - short_code, short_name = code_b, pair["name_b"] - - # Long leg 가격 - long_price = self._current_prices.get(long_code) - short_price = self._current_prices.get(short_code) - if not long_price or not short_price: - continue - - # v2: dollar-neutral 사이징 (BUG-6) - qty_long, qty_short = self._size_arb_pair(long_price, short_price, total_score) - if qty_long <= 0 or qty_short <= 0: - continue - - now = self._get_current_iso() - - # Long leg 시그널 생성 + 매수 - self._signal_counter += 1 - long_signal = SimSignal( - id=f"sim-sig-{self._signal_counter:04d}", - stock_code=long_code, - stock_name=long_name, - type="BUY", - price=long_price, - reason=f"ARB L1:{score_l1} L2:{score_l2} L3:{score_l3} Z:{zscore:+.2f}", - strength=total_score, - detected_at=now, - ) - self.signals.append(long_signal) - if len(self.signals) > 100: - self.signals = self.signals[-100:] - - # v2: 통일 사이징으로 매수 (BUG-6) - self._execute_buy_arb(long_signal, qty_long, pair_id) - - # Short leg 시그널 생성 + 공매도 진입 - self._signal_counter += 1 - short_signal = SimSignal( - id=f"sim-sig-{self._signal_counter:04d}", - stock_code=short_code, - stock_name=short_name, - type="SELL_SHORT", - price=short_price, - reason=f"ARB L1:{score_l1} L2:{score_l2} L3:{score_l3} Z:{zscore:+.2f}", - strength=total_score, - detected_at=now, - ) - self.signals.append(short_signal) - if len(self.signals) > 100: - self.signals = self.signals[-100:] - - # v2: 통일 사이징으로 공매도 (BUG-6) - self._execute_sell_short(short_signal, pair_id, qty_short) - - # v2: 진입 시 entry_z 저장 (방향성 청산용, BUG-5) - self._arb_pair_states[pair_id] = { - "entry_z": zscore, - "code_a": code_a, - "code_b": code_b, - "entry_date": current_date, - "initial_corr": pair["correlation"], - } - - self._phase_stats["arb_entries"] = self._phase_stats.get("arb_entries", 0) + 1 - active_pair_ids.add(pair_id) - held_codes.add(long_code) - held_codes.add(short_code) - - self._add_risk_event( - "INFO", - f"ARB 페어 진입: {long_name}(L) + {short_name}(S) | Z={zscore:+.2f} Score={total_score}", - ) + from simulation.strategies.arbitrage import scan_entries + scan_entries(self) def _check_exits_arbitrage(self): - """ - Arbitrage 양방향 청산 로직 — v4. - 우선순위 재정렬 (BUG-2), 방향성 Z-Score (BUG-5), - 상관관계 2일 연속 확인 (BUG-4), 청산 시 쿨다운 등록 (BUG-3). - v3: Z-Score TP 최소 보유일 3일 (조기 청산 방지). - - Exit Priority: - 1. ES1: -5% 하드 손절 (Long/Short 각각 명시) — BUG-2 - 2. ES_ARB_SL: Dynamic ATR SL - 3. ES_ARB_TP: 방향성 Z-Score 청산 (z 부호 전환, v3: 최소 3일 보유) — BUG-5 - 4. ES_ARB_CORR: 상관관계 35% 하락 + 2일 연속 — BUG-4 - 5. ES3: 트레일링 (5% 활성화) - 6. ES5: 최대 보유 20일 - """ - cfg = self._arb_cfg - to_close: List[str] = [] - pair_close_reasons: Dict[str, str] = {} # pair_id → 청산 사유 - - for code, pos in self.positions.items(): - if pos.status != "ACTIVE": - continue - - current_price = pos.current_price - entry_price = pos.entry_price - - # PnL 계산 (side 별) - if pos.side == "SHORT": - pnl_pct = (entry_price - current_price) / entry_price - else: - pnl_pct = (current_price - entry_price) / entry_price - - exit_reason = None - - # ══ 순위 1: ES1 -5% 하드 손절 (BUG-2: 최우선, Long/Short 각각 명시) ══ - if pos.side == "SHORT": - # Short: 가격이 진입가 대비 5% 상승하면 손절 - if current_price >= entry_price * 1.05: - actual_loss = (entry_price - current_price) / entry_price - exit_reason = f"ES1 Short 하드 손절 ({actual_loss*100:+.1f}%)" - self._phase_stats["es_arb_sl"] = self._phase_stats.get("es_arb_sl", 0) + 1 - else: - # Long: 가격이 진입가 대비 5% 하락하면 손절 - if current_price <= entry_price * 0.95: - actual_loss = (current_price - entry_price) / entry_price - exit_reason = f"ES1 Long 하드 손절 ({actual_loss*100:+.1f}%)" - self._phase_stats["es_arb_sl"] = self._phase_stats.get("es_arb_sl", 0) + 1 - - # ══ 순위 2: ES_ARB_SL Dynamic ATR Stop ══ - if not exit_reason: - df = self._ohlcv_cache.get(code) - if df is not None and len(df) > 14: - if "atr" not in df.columns: - df = self._calculate_indicators(df.copy()) - self._ohlcv_cache[code] = df - last_atr = df.iloc[-1].get("atr") - last_adx = df.iloc[-1].get("adx") - if pd.notna(last_atr) and float(last_atr) > 0: - atr_val = float(last_atr) - adx_val = float(last_adx) if pd.notna(last_adx) else 0 - sl_mult = cfg.atr_sl_mult_strong if adx_val >= cfg.adx_dynamic_threshold else cfg.atr_sl_mult - - if pos.side == "SHORT": - atr_stop = entry_price + atr_val * sl_mult - # ATR 스탑이 하드 손절보다 넓으면 하드 손절 우선 (이미 체크됨) - if current_price >= atr_stop and current_price < entry_price * 1.05: - exit_reason = f"ES_ARB_SL Short ATR×{sl_mult} ({pnl_pct*100:+.1f}%)" - self._phase_stats["es_arb_sl"] = self._phase_stats.get("es_arb_sl", 0) + 1 - else: - atr_stop = entry_price - atr_val * sl_mult - if current_price <= atr_stop and current_price > entry_price * 0.95: - exit_reason = f"ES_ARB_SL Long ATR×{sl_mult} ({pnl_pct*100:+.1f}%)" - self._phase_stats["es_arb_sl"] = self._phase_stats.get("es_arb_sl", 0) + 1 - - # ══ 순위 3: ES_ARB_TP 방향성 Z-Score 청산 (BUG-5, v3: 최소 보유일) ══ - if not exit_reason and pos.pair_id: - # v3: 최소 보유일 미달 시 Z-Score TP 스킵 - days_held = getattr(pos, 'days_held', 0) or 0 - skip_zscore_tp = days_held < cfg.min_hold_days_for_tp - - for pair in self._arb_pairs: - pair_codes = {pair["code_a"], pair["code_b"]} - if code in pair_codes: - # 실시간 Z-Score 재계산 - df_a = self._ohlcv_cache.get(pair["code_a"]) - df_b = self._ohlcv_cache.get(pair["code_b"]) - if df_a is not None and df_b is not None: - close_a = df_a["close"].astype(float).tail(cfg.correlation_lookback) - close_b = df_b["close"].astype(float).tail(cfg.correlation_lookback) - min_len = min(len(close_a), len(close_b)) - if min_len >= cfg.zscore_lookback: - close_a = close_a.tail(min_len).reset_index(drop=True) - close_b = close_b.tail(min_len).reset_index(drop=True) - spread = np.log(close_a.values / close_b.values) - spread_recent = spread[-cfg.zscore_lookback:] - if len(spread_recent) > 0: - s_mean = float(np.mean(spread_recent)) - s_std = float(np.std(spread_recent)) - if s_std > 1e-10: - current_z = (spread[-1] - s_mean) / s_std - - # pair_state는 TP/CORR 양쪽에서 사용 - pair_state = self._arb_pair_states.get(pos.pair_id, {}) - - # v2: 방향성 Z-Score 청산 (BUG-5) - # v3: 최소 보유일(min_hold_days_for_tp) 미달 시 스킵 - if not skip_zscore_tp: - entry_z = pair_state.get("entry_z", 0) - - if entry_z > 0 and current_z <= cfg.zscore_exit: - # 진입 Z>+2 → 스프레드 축소 기대 → z<0.2 이면 청산 - exit_reason = f"ES_ARB_TP Z 방향성 청산 (entry_z={entry_z:+.1f} → z={current_z:.2f})" - self._phase_stats["es_arb_tp"] = self._phase_stats.get("es_arb_tp", 0) + 1 - if pos.pair_id: - pair_close_reasons[pos.pair_id] = exit_reason - elif entry_z < 0 and current_z >= -cfg.zscore_exit: - # 진입 Z<-2 → 스프레드 확대 기대 → z>-0.2 이면 청산 - exit_reason = f"ES_ARB_TP Z 방향성 청산 (entry_z={entry_z:+.1f} → z={current_z:.2f})" - self._phase_stats["es_arb_tp"] = self._phase_stats.get("es_arb_tp", 0) + 1 - if pos.pair_id: - pair_close_reasons[pos.pair_id] = exit_reason - - # ══ 순위 4: ES_ARB_CORR 상관관계 35% 하락 + 2일 연속 (BUG-4) ══ - if not exit_reason: - current_corr = close_a.corr(close_b) - initial_corr = pair_state.get("initial_corr", pair["correlation"]) - if pd.notna(current_corr) and initial_corr > 0: - corr_decay = (initial_corr - current_corr) / initial_corr - if corr_decay >= cfg.correlation_decay_exit: - # v2: 2일 연속 확인 (BUG-4) - decay_key = pos.pair_id or code - self._arb_corr_decay_count[decay_key] = ( - self._arb_corr_decay_count.get(decay_key, 0) + 1 - ) - if self._arb_corr_decay_count[decay_key] >= cfg.corr_decay_confirm_days: - exit_reason = f"ES_ARB_CORR 상관관계 붕괴 {cfg.corr_decay_confirm_days}일 연속 ({corr_decay*100:.0f}%↓)" - self._phase_stats["es_arb_corr"] = self._phase_stats.get("es_arb_corr", 0) + 1 - if pos.pair_id: - pair_close_reasons[pos.pair_id] = exit_reason - else: - # 붕괴 조건 미달 → 카운트 리셋 - decay_key = pos.pair_id or code - self._arb_corr_decay_count[decay_key] = 0 - break - - # ══ 순위 5: ES3 트레일링 ══ - if not exit_reason: - if pos.side == "SHORT": - if pnl_pct >= cfg.trailing_activation_pct: - pos.trailing_activated = True - if pos.trailing_activated and pos.lowest_price > 0: - trail_pct = 0.04 - trail_stop = pos.lowest_price * (1 + trail_pct) - if current_price >= trail_stop: - exit_reason = f"ES3 Short 트레일링 ({pnl_pct*100:+.1f}%)" - else: - if pnl_pct >= cfg.trailing_activation_pct: - pos.trailing_activated = True - if pos.trailing_activated: - trail_pct = 0.04 - trail_stop = pos.highest_price * (1 - trail_pct) - if current_price <= trail_stop: - exit_reason = f"ES3 Long 트레일링 ({pnl_pct*100:+.1f}%)" - - # ══ 순위 6: ES5 최대 보유 ══ - if not exit_reason and pos.days_held >= cfg.max_holding_days: - exit_reason = f"ES5 최대 보유 {cfg.max_holding_days}일 ({pnl_pct*100:+.1f}%)" - - if exit_reason: - exit_type = "STOP_LOSS" if pnl_pct < 0 else "TAKE_PROFIT" - self._execute_sell(pos, current_price, exit_reason, exit_type) - to_close.append(code) - if pos.pair_id: - pair_close_reasons.setdefault(pos.pair_id, exit_reason) - # v2: 청산 시 쿨다운 등록 (BUG-3) - pair_state = self._arb_pair_states.get(pos.pair_id, {}) - cd_a = pair_state.get("code_a", "") - cd_b = pair_state.get("code_b", "") - if cd_a and cd_b: - cooldown_key = f"{min(cd_a,cd_b)}-{max(cd_a,cd_b)}" - self._arb_pair_cooldown[cooldown_key] = cfg.pair_cooldown_days - else: - # highest/lowest 갱신 - if pos.side == "SHORT": - if pos.lowest_price <= 0 or current_price < pos.lowest_price: - pos.lowest_price = current_price - else: - if current_price > pos.highest_price: - pos.highest_price = current_price - - # 페어 동시 청산: 한쪽이 청산되면 반대쪽도 청산 - for pair_id, reason in pair_close_reasons.items(): - for code, pos in self.positions.items(): - if code in to_close: - continue - if pos.pair_id == pair_id and pos.status == "ACTIVE": - paired_reason = f"페어 동시 청산 ({reason})" - pnl_pct = (pos.entry_price - pos.current_price) / pos.entry_price if pos.side == "SHORT" else (pos.current_price - pos.entry_price) / pos.entry_price - exit_type = "STOP_LOSS" if pnl_pct < 0 else "TAKE_PROFIT" - self._execute_sell(pos, pos.current_price, paired_reason, exit_type) - to_close.append(code) + from simulation.strategies.arbitrage import check_exits + check_exits(self) - for code in to_close: - if code in self.positions: - del self.positions[code] def _execute_buy( self, @@ -4383,193 +1519,8 @@ def _check_exits_multi(self): self.strategy_mode = original_mode def _check_exits_momentum(self): - """기존 Momentum Swing 청산 로직.""" - to_close: List[str] = [] - - for code, pos in self.positions.items(): - if pos.status != "ACTIVE": - continue - if self._exit_tag_filter and pos.strategy_tag != self._exit_tag_filter: - continue - # 글로벌 레짐 기반 청산 파라미터 (종목별 레짐은 analytics용) - regime_exit = REGIME_EXIT_PARAMS.get(self._market_regime, REGIME_EXIT_PARAMS["NEUTRAL"]) - - current_price = self._current_prices.get(code, pos.current_price) - entry_price = pos.entry_price - pnl_pct = (current_price - entry_price) / entry_price - - exit_reason = None - exit_type = None - - # ATR 기반 프로그레시브 트레일링 폭 사전 계산 - atr_pct_val = 0.03 # 기본값 - df = self._ohlcv_cache.get(code) - if df is not None and len(df) > 14: - if "atr_pct" not in df.columns: - df = self._calculate_indicators(df.copy()) - self._ohlcv_cache[code] = df - last_atr = df.iloc[-1].get("atr_pct") - if pd.notna(last_atr): - atr_pct_val = float(last_atr) - # 프로그레시브 트레일링: 수익 클수록 타이트한 보호 - if self.disable_es2: - # ── 강화 트레일링 (ES2 비활성화 모드: 7단계) ── - if pnl_pct >= 0.30: - trail_mult = 2.0 # +30%+: 2×ATR, 플로어 -4% (슈퍼 위너 타이트 보호) - trail_floor = -0.04 - elif pnl_pct >= 0.25: - trail_mult = 2.5 # +25-30%: 2.5×ATR, 플로어 -5% - trail_floor = -0.05 - elif pnl_pct >= 0.20: - trail_mult = 3.0 # +20-25%: 3×ATR, 플로어 -6% (기존 ES2 대체) - trail_floor = -0.06 - elif pnl_pct >= 0.15: - trail_mult = 3.5 # +15-20%: 3.5×ATR, 플로어 -6% - trail_floor = -0.06 - elif pnl_pct >= 0.10: - trail_mult = 4.0 # +10-15%: 4×ATR, 플로어 -5% - trail_floor = -0.05 - elif pnl_pct >= 0.07: - trail_mult = 3.5 # +7-10%: 3.5×ATR, 플로어 -5% - trail_floor = -0.05 - else: - trail_mult = 3.0 # 기본: 3×ATR, 플로어 -4% - trail_floor = self.trailing_stop_pct - else: - # ── 기존 트레일링 (4단계) ── - if pnl_pct >= 0.15: - trail_mult = 5.0 # +15%+: 5×ATR, 플로어 -8% - trail_floor = -0.08 - elif pnl_pct >= 0.10: - trail_mult = 4.0 # +10-15%: 4×ATR, 플로어 -6% - trail_floor = -0.06 - elif pnl_pct >= 0.07: - trail_mult = 3.5 # +7-10%: 3.5×ATR, 플로어 -5% - trail_floor = -0.05 - else: - trail_mult = 3.0 # 기본: 3×ATR, 플로어 -4% - trail_floor = self.trailing_stop_pct - # 글로벌 레짐별 트레일링 오버라이드 (STRONG_BULL: 2.0×ATR 타이트) - _ro = REGIME_OVERRIDES.get(self._market_regime, {}) - if "trail_atr_mult" in _ro: - regime_trail_mult = _ro["trail_atr_mult"] - regime_trail_floor = _ro.get("trail_floor_pct", trail_floor) - # 레짐 기반 배수가 현재보다 더 타이트하면 적용 - if regime_trail_mult < trail_mult: - trail_mult = regime_trail_mult - trail_floor = regime_trail_floor - - trail_pct = max(-trail_mult * atr_pct_val, trail_floor) - - # BULL 이격도 부분 청산 (ES1/ES2 전에 실행 — 비파괴적) - if _ro.get("disparity_partial_sell") and not pos.disparity_sold: - _disp = None - if df is not None and "disparity_20" in df.columns: - _disp = df.iloc[-1].get("disparity_20") - elif df is not None and "ma20" in df.columns: - _ma20 = df.iloc[-1].get("ma20") - if pd.notna(_ma20) and _ma20 > 0: - _disp = current_price / float(_ma20) - if _disp is not None and pd.notna(_disp) and _disp > _ro.get("disparity_threshold", 1.15): - sell_qty = max(1, int(pos.quantity * _ro.get("partial_sell_ratio", 0.5))) - if sell_qty < pos.quantity: - self._execute_partial_sell(pos, sell_qty, "ES_DISP_PARTIAL") - pos.disparity_sold = True - - # ES1: 손절 -5% (GAP DOWN 보호: _execute_sell에서 fill price 조정) - if current_price <= entry_price * (1 + self.stop_loss_pct): - exit_reason = "ES1 손절 -5%" - exit_type = "STOP_LOSS" - - # ES2: 익절 (체제별 동적) — disable_es2 모드에서 비활성화 - elif not self.disable_es2 and current_price >= entry_price * (1 + regime_exit["take_profit"]): - tp_label = f"+{regime_exit['take_profit']*100:.0f}%" - exit_reason = f"ES2 익절 {tp_label}" - exit_type = "TAKE_PROFIT" - - # ES3: 트레일링 스탑 (활성화 임계 도달 후에만, ATR 기반) - elif pnl_pct >= (0.03 if self.disable_es2 else regime_exit["trail_activation"]): - if not pos.trailing_activated: - pos.trailing_activated = True - trailing_stop_price = pos.highest_price * (1 + trail_pct) - if current_price <= trailing_stop_price: - exit_reason = "ES3 트레일링스탑" - exit_type = "TRAILING_STOP" - - # ES4: 데드크로스 (MA5/20 — 수익 포지션: 타이트 트레일링 전환) - if not exit_reason: - if df is not None and len(df) >= self.ma_long + 2: - df_calc = df if "ma_short" in df.columns else self._calculate_indicators(df.copy()) - if len(df_calc) >= 2: - curr_row = df_calc.iloc[-1] - prev_row = df_calc.iloc[-2] - if ( - pd.notna(curr_row.get("ma_short")) - and pd.notna(curr_row.get("ma_long")) - and pd.notna(prev_row.get("ma_short")) - and pd.notna(prev_row.get("ma_long")) - and prev_row["ma_short"] >= prev_row["ma_long"] - and curr_row["ma_short"] < curr_row["ma_long"] - ): - if pnl_pct >= 0.02: - # 수익 포지션: 즉시 청산 대신 타이트 트레일링 활성화 - # 1.5×ATR (표준 3×ATR보다 타이트) 또는 최소 -2% - tight_trail = max(-1.5 * atr_pct_val, -0.02) - pos.trailing_activated = True - pos.trailing_stop = round(current_price * (1 + tight_trail)) - elif pnl_pct < -0.02: - # 손실 -2% 초과만 청산 (경미한 손실은 회복 기회) - exit_reason = "ES4 데드크로스" - exit_type = "DEAD_CROSS" - # -2% ~ +2%: 무시 (ES1/ES3/ES5가 처리) - - # ES5: 보유기간 초과 (체제별 동적) - if not exit_reason and pos.days_held > regime_exit["max_holding"]: - exit_reason = "ES5 보유기간 초과" - exit_type = "MAX_HOLDING" - - # ES7: 리밸런스 청산 (워치리스트 탈락) — PnL 게이트 적용 - if not exit_reason and code in self._rebalance_exit_codes: - if pos.days_held < 3 or pnl_pct <= -0.02: - exit_reason = "ES7 리밸런스 청산" - exit_type = "REBALANCE_EXIT" - self._rebalance_exit_codes.discard(code) - elif pnl_pct > 0.02: - # 수익 포지션은 유예 (다음 리밸런스까지 보유) - self._rebalance_exit_codes.discard(code) - else: - exit_reason = "ES7 리밸런스 청산" - exit_type = "REBALANCE_EXIT" - self._rebalance_exit_codes.discard(code) - - if exit_reason: - to_close.append(code) - # Phase 통계: 청산 이유별 카운터 - exit_stat_map = { - "EMERGENCY_STOP": "es0_emergency_stop", - "STOP_LOSS": "es1_stop_loss", - "TAKE_PROFIT": "es2_take_profit", - "TRAILING_STOP": "es3_trailing_stop", - "DEAD_CROSS": "es4_dead_cross", - "MAX_HOLDING": "es5_max_holding", - "TIME_DECAY": "es6_time_decay", - "REBALANCE_EXIT": "es7_rebalance_exit", - } - stat_key = exit_stat_map.get(exit_type or "") - if stat_key: - self._phase_stats[stat_key] += 1 - self._execute_sell(pos, current_price, exit_reason, exit_type or "") - else: - # 트레일링 최고가 갱신 (ATR 기반) - if current_price > pos.highest_price: - pos.highest_price = current_price - if pos.trailing_activated: - pos.trailing_stop = round(current_price * (1 + trail_pct)) - else: - pos.trailing_stop = round(current_price * (1 + self.trailing_stop_pct)) - - for code in to_close: - del self.positions[code] + from simulation.strategies.momentum import check_exits + check_exits(self) def _execute_partial_sell(self, pos: 'SimPosition', sell_qty: int, exit_code: str): """포지션의 일부만 청산 (BULL 이격도 분할 청산용). diff --git a/ats/simulation/strategies/__init__.py b/ats/simulation/strategies/__init__.py new file mode 100644 index 0000000..002b9d5 --- /dev/null +++ b/ats/simulation/strategies/__init__.py @@ -0,0 +1 @@ +"""Per-strategy scan/exit modules for the simulation engine.""" diff --git a/ats/simulation/strategies/arbitrage.py b/ats/simulation/strategies/arbitrage.py new file mode 100644 index 0000000..4e9a1d9 --- /dev/null +++ b/ats/simulation/strategies/arbitrage.py @@ -0,0 +1,1041 @@ +""" +Statistical Pairs Arbitrage strategy — scan_entries / check_exits. + +Extracted from SimulationEngine to keep engine.py smaller. +All logic is identical; `self` is replaced with `engine`. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Dict, List + +import numpy as np +import pandas as pd + +from simulation.models import SimSignal + +if TYPE_CHECKING: + from simulation.engine import SimulationEngine + + +def discover_pairs(engine: "SimulationEngine") -> List[Dict]: + """ + 워치리스트 내 페어 자동 발견 — v2. + 동일 섹터 우선 + 크로스섹터 허용 (BUG-7). + Phase Stats 누적 (BUG-1), 쿨다운 중 페어 스킵 (BUG-3). + """ + import itertools + + cfg = engine._arb_cfg + watchlist = engine._watchlist + pairs: List[Dict] = [] + + # v2: 쿨다운 중 페어 키 집합 (BUG-3) + cooldown_keys = set(engine._arb_pair_cooldown.keys()) + + # ── 후보 조합 생성 ── + combos: List[tuple] = [] + + # 1) 동일 섹터 내 조합 (우선) + sector_map: Dict[str, List[Dict]] = {} + for w in watchlist: + sector = w.get("sector", "") + if sector: + sector_map.setdefault(sector, []).append(w) + + for sector, stocks in sector_map.items(): + if len(stocks) < 2: + continue + for w_a, w_b in itertools.combinations(stocks, 2): + combos.append((w_a, w_b, sector)) + + # 2) 크로스섹터 조합 (v2 BUG-7) + if cfg.cross_sector_pairs: + all_stocks = [w for w in watchlist if w.get("sector", "")] + for w_a, w_b in itertools.combinations(all_stocks, 2): + if w_a.get("sector", "") != w_b.get("sector", ""): + combos.append((w_a, w_b, "cross")) + + # v2: 누적 통계 (BUG-1) — 리셋하지 않고 += 누적 + engine._phase_stats["arb_pairs_scanned"] = ( + engine._phase_stats.get("arb_pairs_scanned", 0) + len(combos) + ) + + for w_a, w_b, sector_label in combos: + code_a, code_b = w_a["code"], w_b["code"] + + # v2: 쿨다운 중 페어 스킵 (BUG-3) + pair_key = f"{min(code_a,code_b)}-{max(code_a,code_b)}" + if pair_key in cooldown_keys: + continue + + df_a = engine._ohlcv_cache.get(code_a) + df_b = engine._ohlcv_cache.get(code_b) + + if df_a is None or df_b is None or len(df_a) < cfg.correlation_lookback or len(df_b) < cfg.correlation_lookback: + continue + + # 날짜 정렬 후 최근 N일 종가 추출 + close_a = df_a["close"].astype(float).tail(cfg.correlation_lookback) + close_b = df_b["close"].astype(float).tail(cfg.correlation_lookback) + + if len(close_a) != len(close_b): + min_len = min(len(close_a), len(close_b)) + close_a = close_a.tail(min_len).reset_index(drop=True) + close_b = close_b.tail(min_len).reset_index(drop=True) + + if len(close_a) < 30: + continue + + # 상관계수 체크 (v2: 0.60 threshold) + corr = close_a.corr(close_b) + if pd.isna(corr) or corr < cfg.correlation_min: + engine._phase_stats["arb_correlation_rejects"] = ( + engine._phase_stats.get("arb_correlation_rejects", 0) + 1 + ) + continue + + # 스프레드 계산: log(price_A / price_B) + spread = np.log(close_a.values / close_b.values) + spread = spread[~np.isnan(spread)] + if len(spread) < cfg.zscore_lookback: + continue + + # 반감기(half-life) 계산: OLS Δspread ~ spread_lag + spread_lag = spread[:-1] + spread_diff = np.diff(spread) + if len(spread_lag) < 10 or np.std(spread_lag) < 1e-10: + continue + + try: + beta = np.cov(spread_diff, spread_lag)[0, 1] / np.var(spread_lag) + if beta >= 0: # 비수렴 + continue + halflife = -np.log(2) / beta + if halflife > cfg.halflife_max or halflife < 1: + continue + except (ValueError, ZeroDivisionError): + continue + + # 스프레드 통계 + spread_mean = float(np.mean(spread)) + spread_std = float(np.std(spread)) + if spread_std < 1e-10: + continue + + current_zscore = (spread[-1] - spread_mean) / spread_std + + # 중복 페어 방지 (동일 섹터에서 이미 발견된 경우) + existing_keys = {f"{min(p['code_a'],p['code_b'])}-{max(p['code_a'],p['code_b'])}" for p in pairs} + if pair_key in existing_keys: + continue + + pairs.append({ + "code_a": code_a, + "code_b": code_b, + "name_a": w_a["name"], + "name_b": w_b["name"], + "sector": sector_label, + "correlation": round(float(corr), 4), + "halflife": round(float(halflife), 1), + "spread_mean": spread_mean, + "spread_std": spread_std, + "current_zscore": round(float(current_zscore), 3), + "spread_series": spread, + "close_a": close_a, + "close_b": close_b, + }) + + # v2: 누적 (BUG-1) + engine._phase_stats["arb_spreads_detected"] = ( + engine._phase_stats.get("arb_spreads_detected", 0) + len(pairs) + ) + return pairs + + +def load_fixed_pairs(engine: "SimulationEngine") -> List[Dict]: + """ + v5: 설정 기반 고정 ETF 페어 로드. + _discover_pairs() 대체. correlation_min 필터링 없음 (사전 검증된 페어). + """ + cfg = engine._arb_cfg + pairs: List[Dict] = [] + cooldown_keys = set(engine._arb_pair_cooldown.keys()) + + for pair_def in engine._arb_fixed_pair_defs: + code_a = pair_def.get("code_a", "") + code_b = pair_def.get("code_b", "") + if not code_a or not code_b: + continue + + # 쿨다운 체크 + pair_key = f"{min(code_a,code_b)}-{max(code_a,code_b)}" + if pair_key in cooldown_keys: + continue + + df_a = engine._ohlcv_cache.get(code_a) + df_b = engine._ohlcv_cache.get(code_b) + + if df_a is None or df_b is None: + continue + if len(df_a) < cfg.zscore_lookback or len(df_b) < cfg.zscore_lookback: + continue + + close_a = df_a["close"].astype(float).tail(cfg.correlation_lookback) + close_b = df_b["close"].astype(float).tail(cfg.correlation_lookback) + + min_len = min(len(close_a), len(close_b)) + if min_len < cfg.zscore_lookback: + continue + close_a = close_a.tail(min_len).reset_index(drop=True) + close_b = close_b.tail(min_len).reset_index(drop=True) + + # 상관계수 (참고용, 필터링 없음) + corr = close_a.corr(close_b) + if pd.isna(corr): + corr = 0.0 + + # 스프레드: log(price_A / price_B) + spread = np.log(close_a.values / close_b.values) + spread = spread[~np.isnan(spread)] + if len(spread) < cfg.zscore_lookback: + continue + + # 반감기 + spread_lag = spread[:-1] + spread_diff = np.diff(spread) + halflife = 10.0 # default + if len(spread_lag) >= 10 and np.std(spread_lag) > 1e-10: + try: + beta = np.cov(spread_diff, spread_lag)[0, 1] / np.var(spread_lag) + if beta < 0: + halflife = min(-np.log(2) / beta, cfg.halflife_max) + except (ValueError, ZeroDivisionError): + pass + + # 스프레드 통계 + spread_mean = float(np.mean(spread)) + spread_std = float(np.std(spread)) + if spread_std < 1e-10: + continue + + current_zscore = (spread[-1] - spread_mean) / spread_std + + pairs.append({ + "code_a": code_a, + "code_b": code_b, + "name_a": pair_def.get("name_a", code_a), + "name_b": pair_def.get("name_b", code_b), + "sector": pair_def.get("sector", "ETF"), + "correlation": round(float(corr), 4), + "halflife": round(float(halflife), 1), + "spread_mean": spread_mean, + "spread_std": spread_std, + "current_zscore": round(float(current_zscore), 3), + "spread_series": spread, + "close_a": close_a, + "close_b": close_b, + }) + + engine._phase_stats["arb_fixed_pairs_loaded"] = ( + engine._phase_stats.get("arb_fixed_pairs_loaded", 0) + len(pairs) + ) + engine._phase_stats["arb_spreads_detected"] = ( + engine._phase_stats.get("arb_spreads_detected", 0) + len(pairs) + ) + return pairs + + +def check_basis_gate(engine: "SimulationEngine") -> bool: + """ + v5: 콘탱고/백워데이션 Basis Gate. + True = 차익거래 윈도우 OPEN, False = CLOSED. + + US: Basis = (ES=F − SPY) / SPY × 100 → Z-Score + KOSPI: ^KS200 실현변동성 Z-Score 프록시 + """ + cfg = engine._arb_cfg + if not cfg.basis_gate_enabled: + engine._arb_basis_window_open = True + return True + + if not engine._arb_basis_signals: + # Basis signal 설정이 없으면 게이트 비활성 (항상 열림) + engine._arb_basis_window_open = True + return True + + for sig in engine._arb_basis_signals: + spot_code = sig.get("spot_code", "") + futures_code = sig.get("futures_code", "") + ma_period = sig.get("basis_ma_period", 20) + z_threshold = sig.get("basis_zscore_threshold", 1.5) + use_premium = sig.get("use_premium_estimate", False) + + if use_premium or not futures_code: + # KOSPI: 변동성 프록시 + spot_ticker = sig.get("spot_ticker", "") + # ^KS200 데이터는 spot_code 또는 spot_ticker로 조회 + df_spot = engine._ohlcv_cache.get(spot_code) + if df_spot is None: + df_spot = engine._ohlcv_cache.get(spot_ticker) + if df_spot is None or len(df_spot) < ma_period * 3: + # 데이터 부족 시 게이트 열기 (거래 허용) + engine._arb_basis_window_open = True + return True + + close = df_spot["close"].astype(float) + returns = close.pct_change().dropna() + if len(returns) < ma_period: + engine._arb_basis_window_open = True + return True + + # 실현 변동성 (연환산) + realized_vol = returns.rolling(ma_period).std() * np.sqrt(252) + realized_vol = realized_vol.dropna() + if len(realized_vol) < ma_period * 3: + engine._arb_basis_window_open = True + return True + + vol_ma = realized_vol.rolling(ma_period * 3).mean() + vol_std = realized_vol.rolling(ma_period * 3).std() + vol_ma_last = vol_ma.iloc[-1] + vol_std_last = vol_std.iloc[-1] + + if pd.isna(vol_ma_last) or pd.isna(vol_std_last) or vol_std_last < 1e-10: + engine._arb_basis_window_open = True + return True + + vol_zscore = (realized_vol.iloc[-1] - vol_ma_last) / vol_std_last + is_open = abs(float(vol_zscore)) > z_threshold + + engine._arb_basis_data = { + "type": "volatility_proxy", + "realized_vol": round(float(realized_vol.iloc[-1]), 4), + "vol_zscore": round(float(vol_zscore), 3), + "threshold": z_threshold, + "window_open": is_open, + } + else: + # US: Basis = (Futures - Spot) / Spot × 100 + df_spot = engine._ohlcv_cache.get(spot_code) + df_futures = engine._ohlcv_cache.get(futures_code) + if df_spot is None or df_futures is None: + engine._arb_basis_window_open = True + return True + if len(df_spot) < ma_period * 2 or len(df_futures) < ma_period * 2: + engine._arb_basis_window_open = True + return True + + spot_close = df_spot["close"].astype(float) + fut_close = df_futures["close"].astype(float) + + # 날짜 정렬 보장: 길이 맞추기 + min_len = min(len(spot_close), len(fut_close)) + spot_close = spot_close.tail(min_len).reset_index(drop=True) + fut_close = fut_close.tail(min_len).reset_index(drop=True) + + # Basis 계산: (Futures - Spot) / Spot × 100 + # ES=F는 SPY의 약 10배이므로 스케일 조정 + basis = (fut_close - spot_close * 10) / (spot_close * 10) * 100 + + if len(basis) < ma_period: + engine._arb_basis_window_open = True + return True + + basis_ma = basis.rolling(ma_period).mean() + basis_std = basis.rolling(ma_period).std() + + basis_ma_last = basis_ma.iloc[-1] + basis_std_last = basis_std.iloc[-1] + + if pd.isna(basis_ma_last) or pd.isna(basis_std_last) or basis_std_last < 1e-10: + engine._arb_basis_window_open = True + return True + + basis_zscore = (basis.iloc[-1] - basis_ma_last) / basis_std_last + is_open = abs(float(basis_zscore)) > z_threshold + + engine._arb_basis_data = { + "type": "futures_basis", + "basis_pct": round(float(basis.iloc[-1]), 4), + "basis_zscore": round(float(basis_zscore), 3), + "threshold": z_threshold, + "window_open": is_open, + } + + engine._arb_basis_window_open = is_open + if is_open: + engine._phase_stats["arb_basis_window_opens"] = ( + engine._phase_stats.get("arb_basis_window_opens", 0) + 1 + ) + return is_open + + engine._arb_basis_window_open = True + return True + + +def score_arb_correlation(engine: "SimulationEngine", pair: Dict) -> int: + """ + Layer 1: 상관관계 품질 (0 ~ weight_correlation). + graduated scoring + 안정성 + 추세. + """ + cfg = engine._arb_cfg + score = 0 + corr = pair["correlation"] + + # Graduated 상관계수 점수 + if corr > 0.85: + score += 20 + elif corr > 0.75: + score += 15 + elif corr > 0.70: + score += 10 + + # 상관관계 안정성: rolling 20일 std + close_a = pair["close_a"] + close_b = pair["close_b"] + if len(close_a) >= 30: + rolling_corr = close_a.rolling(20).corr(close_b).dropna() + if len(rolling_corr) > 5: + corr_std = float(rolling_corr.std()) + if corr_std < 0.1: + score += 10 + elif corr_std < 0.15: + score += 5 + + # 최근 5일 corr 상승 추세 + recent_corr = rolling_corr.tail(5) + if len(recent_corr) >= 5: + if float(recent_corr.iloc[-1]) > float(recent_corr.iloc[0]): + score += 5 + + return min(score, cfg.weight_correlation) + + +def score_arb_spread(engine: "SimulationEngine", pair: Dict) -> int: + """ + Layer 2: 스프레드 이탈도 (0 ~ weight_spread). + Z-Score graduated + half-life + IV/RV 비교 (BlackScholes 참조). + """ + cfg = engine._arb_cfg + score = 0 + zscore = abs(pair["current_zscore"]) + + # Graduated Z-Score 점수 + if zscore > 2.5: + score += 20 + elif zscore > 2.0: + score += 15 + elif zscore > 1.5: + score += 10 + + # 반감기 보너스 (빠른 회귀 = 높은 점수) + halflife = pair["halflife"] + if halflife < 10: + score += 10 + elif halflife < 20: + score += 5 + + # IV vs RV 비교 (Black-Scholes 영감): 스프레드 실현변동성 분석 + spread_series = pair["spread_series"] + if len(spread_series) >= 60: + # 실현변동성 (RV): 최근 20일 vs 장기 60일 + recent_rv = float(np.std(spread_series[-20:])) + long_rv = float(np.std(spread_series[-60:])) + if long_rv > 0 and recent_rv > long_rv: + score += 5 # 변동성 확장 = 회귀 기회↑ + + # 스프레드 극단 횟수 (반복 패턴 = 높은 신뢰) + if len(spread_series) >= 60 and pair["spread_std"] > 0: + historical_z = (spread_series[-60:] - pair["spread_mean"]) / pair["spread_std"] + extreme_count = np.sum(np.abs(historical_z) > 1.5) + if extreme_count >= 3: + score += 5 + + return min(score, cfg.weight_spread) + + +def score_arb_volume(engine: "SimulationEngine", pair: Dict) -> int: + """ + Layer 3: 거래량 + EV 확인 (0 ~ weight_volume). + EV Engine (futuresStrategy.md): EV = P(W) × Avg.W - P(L) × Avg.L > 0 + """ + cfg = engine._arb_cfg + score = 0 + + # 양쪽 종목 거래량 확인 + df_a = engine._ohlcv_cache.get(pair["code_a"]) + df_b = engine._ohlcv_cache.get(pair["code_b"]) + + if df_a is not None and df_b is not None and len(df_a) >= 20 and len(df_b) >= 20: + vol_a = float(df_a["volume"].iloc[-1]) + vol_b = float(df_b["volume"].iloc[-1]) + vol_ma_a = float(df_a["volume"].tail(20).mean()) + vol_ma_b = float(df_b["volume"].tail(20).mean()) + + # 양쪽 vol > MA20 + if vol_ma_a > 0 and vol_ma_b > 0: + if vol_a > vol_ma_a and vol_b > vol_ma_b: + score += 10 + elif vol_a > vol_ma_a or vol_b > vol_ma_b: + score += 5 + + # 이탈 방향 종목 거래량 급증 (1.5x) + zscore = pair["current_zscore"] + if zscore > 0 and vol_ma_a > 0 and vol_a > vol_ma_a * 1.5: + score += 5 # A가 고평가 → A에 거래량 급증 = 의미있는 이탈 + elif zscore < 0 and vol_ma_b > 0 and vol_b > vol_ma_b * 1.5: + score += 5 # B가 고평가 → B에 거래량 급증 + + # EV > 0 검증 (과거 유사 스프레드 회귀의 승률/손익비) + ev_positive = calculate_arb_ev(engine, pair) + if ev_positive: + score += 10 + + return min(score, cfg.weight_volume) + + +def calculate_arb_ev(engine: "SimulationEngine", pair: Dict) -> bool: + """ + Expected Value 검증 (futuresStrategy.md). + EV = P(W) × Avg.W - P(L) × Avg.L + 과거 스프레드 데이터에서 유사 Z-Score 진입의 가상 결과를 시뮬레이션. + """ + spread_series = pair["spread_series"] + if len(spread_series) < 60: + return True # 데이터 부족 시 통과 (보수적) + + mean = pair["spread_mean"] + std = pair["spread_std"] + if std < 1e-10: + return True + + zscore_series = (spread_series - mean) / std + entry_threshold = engine._arb_cfg.zscore_entry + exit_threshold = engine._arb_cfg.zscore_exit + + wins = [] + losses = [] + + i = 0 + while i < len(zscore_series) - 5: + z = zscore_series[i] + if abs(z) >= entry_threshold: + # 진입 시뮬레이션: 이후 5~20일 내 |z| < exit_threshold 도달 여부 + direction = -1 if z > 0 else 1 # z>0이면 축소 베팅 + for j in range(1, min(21, len(zscore_series) - i)): + future_z = zscore_series[i + j] + pnl_z = direction * (z - future_z) # Z-Score 변화량 + if abs(future_z) < exit_threshold: + wins.append(float(pnl_z)) + break + else: + # 20일 내 미회귀 = 손실 + pnl_z = direction * (z - zscore_series[min(i + 20, len(zscore_series) - 1)]) + if pnl_z > 0: + wins.append(float(pnl_z)) + else: + losses.append(float(abs(pnl_z))) + i += 10 # 겹치지 않게 점프 + else: + i += 1 + + if not wins and not losses: + return True # 데이터 부족 + + total = len(wins) + len(losses) + if total < 3: + return True # 충분하지 않으면 통과 + + p_win = len(wins) / total + avg_win = sum(wins) / len(wins) if wins else 0 + avg_loss = sum(losses) / len(losses) if losses else 0 + p_loss = 1 - p_win + + ev = p_win * avg_win - p_loss * avg_loss + return ev > 0 + + +def size_arb_pair(engine: "SimulationEngine", price_a: float, price_b: float, score: int) -> tuple: + """ + v2: 페어 전용 dollar-neutral 사이징 (BUG-6). + 양쪽 동일 금액 기준. 스코어 기반 승수. + Returns: (qty_a, qty_b) + """ + equity = engine._get_total_equity() + max_alloc = equity * engine._arb_cfg.max_weight_per_pair # 10% + half_alloc = max_alloc / 2 # 각 leg 5% + + # 스코어 기반 사이징 (0.7~1.2) + score_mult = 0.7 + (score - 60) / 100.0 * 0.5 # 60점=0.7, 100점=0.9 + score_mult = max(0.7, min(score_mult, 1.2)) + + alloc = half_alloc * score_mult + + # 현금 제약: 최소 현금 비율 유지 + min_cash = equity * engine.min_cash_ratio + available = engine.cash - min_cash + if available <= 0: + return (0, 0) + # Long 매수 + Short 마진(50%) 필요 금액 + total_needed = alloc + alloc * 0.5 # Long full + Short margin + if total_needed > available: + alloc = available / 1.5 # 역산 + + qty_a = max(1, int(alloc / price_a)) if price_a > 0 else 0 + qty_b = max(1, int(alloc / price_b)) if price_b > 0 else 0 + return (qty_a, qty_b) + + +def scan_entries(engine: "SimulationEngine"): + """ + Statistical Pairs Arbitrage 양방향 진입 스캔 — v5. + Z-Score 기반 통계적 진입 (futuresStrategy.md 참조). + Long + Short 동시 진입. + v2: 쿨다운 차감, dollar-neutral 사이징, 진입 시 entry_z 저장. + v3: 워밍업 버퍼, ARB MDD 서킷브레이커 (-10%), 최소 보유일. + v4: MDD 복구, 진입 완화, 페어 재발견 3일, 쿨다운 5일. + v5: Fixed ETF Pair Mode + Basis Gate (콘탱고/백워데이션). + """ + cfg = engine._arb_cfg + + # ── v2: 쿨다운 차감 (BUG-3) ── + expired_keys = [] + for key in list(engine._arb_pair_cooldown.keys()): + engine._arb_pair_cooldown[key] -= 1 + if engine._arb_pair_cooldown[key] <= 0: + expired_keys.append(key) + for key in expired_keys: + del engine._arb_pair_cooldown[key] + + # ── v3: 워밍업 버퍼 (첫 N거래일 진입 금지) ── + engine._arb_day_count += 1 + if engine._arb_day_count <= cfg.warmup_buffer_days: + return + + # ── v4: ARB MDD 서킷브레이커 + 복구 메커니즘 ── + equity = engine._get_total_equity() + if hasattr(engine, '_peak_equity') and engine._peak_equity > 0: + mdd_pct = (equity - engine._peak_equity) / engine._peak_equity + if mdd_pct <= -cfg.arb_mdd_limit: + # MDD 한도 초과 → 신규 진입 차단 + if not engine._arb_mdd_halted: + engine._arb_mdd_halted = True + engine._arb_mdd_halt_days = 0 + engine._arb_mdd_halt_days += 1 + # v4: 시간 기반 자동 복구 (halt_max_days 초과 시 재개) + halt_max = getattr(cfg, 'arb_mdd_halt_max_days', 20) + if engine._arb_mdd_halt_days >= halt_max: + # 현재 equity를 새 baseline으로 설정 (peak 리셋) + engine._peak_equity = equity + engine._arb_mdd_halted = False + engine._arb_mdd_halt_days = 0 + else: + return + elif engine._arb_mdd_halted: + engine._arb_mdd_halt_days += 1 + # v4: DD가 recovery 수준(-5%)으로 회복 OR 시간 초과 + recovery_threshold = getattr(cfg, 'arb_mdd_recovery', 0.05) + halt_max = getattr(cfg, 'arb_mdd_halt_max_days', 20) + if mdd_pct > -recovery_threshold or engine._arb_mdd_halt_days >= halt_max: + if engine._arb_mdd_halt_days >= halt_max: + engine._peak_equity = equity + engine._arb_mdd_halted = False + engine._arb_mdd_halt_days = 0 + else: + return # 아직 recovery 미달 & 시간 미초과 → 계속 차단 + + # ── v5: Basis Gate (콘탱고/백워데이션 체크) ── + if not check_basis_gate(engine): + engine._phase_stats["arb_basis_gate_blocks"] = ( + engine._phase_stats.get("arb_basis_gate_blocks", 0) + 1 + ) + return + + # ── Phase 0: 시장 체제 판단 ── + engine._update_market_regime() + + # ── Phase 4: 리스크 게이트 ── + gate_pass, gate_reason = engine._risk_gate_check() + if not gate_pass: + return + + # 현재 활성 페어 수 체크 + active_pair_ids = set() + for pos in engine.positions.values(): + if pos.status == "ACTIVE" and pos.pair_id: + active_pair_ids.add(pos.pair_id) + if len(active_pair_ids) >= cfg.max_pairs: + return + + # 이미 보유 중인 종목 코드 + held_codes = set(engine.positions.keys()) + + # v5: 페어 발견 — use_fixed_pairs 분기 + rediscovery_days = getattr(cfg, 'pair_rediscovery_days', 3) + current_date = engine._get_current_date_str() + days_since_discovery = 999 + if engine._arb_last_discovery and current_date: + try: + from datetime import datetime as _dt + d1 = _dt.strptime(engine._arb_last_discovery.replace("-", "")[:8], "%Y%m%d") + d2 = _dt.strptime(current_date.replace("-", "")[:8], "%Y%m%d") + days_since_discovery = (d2 - d1).days + except ValueError: + days_since_discovery = 999 + + if not engine._arb_pairs or days_since_discovery >= rediscovery_days: + if cfg.use_fixed_pairs and engine._arb_fixed_pair_defs: + engine._arb_pairs = load_fixed_pairs(engine) + else: + engine._arb_pairs = discover_pairs(engine) + engine._arb_last_discovery = current_date + + if not engine._arb_pairs: + return + + # 스캔 누적 (BUG-1) + engine._phase_stats["total_scans"] = engine._phase_stats.get("total_scans", 0) + 1 + + # 각 페어 스코어링 및 진입 + for pair in engine._arb_pairs: + if len(active_pair_ids) >= cfg.max_pairs: + break + + code_a = pair["code_a"] + code_b = pair["code_b"] + + # 이미 보유 중인 종목은 스킵 + if code_a in held_codes or code_b in held_codes: + continue + + # v4: 실시간 Z-Score 재계산 (페어 발견 시점 값이 아닌 현재 가격 기준) + df_a = engine._ohlcv_cache.get(code_a) + df_b = engine._ohlcv_cache.get(code_b) + if df_a is not None and df_b is not None and len(df_a) >= cfg.zscore_lookback and len(df_b) >= cfg.zscore_lookback: + close_a = df_a["close"].astype(float).tail(cfg.correlation_lookback) + close_b = df_b["close"].astype(float).tail(cfg.correlation_lookback) + min_len = min(len(close_a), len(close_b)) + if min_len >= cfg.zscore_lookback: + close_a = close_a.tail(min_len).reset_index(drop=True) + close_b = close_b.tail(min_len).reset_index(drop=True) + spread = np.log(close_a.values / close_b.values) + spread_recent = spread[-cfg.zscore_lookback:] + s_mean = float(np.mean(spread_recent)) + s_std = float(np.std(spread_recent)) + if s_std > 1e-10: + zscore = (spread[-1] - s_mean) / s_std + else: + zscore = 0.0 + else: + zscore = pair.get("current_zscore", 0) + else: + zscore = pair.get("current_zscore", 0) + + # Z-Score 임계값 미달 → 스킵 + if abs(zscore) < cfg.zscore_entry: + continue + + # 3-Layer 스코어링 + score_l1 = score_arb_correlation(engine, pair) + score_l2 = score_arb_spread(engine, pair) + score_l3 = score_arb_volume(engine, pair) + total_score = score_l1 + score_l2 + score_l3 + + engine._phase_stats["arb_total_score"] = ( + engine._phase_stats.get("arb_total_score", 0) + total_score + ) + + # v5: 고정 페어 모드 → etf_entry_threshold 사용 + threshold = cfg.etf_entry_threshold if cfg.use_fixed_pairs else cfg.entry_threshold + if total_score < threshold: + continue + + # ── 양방향 진입 결정 ── + pair_id = f"arb-{code_a}-{code_b}-{current_date}" + + if zscore > 0: + # Z > 0: A 고평가 → Short A, B 저평가 → Long B + long_code, long_name = code_b, pair["name_b"] + short_code, short_name = code_a, pair["name_a"] + else: + # Z < 0: A 저평가 → Long A, B 고평가 → Short B + long_code, long_name = code_a, pair["name_a"] + short_code, short_name = code_b, pair["name_b"] + + # Long leg 가격 + long_price = engine._current_prices.get(long_code) + short_price = engine._current_prices.get(short_code) + if not long_price or not short_price: + continue + + # v2: dollar-neutral 사이징 (BUG-6) + qty_long, qty_short = size_arb_pair(engine, long_price, short_price, total_score) + if qty_long <= 0 or qty_short <= 0: + continue + + now = engine._get_current_iso() + + # Long leg 시그널 생성 + 매수 + engine._signal_counter += 1 + long_signal = SimSignal( + id=f"sim-sig-{engine._signal_counter:04d}", + stock_code=long_code, + stock_name=long_name, + type="BUY", + price=long_price, + reason=f"ARB L1:{score_l1} L2:{score_l2} L3:{score_l3} Z:{zscore:+.2f}", + strength=total_score, + detected_at=now, + ) + engine.signals.append(long_signal) + if len(engine.signals) > 100: + engine.signals = engine.signals[-100:] + + # v2: 통일 사이징으로 매수 (BUG-6) + engine._execute_buy_arb(long_signal, qty_long, pair_id) + + # Short leg 시그널 생성 + 공매도 진입 + engine._signal_counter += 1 + short_signal = SimSignal( + id=f"sim-sig-{engine._signal_counter:04d}", + stock_code=short_code, + stock_name=short_name, + type="SELL_SHORT", + price=short_price, + reason=f"ARB L1:{score_l1} L2:{score_l2} L3:{score_l3} Z:{zscore:+.2f}", + strength=total_score, + detected_at=now, + ) + engine.signals.append(short_signal) + if len(engine.signals) > 100: + engine.signals = engine.signals[-100:] + + # v2: 통일 사이징으로 공매도 (BUG-6) + engine._execute_sell_short(short_signal, pair_id, qty_short) + + # v2: 진입 시 entry_z 저장 (방향성 청산용, BUG-5) + engine._arb_pair_states[pair_id] = { + "entry_z": zscore, + "code_a": code_a, + "code_b": code_b, + "entry_date": current_date, + "initial_corr": pair["correlation"], + } + + engine._phase_stats["arb_entries"] = engine._phase_stats.get("arb_entries", 0) + 1 + active_pair_ids.add(pair_id) + held_codes.add(long_code) + held_codes.add(short_code) + + engine._add_risk_event( + "INFO", + f"ARB 페어 진입: {long_name}(L) + {short_name}(S) | Z={zscore:+.2f} Score={total_score}", + ) + + +def check_exits(engine: "SimulationEngine"): + """ + Arbitrage 양방향 청산 로직 — v4. + 우선순위 재정렬 (BUG-2), 방향성 Z-Score (BUG-5), + 상관관계 2일 연속 확인 (BUG-4), 청산 시 쿨다운 등록 (BUG-3). + v3: Z-Score TP 최소 보유일 3일 (조기 청산 방지). + + Exit Priority: + 1. ES1: -5% 하드 손절 (Long/Short 각각 명시) — BUG-2 + 2. ES_ARB_SL: Dynamic ATR SL + 3. ES_ARB_TP: 방향성 Z-Score 청산 (z 부호 전환, v3: 최소 3일 보유) — BUG-5 + 4. ES_ARB_CORR: 상관관계 35% 하락 + 2일 연속 — BUG-4 + 5. ES3: 트레일링 (5% 활성화) + 6. ES5: 최대 보유 20일 + """ + cfg = engine._arb_cfg + to_close: List[str] = [] + pair_close_reasons: Dict[str, str] = {} # pair_id → 청산 사유 + + for code, pos in engine.positions.items(): + if pos.status != "ACTIVE": + continue + + current_price = pos.current_price + entry_price = pos.entry_price + + # PnL 계산 (side 별) + if pos.side == "SHORT": + pnl_pct = (entry_price - current_price) / entry_price + else: + pnl_pct = (current_price - entry_price) / entry_price + + exit_reason = None + + # ══ 순위 1: ES1 -5% 하드 손절 (BUG-2: 최우선, Long/Short 각각 명시) ══ + if pos.side == "SHORT": + # Short: 가격이 진입가 대비 5% 상승하면 손절 + if current_price >= entry_price * 1.05: + actual_loss = (entry_price - current_price) / entry_price + exit_reason = f"ES1 Short 하드 손절 ({actual_loss*100:+.1f}%)" + engine._phase_stats["es_arb_sl"] = engine._phase_stats.get("es_arb_sl", 0) + 1 + else: + # Long: 가격이 진입가 대비 5% 하락하면 손절 + if current_price <= entry_price * 0.95: + actual_loss = (current_price - entry_price) / entry_price + exit_reason = f"ES1 Long 하드 손절 ({actual_loss*100:+.1f}%)" + engine._phase_stats["es_arb_sl"] = engine._phase_stats.get("es_arb_sl", 0) + 1 + + # ══ 순위 2: ES_ARB_SL Dynamic ATR Stop ══ + if not exit_reason: + df = engine._ohlcv_cache.get(code) + if df is not None and len(df) > 14: + if "atr" not in df.columns: + df = engine._calculate_indicators(df.copy()) + engine._ohlcv_cache[code] = df + last_atr = df.iloc[-1].get("atr") + last_adx = df.iloc[-1].get("adx") + if pd.notna(last_atr) and float(last_atr) > 0: + atr_val = float(last_atr) + adx_val = float(last_adx) if pd.notna(last_adx) else 0 + sl_mult = cfg.atr_sl_mult_strong if adx_val >= cfg.adx_dynamic_threshold else cfg.atr_sl_mult + + if pos.side == "SHORT": + atr_stop = entry_price + atr_val * sl_mult + # ATR 스탑이 하드 손절보다 넓으면 하드 손절 우선 (이미 체크됨) + if current_price >= atr_stop and current_price < entry_price * 1.05: + exit_reason = f"ES_ARB_SL Short ATR×{sl_mult} ({pnl_pct*100:+.1f}%)" + engine._phase_stats["es_arb_sl"] = engine._phase_stats.get("es_arb_sl", 0) + 1 + else: + atr_stop = entry_price - atr_val * sl_mult + if current_price <= atr_stop and current_price > entry_price * 0.95: + exit_reason = f"ES_ARB_SL Long ATR×{sl_mult} ({pnl_pct*100:+.1f}%)" + engine._phase_stats["es_arb_sl"] = engine._phase_stats.get("es_arb_sl", 0) + 1 + + # ══ 순위 3: ES_ARB_TP 방향성 Z-Score 청산 (BUG-5, v3: 최소 보유일) ══ + if not exit_reason and pos.pair_id: + # v3: 최소 보유일 미달 시 Z-Score TP 스킵 + days_held = getattr(pos, 'days_held', 0) or 0 + skip_zscore_tp = days_held < cfg.min_hold_days_for_tp + + for pair in engine._arb_pairs: + pair_codes = {pair["code_a"], pair["code_b"]} + if code in pair_codes: + # 실시간 Z-Score 재계산 + df_a = engine._ohlcv_cache.get(pair["code_a"]) + df_b = engine._ohlcv_cache.get(pair["code_b"]) + if df_a is not None and df_b is not None: + close_a = df_a["close"].astype(float).tail(cfg.correlation_lookback) + close_b = df_b["close"].astype(float).tail(cfg.correlation_lookback) + min_len = min(len(close_a), len(close_b)) + if min_len >= cfg.zscore_lookback: + close_a = close_a.tail(min_len).reset_index(drop=True) + close_b = close_b.tail(min_len).reset_index(drop=True) + spread = np.log(close_a.values / close_b.values) + spread_recent = spread[-cfg.zscore_lookback:] + if len(spread_recent) > 0: + s_mean = float(np.mean(spread_recent)) + s_std = float(np.std(spread_recent)) + if s_std > 1e-10: + current_z = (spread[-1] - s_mean) / s_std + + # pair_state는 TP/CORR 양쪽에서 사용 + pair_state = engine._arb_pair_states.get(pos.pair_id, {}) + + # v2: 방향성 Z-Score 청산 (BUG-5) + # v3: 최소 보유일(min_hold_days_for_tp) 미달 시 스킵 + if not skip_zscore_tp: + entry_z = pair_state.get("entry_z", 0) + + if entry_z > 0 and current_z <= cfg.zscore_exit: + # 진입 Z>+2 → 스프레드 축소 기대 → z<0.2 이면 청산 + exit_reason = f"ES_ARB_TP Z 방향성 청산 (entry_z={entry_z:+.1f} → z={current_z:.2f})" + engine._phase_stats["es_arb_tp"] = engine._phase_stats.get("es_arb_tp", 0) + 1 + if pos.pair_id: + pair_close_reasons[pos.pair_id] = exit_reason + elif entry_z < 0 and current_z >= -cfg.zscore_exit: + # 진입 Z<-2 → 스프레드 확대 기대 → z>-0.2 이면 청산 + exit_reason = f"ES_ARB_TP Z 방향성 청산 (entry_z={entry_z:+.1f} → z={current_z:.2f})" + engine._phase_stats["es_arb_tp"] = engine._phase_stats.get("es_arb_tp", 0) + 1 + if pos.pair_id: + pair_close_reasons[pos.pair_id] = exit_reason + + # ══ 순위 4: ES_ARB_CORR 상관관계 35% 하락 + 2일 연속 (BUG-4) ══ + if not exit_reason: + current_corr = close_a.corr(close_b) + initial_corr = pair_state.get("initial_corr", pair["correlation"]) + if pd.notna(current_corr) and initial_corr > 0: + corr_decay = (initial_corr - current_corr) / initial_corr + if corr_decay >= cfg.correlation_decay_exit: + # v2: 2일 연속 확인 (BUG-4) + decay_key = pos.pair_id or code + engine._arb_corr_decay_count[decay_key] = ( + engine._arb_corr_decay_count.get(decay_key, 0) + 1 + ) + if engine._arb_corr_decay_count[decay_key] >= cfg.corr_decay_confirm_days: + exit_reason = f"ES_ARB_CORR 상관관계 붕괴 {cfg.corr_decay_confirm_days}일 연속 ({corr_decay*100:.0f}%↓)" + engine._phase_stats["es_arb_corr"] = engine._phase_stats.get("es_arb_corr", 0) + 1 + if pos.pair_id: + pair_close_reasons[pos.pair_id] = exit_reason + else: + # 붕괴 조건 미달 → 카운트 리셋 + decay_key = pos.pair_id or code + engine._arb_corr_decay_count[decay_key] = 0 + break + + # ══ 순위 5: ES3 트레일링 ══ + if not exit_reason: + if pos.side == "SHORT": + if pnl_pct >= cfg.trailing_activation_pct: + pos.trailing_activated = True + if pos.trailing_activated and pos.lowest_price > 0: + trail_pct = 0.04 + trail_stop = pos.lowest_price * (1 + trail_pct) + if current_price >= trail_stop: + exit_reason = f"ES3 Short 트레일링 ({pnl_pct*100:+.1f}%)" + else: + if pnl_pct >= cfg.trailing_activation_pct: + pos.trailing_activated = True + if pos.trailing_activated: + trail_pct = 0.04 + trail_stop = pos.highest_price * (1 - trail_pct) + if current_price <= trail_stop: + exit_reason = f"ES3 Long 트레일링 ({pnl_pct*100:+.1f}%)" + + # ══ 순위 6: ES5 최대 보유 ══ + if not exit_reason and pos.days_held >= cfg.max_holding_days: + exit_reason = f"ES5 최대 보유 {cfg.max_holding_days}일 ({pnl_pct*100:+.1f}%)" + + if exit_reason: + exit_type = "STOP_LOSS" if pnl_pct < 0 else "TAKE_PROFIT" + engine._execute_sell(pos, current_price, exit_reason, exit_type) + to_close.append(code) + if pos.pair_id: + pair_close_reasons.setdefault(pos.pair_id, exit_reason) + # v2: 청산 시 쿨다운 등록 (BUG-3) + pair_state = engine._arb_pair_states.get(pos.pair_id, {}) + cd_a = pair_state.get("code_a", "") + cd_b = pair_state.get("code_b", "") + if cd_a and cd_b: + cooldown_key = f"{min(cd_a,cd_b)}-{max(cd_a,cd_b)}" + engine._arb_pair_cooldown[cooldown_key] = cfg.pair_cooldown_days + else: + # highest/lowest 갱신 + if pos.side == "SHORT": + if pos.lowest_price <= 0 or current_price < pos.lowest_price: + pos.lowest_price = current_price + else: + if current_price > pos.highest_price: + pos.highest_price = current_price + + # 페어 동시 청산: 한쪽이 청산되면 반대쪽도 청산 + for pair_id, reason in pair_close_reasons.items(): + for code, pos in engine.positions.items(): + if code in to_close: + continue + if pos.pair_id == pair_id and pos.status == "ACTIVE": + paired_reason = f"페어 동시 청산 ({reason})" + pnl_pct = (pos.entry_price - pos.current_price) / pos.entry_price if pos.side == "SHORT" else (pos.current_price - pos.entry_price) / pos.entry_price + exit_type = "STOP_LOSS" if pnl_pct < 0 else "TAKE_PROFIT" + engine._execute_sell(pos, pos.current_price, paired_reason, exit_type) + to_close.append(code) + + for code in to_close: + if code in engine.positions: + del engine.positions[code] diff --git a/ats/simulation/strategies/breakout_retest.py b/ats/simulation/strategies/breakout_retest.py new file mode 100644 index 0000000..309b1e3 --- /dev/null +++ b/ats/simulation/strategies/breakout_retest.py @@ -0,0 +1,704 @@ +""" +Breakout-Retest 2-Phase strategy — scan_entries / check_exits. + +Extracted from SimulationEngine to keep engine.py smaller. +All logic is identical; `self` is replaced with `engine`. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any, Dict, List + +import numpy as np +import pandas as pd + +from simulation.constants import ( + REGIME_PARAMS, REGIME_EXIT_PARAMS, +) +from simulation.models import SimSignal + +if TYPE_CHECKING: + from simulation.engine import SimulationEngine + + +# ══════════════════════════════════════════ +# BRT 지표 계산 +# ══════════════════════════════════════════ + +def calculate_indicators_breakout_retest(engine: "SimulationEngine", df: pd.DataFrame) -> pd.DataFrame: + """기존 지표 + SMC + OBV + ADX 통합 계산 (breakout_retest 전용).""" + df = engine._calculate_indicators(df) + if df.empty: + return df + + # SMC: Swing Points, BOS/CHoCH, Order Blocks, FVG + from analytics.indicators import calculate_smc + df = calculate_smc(df, swing_length=engine._brt_cfg.swing_length) + + # OBV (On Balance Volume) + c = df["close"].astype(float) + v = df["volume"].astype(float) + df["obv"] = (np.sign(c.diff()).fillna(0) * v).cumsum() + df["obv_ema5"] = df["obv"].ewm(span=5, adjust=False).mean() + df["obv_ema20"] = df["obv"].ewm(span=20, adjust=False).mean() + + return df + + +# ══════════════════════════════════════════ +# BRT 4-Layer 스코어링 +# ══════════════════════════════════════════ + +def score_brt_structure(engine: "SimulationEngine", df: pd.DataFrame) -> int: + """Layer 1: SMC 구조 스코어 (0~weight_structure). BOS + 유동성 스윕.""" + if len(df) < 10: + return 0 + + score = 0 + lookback = min(20, len(df)) + recent = df.iloc[-lookback:] + + markers = recent[recent["marker"].notna()] + if not markers.empty: + last_marker = markers.iloc[-1]["marker"] + if last_marker == "BOS_BULL": + score += 20 + elif last_marker == "CHOCH_BULL": + score += 15 + + # 유동성 스윕 + swing_lows = recent[recent["is_swing_low"] == True] + if not swing_lows.empty and len(df) >= 7: + last_sl = float(swing_lows.iloc[-1]["low"]) + recent_7 = df.iloc[-7:] + if (recent_7["low"] < last_sl).any(): + score += 10 + + return min(score, engine._brt_cfg.weight_structure) + + +def score_brt_volatility(engine: "SimulationEngine", df: pd.DataFrame) -> int: + """Layer 2: BB/ATR 변동성 스코어 (0~weight_volatility).""" + lookback = engine._brt_cfg.bb_squeeze_lookback + if len(df) < max(lookback, 50): + return 0 + + score = 0 + bb_width = df["bb_width"].dropna() + if len(bb_width) < lookback: + return 0 + + current_width = float(bb_width.iloc[-1]) + min_width = float(bb_width.iloc[-lookback:].min()) + bb_ema = float(bb_width.ewm(span=engine._brt_cfg.bb_squeeze_ema).mean().iloc[-1]) + + if min_width > 0 and current_width <= min_width * 1.1: + score += 15 + elif bb_ema > 0 and current_width < bb_ema: + score += 8 + + # ATR 압축 + atr_pct = df["atr_pct"].dropna() + if len(atr_pct) >= 50: + atr_avg = float(atr_pct.rolling(50).mean().iloc[-1]) + curr_atr = float(atr_pct.iloc[-1]) + if atr_avg > 0 and curr_atr < atr_avg * 0.8: + score += 5 + + return min(score, engine._brt_cfg.weight_volatility) + + +def score_brt_obv(engine: "SimulationEngine", df: pd.DataFrame) -> int: + """Layer 3: OBV 돌파 스코어 (0~weight_volume).""" + obv = df["obv"].dropna() + lb = engine._brt_cfg.obv_break_lookback + if len(obv) < lb + 1: + return 0 + + score = 0 + curr_obv = float(obv.iloc[-1]) + prev_obv_high = float(obv.iloc[-lb - 1:-1].max()) + + if curr_obv > prev_obv_high: + score += 15 + obv_ema5 = float(df["obv_ema5"].iloc[-1]) if pd.notna(df["obv_ema5"].iloc[-1]) else 0 + obv_ema20 = float(df["obv_ema20"].iloc[-1]) if pd.notna(df["obv_ema20"].iloc[-1]) else 0 + if obv_ema5 > obv_ema20: + score += 10 + + return min(score, engine._brt_cfg.weight_volume) + + +def score_brt_momentum(engine: "SimulationEngine", df: pd.DataFrame) -> int: + """Layer 4: ADX/MACD 모멘텀 스코어 (0~weight_momentum).""" + rising_bars = engine._brt_cfg.adx_rising_bars + if len(df) < rising_bars + 2: + return 0 + + score = 0 + curr = df.iloc[-1] + prev = df.iloc[-2] + + adx_series = df["adx"].dropna() + if len(adx_series) >= rising_bars + 1: + curr_adx = float(adx_series.iloc[-1]) + plus_di = float(curr.get("plus_di", 0)) if pd.notna(curr.get("plus_di")) else 0 + minus_di = float(curr.get("minus_di", 0)) if pd.notna(curr.get("minus_di")) else 0 + + if curr_adx > engine._brt_cfg.adx_threshold and plus_di > minus_di: + score += 8 + rising = True + for i in range(1, rising_bars + 1): + if float(adx_series.iloc[-i]) <= float(adx_series.iloc[-i - 1]): + rising = False + break + if rising: + score += 7 + + macd_hist = float(curr.get("macd_hist", 0)) if pd.notna(curr.get("macd_hist")) else 0 + prev_macd = float(prev.get("macd_hist", 0)) if pd.notna(prev.get("macd_hist")) else 0 + + if prev_macd <= 0 and macd_hist > 0: + score += 10 + elif macd_hist > 0 and macd_hist > prev_macd: + score += 5 + + return min(score, engine._brt_cfg.weight_momentum) + + +# ══════════════════════════════════════════ +# BRT 6조건 / 페이크아웃 필터 / 리테스트 존 +# ══════════════════════════════════════════ + +def check_brt_six_conditions(engine: "SimulationEngine", df: pd.DataFrame) -> tuple: + """6조건 검증 (sim). 최소 4개 필요.""" + met = [] + curr = df.iloc[-1] + cfg = engine._brt_cfg + + # C1: Volatility Squeeze + bb_width = df["bb_width"].dropna() + if len(bb_width) >= cfg.bb_squeeze_lookback: + curr_w = float(bb_width.iloc[-1]) + min_w = float(bb_width.iloc[-cfg.bb_squeeze_lookback:].min()) + if min_w > 0 and curr_w <= min_w * 1.2: + met.append("C1_SQUEEZE") + + # C2: Liquidity Sweep + swing_lows = df[df.get("is_swing_low", pd.Series(dtype=bool)) == True] + if not swing_lows.empty and len(df) >= 7: + last_sl = float(swing_lows.iloc[-1]["low"]) + recent = df.iloc[-7:] + if (recent["low"] < last_sl).any(): + met.append("C2_LIQ_SWEEP") + + # C3: Displacement + body = abs(float(curr["close"]) - float(curr["open"])) + atr = float(curr.get("atr", 0)) if pd.notna(curr.get("atr")) else 0 + if atr > 0 and body > atr * cfg.displacement_atr_mult: + met.append("C3_DISPLACEMENT") + + # C4: OBV Break + obv = df["obv"].dropna() + if len(obv) > cfg.obv_break_lookback: + if float(obv.iloc[-1]) > float(obv.iloc[-cfg.obv_break_lookback - 1:-1].max()): + met.append("C4_OBV_BREAK") + + # C5: ADX > threshold & rising + adx_s = df["adx"].dropna() + if len(adx_s) >= cfg.adx_rising_bars + 1: + if float(adx_s.iloc[-1]) > cfg.adx_threshold: + rising = all( + float(adx_s.iloc[-i]) > float(adx_s.iloc[-i - 1]) + for i in range(1, cfg.adx_rising_bars + 1) + ) + if rising: + met.append("C5_ADX_RISING") + + # C6: FVG Formation + fvg_recent = df.iloc[-3:] + if not fvg_recent[fvg_recent.get("fvg_type", pd.Series(dtype=str)) == "bull"].empty: + met.append("C6_FVG") + + return len(met) >= 3, met # Phase 5: 4/6→3/6 진입 기준 완화 + + +def apply_brt_fakeout_filters(engine: "SimulationEngine", df: pd.DataFrame) -> tuple: + """3개 페이크아웃 필터 (sim). (통과 여부, 차단 사유).""" + curr = df.iloc[-1] + cfg = engine._brt_cfg + + # ERR01: 저거래량 + volume = float(curr.get("volume", 0)) + vol_ma = float(curr.get("volume_ma", 1)) if pd.notna(curr.get("volume_ma")) else 1 + if vol_ma > 0 and volume < vol_ma * cfg.min_volume_ratio: + return False, "ERR01_LOW_VOLUME" + + # ERR02: 긴 윗꼬리 + close = float(curr["close"]) + open_p = float(curr["open"]) + high = float(curr["high"]) + body = abs(close - open_p) + upper_wick = high - max(close, open_p) + if body > 0 and upper_wick / body > cfg.max_wick_body_ratio: + return False, "ERR02_WICK_TRAP" + + # ERR03: MACD/RSI 다이버전스 + if cfg.divergence_check and len(df) >= 10: + price_curr = float(df["close"].iloc[-1]) + price_prev_max = float(df["close"].iloc[-10:-1].max()) + macd_curr = float(curr.get("macd_hist", 0)) if pd.notna(curr.get("macd_hist")) else 0 + macd_prev_max = float(df["macd_hist"].iloc[-10:-1].max()) if "macd_hist" in df.columns else 0 + rsi_curr = float(curr.get("rsi", 50)) if pd.notna(curr.get("rsi")) else 50 + rsi_prev_max = float(df["rsi"].iloc[-10:-1].max()) if "rsi" in df.columns else 50 + + if price_curr > price_prev_max and (macd_curr < macd_prev_max * 0.8 or rsi_curr < rsi_prev_max * 0.9): + return False, "ERR03_DIVERGENCE" + + return True, None + + +def capture_brt_retest_zones(engine: "SimulationEngine", df: pd.DataFrame, breakout_price: float, breakout_atr: float) -> Dict[str, Any]: + """돌파 시점 FVG/OB/레벨 존을 캡처해서 상태 dict로 반환.""" + cfg = engine._brt_cfg + state: Dict[str, Any] = { + "phase": "WAITING_RETEST", + "breakout_price": breakout_price, + "breakout_atr": breakout_atr, + "bars_since_breakout": 0, + "breakout_score": 0, + "fvg_top": 0.0, "fvg_bottom": 0.0, + "ob_top": 0.0, "ob_bottom": 0.0, + "breakout_level": 0.0, + "zone_top": 0.0, "zone_bottom": 0.0, + "zone_type": "LEVEL", + "conditions_met": [], + } + + recent = df.iloc[-20:] + + # FVG 존 캡처 + if cfg.use_fvg_zone: + fvg_rows = recent[(recent.get("fvg_type", pd.Series(dtype=str)) == "bull") & recent["fvg_top"].notna()] + if not fvg_rows.empty: + last_fvg = fvg_rows.iloc[-1] + state["fvg_top"] = float(last_fvg["fvg_top"]) + state["fvg_bottom"] = float(last_fvg["fvg_bottom"]) + + # OB 존 캡처 + if cfg.use_ob_zone: + ob_rows = recent[recent["ob_top"].notna()] + if not ob_rows.empty: + last_ob = ob_rows.iloc[-1] + state["ob_top"] = float(last_ob["ob_top"]) + state["ob_bottom"] = float(last_ob["ob_bottom"]) + + # 돌파 레벨 (마지막 swing high) + if cfg.use_breakout_level: + swing_highs = recent[recent.get("is_swing_high", pd.Series(dtype=bool)) == True] + if not swing_highs.empty: + state["breakout_level"] = float(swing_highs.iloc[-1]["high"]) + + # 복합 존 계산 + zone_candidates = [] + if state["fvg_bottom"] > 0: + zone_candidates.append((state["fvg_bottom"], state["fvg_top"])) + if state["ob_bottom"] > 0: + zone_candidates.append((state["ob_bottom"], state["ob_top"])) + if state["breakout_level"] > 0: + buffer = breakout_atr * cfg.retest_zone_atr_buffer + zone_candidates.append((state["breakout_level"] - buffer, state["breakout_level"])) + + if zone_candidates: + state["zone_bottom"] = min(z[0] for z in zone_candidates) + state["zone_top"] = max(z[1] for z in zone_candidates) + state["zone_type"] = "COMPOSITE" + else: + buffer = breakout_atr * cfg.retest_zone_atr_buffer + state["zone_bottom"] = breakout_price - breakout_atr - buffer + state["zone_top"] = breakout_price + state["zone_type"] = "LEVEL" + + return state + + +def score_brt_retest_zone(engine: "SimulationEngine", df: pd.DataFrame, state: Dict[str, Any]) -> int: + """리테스트 존 근접도 스코어링 (0-100).""" + price = float(df.iloc[-1]["close"]) + score = 0 + cfg = engine._brt_cfg + + # FVG 근접도 + fvg_b = state.get("fvg_bottom", 0) + fvg_t = state.get("fvg_top", 0) + if fvg_b > 0 and cfg.use_fvg_zone: + if fvg_b <= price <= fvg_t: + score += cfg.fvg_zone_weight + elif price < fvg_t and price > fvg_b - state.get("breakout_atr", 0) * 0.3: + score += cfg.fvg_zone_weight // 2 + + # OB 근접도 + ob_b = state.get("ob_bottom", 0) + ob_t = state.get("ob_top", 0) + if ob_b > 0 and cfg.use_ob_zone: + if ob_b <= price <= ob_t: + score += cfg.ob_zone_weight + elif price < ob_t and price > ob_b - state.get("breakout_atr", 0) * 0.3: + score += cfg.ob_zone_weight // 2 + + # 돌파 레벨 근접도 + bl = state.get("breakout_level", 0) + if bl > 0 and cfg.use_breakout_level: + buffer = state.get("breakout_atr", 0) * cfg.retest_zone_atr_buffer + if bl - buffer <= price <= bl + buffer: + score += cfg.level_zone_weight + + return min(score, 100) + + +# ══════════════════════════════════════════ +# BRT 진입 스캔 +# ══════════════════════════════════════════ + +def scan_entries(engine: "SimulationEngine"): + """ + Breakout-Retest 2-Phase 진입 스캔. + Pass 1: IDLE 티커 → Phase A 돌파 감지 + Pass 2: WAITING_RETEST 티커 → Phase B 리테스트 확인 + """ + # ── Phase 0: 시장 체제 판단 ── + engine._update_market_regime() + + # ── Phase 4: 리스크 게이트 ── + can_trade, block_reason = engine._risk_gate_check() + if not can_trade: + engine._phase_stats["phase4_risk_blocks"] += 1 + engine._add_risk_event("WARNING", f"BRT 진입 차단: {block_reason}") + return + + total_equity = engine._get_total_equity() + regime_params = REGIME_PARAMS.get(engine._market_regime, REGIME_PARAMS["NEUTRAL"]) + + active_count = len([p for p in engine.positions.values() if p.status == "ACTIVE"]) + + # ── Pass 1: IDLE 티커에서 돌파 감지 ── + for w in engine._watchlist: + code = w["code"] + + + if code in engine.positions and engine.positions[code].status in ("ACTIVE", "PENDING"): + continue + + # 이미 WAITING_RETEST 상태면 Pass 1 스킵 + if code in engine._breakout_states and engine._breakout_states[code].get("phase") == "WAITING_RETEST": + continue + + df = engine._ohlcv_cache.get(code) + if df is None or len(df) < max(engine._brt_cfg.bb_squeeze_lookback, 50): + continue + + df = calculate_indicators_breakout_retest(engine, df.copy()) + if df.empty or len(df) < 2: + continue + engine._ohlcv_cache[code] = df + + engine._phase_stats["total_scans"] += 1 + + # 4-Layer 스코어링 + score_s = score_brt_structure(engine, df) + score_v = score_brt_volatility(engine, df) + score_o = score_brt_obv(engine, df) + score_m = score_brt_momentum(engine, df) + total_score = score_s + score_v + score_o + score_m + + if total_score < engine._brt_cfg.breakout_threshold: + engine._phase_stats["phase3_no_primary"] += 1 + continue + + # 6조건 검증 + conditions_ok, met_list = check_brt_six_conditions(engine, df) + if not conditions_ok: + engine._phase_stats["phase3_no_confirm"] += 1 + continue + + # 3개 페이크아웃 필터 + filter_ok, block_reason = apply_brt_fakeout_filters(engine, df) + if not filter_ok: + engine._phase_stats["brt_fakeout_blocked"] += 1 + continue + + # 돌파 확인 → WAITING_RETEST 전이 + curr = df.iloc[-1] + breakout_price = float(curr["close"]) + breakout_atr = float(curr.get("atr", breakout_price * 0.03)) if pd.notna(curr.get("atr")) else breakout_price * 0.03 + + state = capture_brt_retest_zones(engine, df, breakout_price, breakout_atr) + state["breakout_score"] = total_score + state["conditions_met"] = met_list + engine._breakout_states[code] = state + engine._phase_stats["brt_breakouts_detected"] += 1 + + engine._add_risk_event( + "INFO", + f"돌파 감지: {w['name']} score={total_score} [S:{score_s} V:{score_v} O:{score_o} M:{score_m}]" + ) + + # ── Pass 2: WAITING_RETEST 티커에서 리테스트 진입 확인 ── + new_signals: List[tuple] = [] + expired_codes = [] + + for code, state in list(engine._breakout_states.items()): + if state.get("phase") != "WAITING_RETEST": + continue + if code in engine.positions and engine.positions[code].status in ("ACTIVE", "PENDING"): + continue + + df = engine._ohlcv_cache.get(code) + if df is None or len(df) < 2: + continue + + # 지표가 이미 계산되어 있지 않으면 재계산 + if "obv" not in df.columns: + df = calculate_indicators_breakout_retest(engine, df.copy()) + engine._ohlcv_cache[code] = df + + curr = df.iloc[-1] + price = float(curr["close"]) + low = float(curr["low"]) + + state["bars_since_breakout"] = state.get("bars_since_breakout", 0) + 1 + + # 만료 체크 + if state["bars_since_breakout"] > engine._brt_cfg.retest_max_bars: + state["phase"] = "IDLE" + expired_codes.append(code) + engine._phase_stats["brt_retests_expired"] += 1 + continue + + # 존 하단 이탈 → 실패 + if price < state["zone_bottom"]: + state["phase"] = "IDLE" + expired_codes.append(code) + continue + + # 존 도달 확인 + in_zone = low <= state["zone_top"] and price >= state["zone_bottom"] + if not in_zone: + continue + + # ── 확인 조건 (3개 중 2개 이상) ── + confirmations = 0 + confirm_parts = [] + + # 1. 거래량 감소 + volume = float(curr.get("volume", 0)) + vol_ma = float(curr.get("volume_ma", 1)) if pd.notna(curr.get("volume_ma")) else 1 + if vol_ma > 0 and volume < vol_ma * engine._brt_cfg.retest_volume_decay: + confirmations += 1 + confirm_parts.append("VOL_DECAY") + + # 2. 반등 캔들 + open_p = float(curr["open"]) + body = abs(price - open_p) + lower_wick = min(price, open_p) - low + bullish_rejection = body > 0 and lower_wick > body * engine._brt_cfg.retest_rejection_wick_ratio + bullish_close = price > open_p + if bullish_rejection or bullish_close: + confirmations += 1 + confirm_parts.append("REJECTION" if bullish_rejection else "BULL_CLOSE") + + # 3. RSI 지지 + rsi = float(curr.get("rsi", 50)) if pd.notna(curr.get("rsi")) else 50 + if rsi >= engine._brt_cfg.retest_rsi_floor: + confirmations += 1 + confirm_parts.append(f"RSI_{int(rsi)}") + + if confirmations < 2: + continue + + # 존 스코어링 + zone_score = score_brt_retest_zone(engine, df, state) + if zone_score < engine._brt_cfg.retest_zone_threshold: + continue + + # ── 리테스트 진입 확인 ── + strength = min(state.get("breakout_score", 60) + zone_score // 2, 100) + stock_name = engine._stock_names.get(code, code) + + engine._signal_counter += 1 + signal = SimSignal( + id=f"sim-sig-{engine._signal_counter:04d}", + stock_code=code, + stock_name=stock_name, + type="BUY", + price=engine._current_prices.get(code, price), + reason=f"BRT_RETEST_{strength} [BKO:{state.get('breakout_score', 0)} ZONE:{zone_score} {'+'.join(confirm_parts)}]", + strength=strength, + detected_at=engine._get_current_iso(), + ) + new_signals.append((signal, "MODERATE", "MID", 3)) + + state["phase"] = "IDLE" # 사용된 상태 리셋 + engine._phase_stats["brt_retests_entered"] += 1 + + # 만료된 상태 정리 + for code in expired_codes: + if code in engine._breakout_states and engine._breakout_states[code].get("phase") == "IDLE": + del engine._breakout_states[code] + + # 시그널 강도순 정렬 후 매수 실행 + new_signals.sort(key=lambda x: x[0].strength, reverse=True) + + for sig, trend_strength, trend_stage, align_score in new_signals: + if active_count >= regime_params["max_positions"]: + break + engine.signals.append(sig) + if len(engine.signals) > 100: + engine.signals = engine.signals[-100:] + + engine._execute_buy(sig, trend_strength=trend_strength, + trend_stage=trend_stage, alignment_score=align_score) + engine._phase_stats["entries_executed"] += 1 + active_count += 1 + + +# ══════════════════════════════════════════ +# BRT 청산 로직 +# ══════════════════════════════════════════ + +def check_exits(engine: "SimulationEngine"): + """ + Breakout-Retest 전용 청산 체크. + ES1(-5%) > ATR SL(1.5x) > ATR TP(3.0x) > CHoCH > ES3 트레일링 > Zone Break > ES5 보유기간 > ES7 리밸런스 + """ + to_close: List[str] = [] + + for code, pos in engine.positions.items(): + if pos.status != "ACTIVE": + continue + if engine._exit_tag_filter and pos.strategy_tag != engine._exit_tag_filter: + continue + # 글로벌 레짐 기반 청산 파라미터 (종목별 레짐은 analytics용) + regime_exit = REGIME_EXIT_PARAMS.get(engine._market_regime, REGIME_EXIT_PARAMS["NEUTRAL"]) + + + current_price = engine._current_prices.get(code, pos.current_price) + entry_price = pos.entry_price + pnl_pct = (current_price - entry_price) / entry_price + + exit_reason = None + exit_type = None + + # ATR 조회 + atr_val = None + df = engine._ohlcv_cache.get(code) + if df is not None and len(df) > 14: + if "atr" not in df.columns: + df = engine._calculate_indicators(df.copy()) + engine._ohlcv_cache[code] = df + last_atr = df.iloc[-1].get("atr") + if pd.notna(last_atr): + atr_val = float(last_atr) + + # ES1: 손절 -5% (GAP DOWN 보호: _execute_sell에서 fill price 조정) + if current_price <= entry_price * (1 + engine.stop_loss_pct): + exit_reason = "ES1 손절 -5%" + exit_type = "STOP_LOSS" + + # ES_BRT_SL: ATR × 1.5 (2일 쿨다운) + elif atr_val and atr_val > 0: + atr_sl_price = entry_price - atr_val * engine._brt_cfg.atr_sl_mult + floor_sl_price = entry_price * (1 + engine.stop_loss_pct) + effective_sl = max(atr_sl_price, floor_sl_price) + + if current_price <= effective_sl and effective_sl > floor_sl_price: + exit_reason = "ES_BRT ATR SL (1.5x)" + exit_type = "ATR_STOP_LOSS" + + # ES_BRT_TP: ATR × 3.0 + if not exit_reason: + atr_tp_price = entry_price + atr_val * engine._brt_cfg.atr_tp_mult + if current_price >= atr_tp_price: + exit_reason = "ES_BRT ATR TP (3.0x)" + exit_type = "ATR_TAKE_PROFIT" + + # ES_CHOCH: 추세 반전 감지 (Phase 5: PnL 게이트) + if not exit_reason and engine._brt_cfg.choch_exit and df is not None and len(df) > 10: + choch_pnl_gate = pnl_pct < -0.02 or pnl_pct > 0.05 + if choch_pnl_gate: + df_calc = calculate_indicators_breakout_retest(engine, df.copy()) + recent_markers = df_calc.iloc[-5:] + for _, row in recent_markers.iterrows(): + if row.get("marker") == "CHOCH_BEAR": + exit_reason = "ES_CHOCH 추세반전" + exit_type = "CHOCH_EXIT" + break + + # ES3: 트레일링 스탑 (+5% 활성화, ATR × 2.0) + if not exit_reason: + if pnl_pct >= engine._brt_cfg.trailing_activation_pct: + if not pos.trailing_activated: + pos.trailing_activated = True + # ATR 기반 트레일링 + trail_pct = engine.trailing_stop_pct # 기본 -4% + if atr_val and atr_val > 0: + atr_trail = -(atr_val * engine._brt_cfg.trailing_atr_mult) / entry_price + trail_pct = max(atr_trail, engine.trailing_stop_pct) + trailing_stop_price = pos.highest_price * (1 + trail_pct) + if current_price <= trailing_stop_price: + exit_reason = "ES3 트레일링스탑" + exit_type = "TRAILING_STOP" + + # ES_ZONE_BREAK: 리테스트 존 무효화 (존 하단 이탈 시 청산) + if not exit_reason and code in engine._breakout_states: + brt_state = engine._breakout_states[code] + zone_bottom = brt_state.get("zone_bottom", 0) + if zone_bottom > 0 and current_price < zone_bottom: + exit_reason = "ES_ZONE_BREAK 존 무효화" + exit_type = "ZONE_BREAK" + + # ES5: 보유기간 초과 + max_hold = min(engine._brt_cfg.max_holding_days, regime_exit["max_holding"]) + if not exit_reason and pos.days_held > max_hold: + exit_reason = "ES5 보유기간 초과" + exit_type = "MAX_HOLDING" + + # ES7: 리밸런스 청산 (PnL 게이트: 수익 중이면 유예) + if not exit_reason and code in engine._rebalance_exit_codes: + if pos.days_held < 3 or pnl_pct <= -0.02: + exit_reason = "ES7 리밸런스 청산" + exit_type = "REBALANCE_EXIT" + engine._rebalance_exit_codes.discard(code) + elif pnl_pct > 0.02: + # 수익 +2%+ → 다음 리밸런스까지 유예 + engine._rebalance_exit_codes.discard(code) + else: + exit_reason = "ES7 리밸런스 청산" + exit_type = "REBALANCE_EXIT" + engine._rebalance_exit_codes.discard(code) + + if exit_reason: + to_close.append(code) + # Phase 통계 + exit_stat_map = { + "EMERGENCY_STOP": "es0_emergency_stop", + "STOP_LOSS": "es1_stop_loss", + "ATR_STOP_LOSS": "es_brt_sl", + "ATR_TAKE_PROFIT": "es_brt_tp", + "CHOCH_EXIT": "es_choch_exit", + "TRAILING_STOP": "es3_trailing_stop", + "ZONE_BREAK": "es_zone_break", + "MAX_HOLDING": "es5_max_holding", + "REBALANCE_EXIT": "es7_rebalance_exit", + } + stat_key = exit_stat_map.get(exit_type or "") + if stat_key and stat_key in engine._phase_stats: + engine._phase_stats[stat_key] += 1 + engine._execute_sell(pos, current_price, exit_reason, exit_type or "") + else: + # 트레일링 최고가 갱신 + if current_price > pos.highest_price: + pos.highest_price = current_price + + for code in to_close: + del engine.positions[code] diff --git a/ats/simulation/strategies/defensive.py b/ats/simulation/strategies/defensive.py new file mode 100644 index 0000000..c1207ba --- /dev/null +++ b/ats/simulation/strategies/defensive.py @@ -0,0 +1,158 @@ +""" +Defensive strategy — scan_entries / check_exits. + +Extracted from SimulationEngine to keep engine.py smaller. +All logic is identical; `self` is replaced with `engine`. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, List + +import pandas as pd + +from simulation.constants import ( + REGIME_OVERRIDES, INVERSE_ETFS, SAFE_HAVEN_ETFS, +) +from simulation.models import SimSignal + +if TYPE_CHECKING: + from simulation.engine import SimulationEngine + + +def scan_entries(engine: "SimulationEngine"): + """ + Defensive 전략: BEAR/RANGE_BOUND 레짐 시 인버스 ETF + 안전자산 매수. + + 조건: + - 레짐이 BEAR/CRISIS 또는 (RANGE_BOUND/NEUTRAL AND VIX > threshold) + - 인버스 ETF / 안전자산 OHLCV 데이터 존재 + - 이미 보유 중이 아닌 것 + """ + # 레짐별 VIX 진입 임계값 (BEAR: 20, 기본: 25) + _def_ro = REGIME_OVERRIDES.get(engine._market_regime, {}) + vix_threshold = _def_ro.get("defensive_vix_threshold", 25) + + # STRONG_BULL/BULL에서는 진입하지 않음 + if engine._market_regime in ("STRONG_BULL", "BULL"): + return + if engine._market_regime == "NEUTRAL" and engine._vix_ema20 < vix_threshold: + return + + # 마켓에 맞는 인버스 ETF 목록 + market_key = "sp500" # 기본값 + if "kospi" in engine.market_id.lower(): + market_key = "kospi" + elif "nasdaq" in engine.market_id.lower(): + market_key = "nasdaq" + + # 인버스 ETF + CRISIS 안전자산 합산 + defensive_tickers = list(INVERSE_ETFS.get(market_key, [])) + if _def_ro.get("safe_haven_enabled"): + for item in SAFE_HAVEN_ETFS.get(market_key, []): + ticker = item["ticker"] + if ticker not in defensive_tickers: + defensive_tickers.append(ticker) + + if not defensive_tickers: + return + + for ticker in defensive_tickers: + # 이미 보유 중이면 스킵 + if ticker in engine.positions and engine.positions[ticker].status == "ACTIVE": + continue + + df = engine._ohlcv_cache.get(ticker) + if df is None or len(df) < 20: + continue + + price = float(df.iloc[-1]["close"]) + if price <= 0: + continue + + # 시그널 생성 (고정 strength — defensive는 레짐 기반) + strength = 70 if engine._market_regime in ("BEAR", "CRISIS") else 50 + if engine._vix_ema20 > 30: + strength += 10 + # CRISIS 안전자산은 추가 강도 + is_safe_haven = any( + item["ticker"] == ticker + for item in SAFE_HAVEN_ETFS.get(market_key, []) + ) + if is_safe_haven and engine._market_regime == "CRISIS": + strength += 15 + + ticker_name = f"SafeHaven_{ticker}" if is_safe_haven else f"Inv_{ticker}" + + engine._signal_counter += 1 + signal = SimSignal( + id=f"sim-sig-{engine._signal_counter:04d}", + stock_code=ticker, + stock_name=ticker_name, + type="BUY", + price=price, + strength=strength, + reason=f"Defensive: {engine._market_regime} regime, VIX={engine._vix_ema20:.1f}", + detected_at=engine._get_current_iso(), + ) + engine._execute_buy( + signal, + trend_strength="MODERATE", + trend_stage="MID", + alignment_score=3, + ) + + +def check_exits(engine: "SimulationEngine"): + """ + Defensive 전략 청산: 레짐이 BULL로 전환되면 청산. + 또는 일반 ES1 손절(-5%) 적용. + """ + to_close: List[str] = [] + + for code, pos in engine.positions.items(): + if pos.status != "ACTIVE": + continue + if engine._exit_tag_filter and pos.strategy_tag != engine._exit_tag_filter: + continue + + current_price = engine._current_prices.get(code, pos.current_price) + entry_price = pos.entry_price + pnl_pct = (current_price - entry_price) / entry_price + + exit_reason = None + exit_type = None + + # ES1: 하드 손절 -5% + if pnl_pct <= -0.05: + exit_reason = "ES1: 손절 -5%" + exit_type = "STOP_LOSS" + + # 레짐이 BULL로 전환 → 인버스 청산 + elif engine._market_regime == "BULL": + exit_reason = "DEF_REGIME: BULL 전환 청산" + exit_type = "REGIME_EXIT" + + # 익절: +10% (인버스는 보수적 TP) + elif pnl_pct >= 0.10: + exit_reason = "DEF_TP: 익절 +10%" + exit_type = "TAKE_PROFIT" + + # 트레일링: +5% 이상이면 2×ATR 트레일링 + elif pnl_pct >= 0.05: + df = engine._ohlcv_cache.get(code) + if df is not None and "atr" in df.columns and len(df) > 0: + atr_val = float(df.iloc[-1].get("atr", 0)) + if atr_val > 0: + trail_stop = pos.highest_price - 2.0 * atr_val + if current_price <= trail_stop: + exit_reason = f"DEF_TRAIL: 트레일링 (ATR×2.0)" + exit_type = "TRAILING_STOP" + + if exit_reason: + to_close.append(code) + engine._close_position(code, current_price, exit_reason, exit_type) + + for code in to_close: + if code in engine.positions: + del engine.positions[code] diff --git a/ats/simulation/strategies/mean_reversion.py b/ats/simulation/strategies/mean_reversion.py new file mode 100644 index 0000000..07333f5 --- /dev/null +++ b/ats/simulation/strategies/mean_reversion.py @@ -0,0 +1,499 @@ +""" +Mean Reversion strategy — indicators, scoring, scan_entries, check_exits. + +Extracted from SimulationEngine to keep engine.py smaller. +All logic is identical; `self` is replaced with `engine`. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, List + +import numpy as np +import pandas as pd + +from simulation.constants import ( + REGIME_PARAMS, REGIME_EXIT_PARAMS, REGIME_OVERRIDES, +) +from simulation.models import SimSignal + +if TYPE_CHECKING: + from simulation.engine import SimulationEngine + + +# ────────────────────────────────────────────────────────── +# Indicator calculation +# ────────────────────────────────────────────────────────── + +def calculate_indicators_mean_reversion(engine: "SimulationEngine", df: pd.DataFrame) -> pd.DataFrame: + """기존 지표 + MA200 + Stochastic + 연속하락일 계산.""" + df = engine._calculate_indicators(df) + if df.empty: + return df + + c = df["close"].astype(float) + h = df["high"].astype(float) + lo = df["low"].astype(float) + + # Stochastic %K/%D + k_period = engine._mr_cfg.stochastic_k_period + d_period = engine._mr_cfg.stochastic_d_period + lowest_low = lo.rolling(window=k_period).min() + highest_high = h.rolling(window=k_period).max() + denom = (highest_high - lowest_low).replace(0, np.nan) + df["stoch_k"] = 100 * (c - lowest_low) / denom + df["stoch_d"] = df["stoch_k"].rolling(window=d_period).mean() + + # 연속 하락일 카운터 + daily_return = c.pct_change() + is_down = (daily_return < 0).astype(int) + consec = [] + count = 0 + for val in is_down: + if val == 1: + count += 1 + else: + count = 0 + consec.append(count) + df["consecutive_down_days"] = consec + + # Phase 5: MA50 for MR TP target + if len(df) >= 50: + df["ma50"] = c.rolling(window=50).mean() + + return df + + +# ────────────────────────────────────────────────────────── +# 3-Layer scoring (pure functions using df + engine._mr_cfg) +# ────────────────────────────────────────────────────────── + +def score_mr_signal(engine: "SimulationEngine", df: pd.DataFrame) -> int: + """Layer 1: MR Signal (0~weight_signal). Graduated RSI + BB proximity + MA200.""" + if len(df) < 200: + return 0 + + score = 0 + curr = df.iloc[-1] + price = float(curr["close"]) + + # Graduated RSI scoring (바이너리 → 단계적) + rsi = float(curr.get("rsi", 50)) if pd.notna(curr.get("rsi")) else 50 + if rsi < 25: + score += 20 # 강한 과매도 + elif rsi < 35: + score += 15 # 중간 과매도 + elif rsi < 42: + score += 10 # 경미한 과매도 + + # Graduated BB Lower proximity (breach + 근접) + bb_lower = float(curr.get("bb_lower", 0)) if pd.notna(curr.get("bb_lower")) else 0 + if bb_lower > 0: + if price < bb_lower: + score += 15 # BB 하단 돌파 (강한 시그널) + elif price < bb_lower * 1.01: + score += 8 # BB 하단 1% 이내 근접 + + # MA200 위 = 장기 상승 추세 안에서의 pullback (건강한 MR) + ma200 = float(curr.get("ma200", 0)) if pd.notna(curr.get("ma200")) else 0 + if ma200 > 0 and price > ma200: + score += 5 + + return min(score, engine._mr_cfg.weight_signal) + + +def score_mr_volatility(engine: "SimulationEngine", df: pd.DataFrame) -> int: + """Layer 2: Volatility & Volume (0~weight_volatility). Graduated scoring.""" + if len(df) < 30: + return 0 + + score = 0 + curr = df.iloc[-1] + + # BB Width 확장 (변동성 증가 = 평균 회귀 기회) + bb_width = float(curr.get("bb_width", 0)) if pd.notna(curr.get("bb_width")) else 0 + bb_width_avg = df["bb_width"].rolling(window=20).mean() + bb_avg_val = float(bb_width_avg.iloc[-1]) if pd.notna(bb_width_avg.iloc[-1]) else bb_width + if bb_avg_val > 0: + if bb_width > bb_avg_val * 1.5: + score += 12 # 강한 변동성 확장 + elif bb_width > bb_avg_val * 1.2: + score += 8 # 보통 확장 + elif bb_width > bb_avg_val * 1.0: + score += 4 # 약간 확장 + + # Graduated volume scoring (2.0x → 1.5x/1.2x 단계) + curr_vol = float(curr.get("volume", 0)) if pd.notna(curr.get("volume")) else 0 + vol_ma = float(curr.get("volume_ma", 1)) if pd.notna(curr.get("volume_ma")) else 1 + if vol_ma > 0: + vol_ratio = curr_vol / vol_ma + if vol_ratio > 2.0: + score += 10 # 강한 볼륨 스파이크 (capitulation) + elif vol_ratio > engine._mr_cfg.volume_spike_mult: + score += 7 # 보통 스파이크 + elif vol_ratio > 1.2: + score += 4 # 약한 볼륨 증가 + + # ATR 확장 (패닉 셀오프 감지) + atr = float(curr.get("atr", 0)) if pd.notna(curr.get("atr")) else 0 + atr_ma = df["atr"].rolling(window=20).mean() + atr_avg_val = float(atr_ma.iloc[-1]) if pd.notna(atr_ma.iloc[-1]) else atr + if atr_avg_val > 0 and atr > atr_avg_val * 1.3: + score += 8 + + return min(score, engine._mr_cfg.weight_volatility) + + +def score_mr_confirmation(engine: "SimulationEngine", df: pd.DataFrame) -> int: + """Layer 3: Confirmation (0~weight_confirmation). MACD slope + Stochastic + 연속하락.""" + if len(df) < 30: + return 0 + + score = 0 + curr = df.iloc[-1] + prev = df.iloc[-2] + + # MACD: zero cross (+10) OR slope positive (+5) + macd_hist = float(curr.get("macd_hist", 0)) if pd.notna(curr.get("macd_hist")) else 0 + prev_macd = float(prev.get("macd_hist", 0)) if pd.notna(prev.get("macd_hist")) else 0 + if prev_macd <= 0 and macd_hist > 0: + score += 10 # zero cross (강한 반전 시그널) + elif macd_hist > prev_macd and macd_hist < 0: + score += 5 # slope positive (하락세 둔화) + + # Stochastic: graduated (K<20 → +10, K<30 → +5, K<20 GC bonus +5) + stoch_k = float(curr.get("stoch_k", 50)) if pd.notna(curr.get("stoch_k")) else 50 + stoch_d = float(curr.get("stoch_d", 50)) if pd.notna(curr.get("stoch_d")) else 50 + prev_stoch_k = float(prev.get("stoch_k", 50)) if pd.notna(prev.get("stoch_k")) else 50 + prev_stoch_d = float(prev.get("stoch_d", 50)) if pd.notna(prev.get("stoch_d")) else 50 + if stoch_k < 20: + score += 8 # 강한 과매도 + if prev_stoch_k <= prev_stoch_d and stoch_k > stoch_d: + score += 4 # golden cross 보너스 + elif stoch_k < 30: + score += 5 # 보통 과매도 + + # 연속 하락일: graduated (>=2 → +5, >=3 → +8, >=5 → +10) + consec_down = int(curr.get("consecutive_down_days", 0)) + if consec_down >= 5: + score += 10 # 장기 하락 (강한 MR 후보) + elif consec_down >= engine._mr_cfg.consecutive_down_days + 1: + score += 8 # 3일 연속 하락 + elif consec_down >= engine._mr_cfg.consecutive_down_days: + score += 5 # 2일 연속 하락 + + return min(score, engine._mr_cfg.weight_confirmation) + + +# ────────────────────────────────────────────────────────── +# Entry scan +# ────────────────────────────────────────────────────────── + +def scan_entries(engine: "SimulationEngine"): + """ + Mean Reversion 3-Layer 스코어링 기반 진입 스캔. + Phase 0 (시장 체제) + Phase 4 (리스크 게이트) → 레짐 필터 → MR 스코어링 → 매수 실행. + """ + # ── Phase 0: 시장 체제 판단 ── + engine._update_market_regime() + + # ── Phase 4: 리스크 게이트 ── + can_trade, block_reason = engine._risk_gate_check() + if not can_trade: + engine._phase_stats["phase4_risk_blocks"] += 1 + engine._add_risk_event("WARNING", f"MR 진입 차단: {block_reason}") + return + + total_equity = engine._get_total_equity() + regime_params = REGIME_PARAMS.get(engine._market_regime, REGIME_PARAMS["NEUTRAL"]) + + active_count = len([p for p in engine.positions.values() if p.status == "ACTIVE"]) + + if active_count >= regime_params["max_positions"]: + return + + new_signals: List[tuple] = [] + + for w in engine._watchlist: + code = w["code"] + + # 기존 보유 종목 체크 — MR 수익 +3%, 3일+ 보유, 미스케일 → 추가 진입 허용 + is_scale = False + if code in engine.positions and engine.positions[code].status in ("ACTIVE", "PENDING"): + pos = engine.positions[code] + if (pos.strategy_tag == "mean_reversion" + and pos.scale_count < 1 + and pos.days_held >= 3): + cur_px = engine._current_prices.get(code, pos.current_price) + eff_entry = pos.avg_entry_price if pos.avg_entry_price > 0 else pos.entry_price + if (cur_px - eff_entry) / eff_entry >= 0.03: + is_scale = True # Fall through to scoring + else: + continue + else: + continue + + df = engine._ohlcv_cache.get(code) + if df is None or len(df) < 200: + continue + + df = calculate_indicators_mean_reversion(engine, df.copy()) + if df.empty or len(df) < 2: + continue + + engine._phase_stats["total_scans"] += 1 + curr = df.iloc[-1] + + # 레짐 필터: ADX < 25 (비추세) OR 극도 과매도 + # NEUTRAL 레짐: 더 엄격한 ADX 제한 (25→22) + _mr_ro = REGIME_OVERRIDES.get(engine._market_regime, {}) + effective_adx_limit = _mr_ro.get("mr_adx_limit", engine._mr_cfg.adx_trending_limit) + adx = float(curr.get("adx", 0)) if pd.notna(curr.get("adx")) else 0 + rsi = float(curr.get("rsi", 50)) if pd.notna(curr.get("rsi")) else 50 + if adx >= effective_adx_limit and rsi >= engine._mr_cfg.extreme_oversold_rsi: + engine._phase_stats["phase1_trend_rejects"] += 1 + continue + + # RANGE_BOUND 레짐: 지지선 근처에서만 MR 진입 + if _mr_ro.get("sr_zone_entry") and not is_scale: + sr = engine._detect_support_resistance(df, lookback=_mr_ro.get("box_lookback", 40)) + _current_px = engine._current_prices.get(code, float(curr["close"])) + atr_buf = float(curr.get("atr", 0)) if pd.notna(curr.get("atr")) else 0 + buffer = atr_buf * _mr_ro.get("sr_atr_buffer", 1.5) + near_support = any(abs(_current_px - s) < buffer for s in sr["support"]) if sr["support"] else False + if not near_support and sr["support"]: + continue # 지지선 근처가 아니면 진입 차단 + + # Phase 5: 반전 확인 캔들 — 전일 양봉 필수 (스케일업은 면제) + if not is_scale and len(df) >= 2: + prev = df.iloc[-2] + prev_close = float(prev["close"]) if pd.notna(prev.get("close")) else 0 + prev_open = float(prev["open"]) if pd.notna(prev.get("open")) else 0 + if prev_close <= prev_open: # 전일 음봉 → 반전 미확인 + continue + + # 3-Layer 스코어링 + score_signal = score_mr_signal(engine, df) + score_vol = score_mr_volatility(engine, df) + score_confirm = score_mr_confirmation(engine, df) + total_score = score_signal + score_vol + score_confirm + + if total_score < engine._mr_cfg.entry_threshold: + engine._phase_stats["phase3_no_primary"] += 1 + continue + + current_price = engine._current_prices.get(code, float(curr["close"])) + + # 스케일업: 시그널 강도 50% 감소, 라벨 변경 + scale_label = "MR_SCALE" if is_scale else "MR" + effective_strength = min(int(total_score * 0.5), 50) if is_scale else min(total_score, 100) + + engine._signal_counter += 1 + signal = SimSignal( + id=f"sim-sig-{engine._signal_counter:04d}", + stock_code=code, + stock_name=w["name"], + type="BUY", + price=current_price, + reason=f"{scale_label}_{total_score} [L1:{score_signal} L2:{score_vol} L3:{score_confirm}]", + strength=effective_strength, + detected_at=engine._get_current_iso(), + ) + new_signals.append((signal, "MODERATE", "MID", 3)) + + engine._phase_stats["mr_total_score"] += total_score + engine._phase_stats["mr_entries"] += 1 + + # 시그널 강도순 정렬 후 매수 실행 + new_signals.sort(key=lambda x: x[0].strength, reverse=True) + + for sig, trend_strength, trend_stage, align_score in new_signals: + if active_count >= regime_params["max_positions"]: + break + engine.signals.append(sig) + if len(engine.signals) > 100: + engine.signals = engine.signals[-100:] + + engine._execute_buy(sig, trend_strength=trend_strength, + trend_stage=trend_stage, alignment_score=align_score) + engine._phase_stats["entries_executed"] += 1 + active_count += 1 + + +# ────────────────────────────────────────────────────────── +# Exit check +# ────────────────────────────────────────────────────────── + +def check_exits(engine: "SimulationEngine"): + """ + Mean Reversion 전용 7-Priority 청산 체크. + ES1(-5%) > ATR SL > MR TP(MA20/RSI>60) > BB Mid > Trailing > Overbought > Max Holding > ES7 + """ + to_close: List[str] = [] + + for code, pos in engine.positions.items(): + if pos.status != "ACTIVE": + continue + if engine._exit_tag_filter and pos.strategy_tag != engine._exit_tag_filter: + continue + # 글로벌 레짐 기반 청산 파라미터 (종목별 레짐은 analytics용) + regime_exit = REGIME_EXIT_PARAMS.get(engine._market_regime, REGIME_EXIT_PARAMS["NEUTRAL"]) + + + current_price = engine._current_prices.get(code, pos.current_price) + entry_price = pos.entry_price + # 스케일업된 포지션은 가중평균 매입가 기준 PnL 계산 + effective_entry = pos.avg_entry_price if pos.avg_entry_price > 0 else entry_price + pnl_pct = (current_price - effective_entry) / effective_entry + + exit_reason = None + exit_type = None + + # ATR / RSI / BB 조회 + atr_val = None + rsi_val = None + bb_mid = None + ma20 = None + df = engine._ohlcv_cache.get(code) + if df is not None and len(df) > 14: + if "atr" not in df.columns or "stoch_k" not in df.columns: + df = calculate_indicators_mean_reversion(engine, df.copy()) + engine._ohlcv_cache[code] = df + last = df.iloc[-1] + if pd.notna(last.get("atr")): + atr_val = float(last["atr"]) + if pd.notna(last.get("rsi")): + rsi_val = float(last["rsi"]) + if pd.notna(last.get("bb_middle")): + bb_mid = float(last["bb_middle"]) + if pd.notna(last.get("ma_long")): + ma20 = float(last["ma_long"]) + + # ES1: 손절 -5% (GAP DOWN 보호: _execute_sell에서 fill price 조정) + if current_price <= entry_price * (1 + engine.stop_loss_pct): + exit_reason = "ES1 손절 -5%" + exit_type = "STOP_LOSS" + + # ATR SL (2일 쿨다운) + elif atr_val and atr_val > 0: + atr_sl_price = entry_price - atr_val * engine._mr_cfg.atr_sl_mult + floor_sl = entry_price * (1 + engine.stop_loss_pct) + effective_sl = max(atr_sl_price, floor_sl) + if current_price <= effective_sl and effective_sl > floor_sl: + exit_reason = "ES_MR ATR SL" + exit_type = "ATR_STOP_LOSS" + + # Phase 5.4: MR TP 상향 — MA50+RSI>55 (더 큰 반등 포착) + # 기존 MA20+RSI>50 → 너무 일찍 청산 (2-3% 수익), SL -5%와 R:R 불균형 + ma50 = None + if df is not None and len(df) > 50: + last = df.iloc[-1] + if pd.notna(last.get("ma50")): + ma50 = float(last["ma50"]) + elif pd.notna(last.get("ma60")): + ma50 = float(last["ma60"]) # MA50 없으면 MA60 대체 + + if not exit_reason and ma50 and rsi_val is not None: + if current_price > ma50 and rsi_val > 55: + exit_reason = "ES_MR TP (MA50+RSI>55)" + exit_type = "MEAN_REVERSION_TP" + + # RSI > 65 제거 — 너무 이른 청산. 대신 RSI > 70만 유지 (아래 overbought) + + # 수익 보호: pnl >= 5% 이면 MA20 단독으로도 청산 (최소 수익 확보) + if not exit_reason and ma20 and pnl_pct >= 0.05 and current_price > ma20: + exit_reason = "ES_MR TP (MA20 profit lock 5%)" + exit_type = "MEAN_REVERSION_TP" + + # ES3: 트레일링 스탑 (MR → 5%에서 활성화, 기존 4%) + if not exit_reason: + trail_pct = engine.trailing_stop_pct + if pnl_pct >= 0.05: + if not pos.trailing_activated: + pos.trailing_activated = True + trailing_stop_price = pos.highest_price * (1 + trail_pct) + if current_price <= trailing_stop_price: + exit_reason = "ES3 트레일링스탑" + exit_type = "TRAILING_STOP" + + # Overbought: RSI > 70 + if not exit_reason and rsi_val is not None and rsi_val > engine._mr_cfg.rsi_overbought: + exit_reason = "ES_MR 과매수(RSI>70)" + exit_type = "OVERBOUGHT_EXIT" + + # ES_TIME_DECAY: 글로벌 레짐 기반 시간감쇄 강제 청산 + _ro_mr = REGIME_OVERRIDES.get(engine._market_regime, {}) + if not exit_reason and _ro_mr.get("time_decay_enabled"): + decay_days = _ro_mr.get("time_decay_days", 10) + decay_pnl = _ro_mr.get("time_decay_pnl_min", 0.02) + if pos.days_held >= decay_days and pnl_pct < decay_pnl: + exit_reason = f"ES_TIME_DECAY: {pos.days_held}일 보유, PnL {pnl_pct:.1%} < {decay_pnl:.0%}" + exit_type = "TIME_DECAY" + engine._phase_stats.setdefault("es_neutral_time_decay", 0) + engine._phase_stats["es_neutral_time_decay"] += 1 + + # ES_BOX_BREAK: RANGE_BOUND 레짐 박스 이탈 즉시 청산 + if not exit_reason and _ro_mr.get("box_breakout_exit") and df is not None: + _box_lb = _ro_mr.get("box_lookback", 40) + if len(df) >= _box_lb: + recent_box = df.tail(_box_lb) + box_high = float(recent_box["high"].max()) + box_low = float(recent_box["low"].min()) + if current_price > box_high * 1.01 or current_price < box_low * 0.99: + exit_reason = f"ES_BOX_BREAK: 박스({box_low:.0f}-{box_high:.0f}) 이탈" + exit_type = "BOX_BREAKOUT_EXIT" + engine._phase_stats.setdefault("es_range_box_breakout", 0) + engine._phase_stats["es_range_box_breakout"] += 1 + + # ES5: 수익률 연동 보유기간 (MR 전용) + if not exit_reason: + base_max = engine._mr_cfg.max_holding_days # 20 + if pnl_pct >= 0.10: + effective_max = 60 # 큰 수익: 트레일링 스탑이 관리 + elif pnl_pct >= 0.05: + effective_max = 35 # 좋은 수익: 적정 확장 + elif pnl_pct >= 0.02: + effective_max = 25 # 소폭 수익: 완만 확장 + else: + effective_max = base_max # 손실/평: 20일 유지 + if pos.days_held > effective_max: + exit_reason = f"ES5 보유기간 초과 ({effective_max}일)" + exit_type = "MAX_HOLDING" + + # ES7: 리밸런스 청산 (PnL 게이트: 수익 중이면 유예) + if not exit_reason and code in engine._rebalance_exit_codes: + if pos.days_held < 3 or pnl_pct <= -0.02: + exit_reason = "ES7 리밸런스 청산" + exit_type = "REBALANCE_EXIT" + engine._rebalance_exit_codes.discard(code) + elif pnl_pct > 0.02: + # 수익 +2%+ → 다음 리밸런스까지 유예 + engine._rebalance_exit_codes.discard(code) + else: + exit_reason = "ES7 리밸런스 청산" + exit_type = "REBALANCE_EXIT" + engine._rebalance_exit_codes.discard(code) + + if exit_reason: + to_close.append(code) + exit_stat_map = { + "EMERGENCY_STOP": "es0_emergency_stop", + "STOP_LOSS": "es1_stop_loss", + "ATR_STOP_LOSS": "es_mr_sl", + "MEAN_REVERSION_TP": "es_mr_tp", + "BB_MID_REVERT": "es_mr_bb", + "TRAILING_STOP": "es3_trailing_stop", + "OVERBOUGHT_EXIT": "es_mr_ob", + "MAX_HOLDING": "es5_max_holding", + "REBALANCE_EXIT": "es7_rebalance_exit", + } + stat_key = exit_stat_map.get(exit_type or "") + if stat_key and stat_key in engine._phase_stats: + engine._phase_stats[stat_key] += 1 + engine._execute_sell(pos, current_price, exit_reason, exit_type or "") + else: + if current_price > pos.highest_price: + pos.highest_price = current_price + + for code in to_close: + del engine.positions[code] diff --git a/ats/simulation/strategies/momentum.py b/ats/simulation/strategies/momentum.py new file mode 100644 index 0000000..d8c6238 --- /dev/null +++ b/ats/simulation/strategies/momentum.py @@ -0,0 +1,483 @@ +""" +Momentum Swing strategy — scan_entries / check_exits. + +Extracted from SimulationEngine to keep engine.py smaller. +All logic is identical; `self` is replaced with `engine`. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, List + +import pandas as pd + +from simulation.constants import ( + REGIME_PARAMS, REGIME_EXIT_PARAMS, REGIME_OVERRIDES, +) +from simulation.models import SimSignal + +if TYPE_CHECKING: + from simulation.engine import SimulationEngine + + +def scan_entries(engine: "SimulationEngine"): + """ + 6-Phase 통합 파이프라인 (기존 Momentum Swing): + Phase 0 (시장 체제) → Phase 4 (리스크 게이트) → 종목별 Phase 1→2→3 + """ + # ── Phase 0: 시장 체제 판단 ── + engine._update_market_regime() + # BEAR 체제: 제한적 거래 허용 (max_positions=2, max_weight=5%) + # 하드블록 대신 REGIME_PARAMS가 RG3에서 포지션 수 제한 + + # ── Phase 4: 리스크 게이트 (사전 체크) ── + can_trade, block_reason = engine._risk_gate_check() + if not can_trade: + engine._phase_stats["phase4_risk_blocks"] += 1 + engine._add_risk_event("WARNING", f"진입 차단: {block_reason}") + return + + total_equity = engine._get_total_equity() + regime_params = REGIME_PARAMS.get(engine._market_regime, REGIME_PARAMS["NEUTRAL"]) + + active_count = len([p for p in engine.positions.values() if p.status == "ACTIVE"]) + + if active_count >= regime_params["max_positions"]: + return + + new_signals: List[tuple] = [] # (signal, trend_strength, trend_stage) + + for w in engine._watchlist: + code = w["code"] + + # STRONG_BULL 피라미딩: 기존 보유 종목도 조건부 추가 매수 허용 + is_pyramid = False + _pyr_ro = REGIME_OVERRIDES.get(engine._market_regime, {}) + if code in engine.positions and engine.positions[code].status in ("ACTIVE", "PENDING"): + pos_existing = engine.positions[code] + if (_pyr_ro.get("pyramiding_enabled") + and pos_existing.strategy_tag == "momentum" + and pos_existing.scale_count < _pyr_ro.get("pyramiding_max", 1) + and pos_existing.days_held >= 5): + cur_px = engine._current_prices.get(code, pos_existing.current_price) + eff_entry = pos_existing.avg_entry_price if pos_existing.avg_entry_price > 0 else pos_existing.entry_price + pnl_ratio = (cur_px - eff_entry) / eff_entry if eff_entry > 0 else 0 + if pnl_ratio >= _pyr_ro.get("pyramiding_pnl_min", 0.05): + is_pyramid = True # 피라미딩 조건 충족, fall through + else: + continue + else: + continue + + df = engine._ohlcv_cache.get(code) + if df is None or len(df) < engine.ma_long + 5: + continue + + df = engine._calculate_indicators(df.copy()) + if df.empty or len(df) < 2: + continue + + engine._phase_stats["total_scans"] += 1 + + # ── Phase 1: 추세 확인 ── + trend = engine._confirm_trend(df) + if trend["direction"] != "UP": + engine._phase_stats["phase1_trend_rejects"] += 1 + if code in engine._debug_tickers: + print( + f"[DIAG] {code} Phase1 REJECT: direction={trend['direction']} " + f"adx={trend['adx']:.1f} aligned={trend['aligned']}" + ) + continue # FLAT/DOWN 종목 스킵 + + # ── Phase 2: 추세 위치 파악 ── + stage = engine._estimate_trend_stage(df) + if stage == "LATE": + engine._phase_stats["phase2_late_rejects"] += 1 + if code in engine._debug_tickers: + _curr = df.iloc[-1] + _rsi = float(_curr.get("rsi", 0)) if pd.notna(_curr.get("rsi")) else 0 + _close = float(_curr["close"]) + _52w_h = float(df["close"].astype(float).rolling(min(252, len(df))).max().iloc[-1]) + _pct_h = (_close / _52w_h * 100) if _52w_h > 0 else 0 + print( + f"[DIAG] {code} Phase2 LATE REJECT: rsi={_rsi:.1f} " + f"pct_of_52w_high={_pct_h:.1f}%" + ) + continue # 말기 종목 진입 스킵 + + # ── Phase 3: 진입 시그널 ── + curr = df.iloc[-1] + prev = df.iloc[-2] + + primary = [] + confirmations = [] + + # PS1: 골든크로스 + if ( + pd.notna(curr["ma_short"]) + and pd.notna(curr["ma_long"]) + and pd.notna(prev["ma_short"]) + and pd.notna(prev["ma_long"]) + ): + if prev["ma_short"] <= prev["ma_long"] and curr["ma_short"] > curr["ma_long"]: + primary.append("PS1") + + # PS2: MACD 골든크로스 + 기울기 필터 + if pd.notna(curr.get("macd_hist")) and pd.notna(prev.get("macd_hist")): + if prev["macd_hist"] <= 0 and curr["macd_hist"] > 0: + # 3봉 기울기 양수 확인 (감속 크로스 필터링) + if len(df) >= 4: + hist_3ago = float(df.iloc[-3].get("macd_hist", 0)) if pd.notna(df.iloc[-3].get("macd_hist")) else 0 + slope = float(curr["macd_hist"]) - hist_3ago + if slope > 0: + primary.append("PS2") + else: + primary.append("PS2") + + # PS3: MA 풀백 진입 (추세 지속 시그널) + # 확립된 상승 추세에서 MA20 지지 확인 후 반등 → 대형 주도주 포착 + if not primary: + if ( + pd.notna(curr["ma_short"]) + and pd.notna(curr["ma_long"]) + and pd.notna(curr.get("ma60")) + and len(df) >= 5 + ): + ma_short_val = float(curr["ma_short"]) + ma_long_val = float(curr["ma_long"]) + ma60_val = float(curr["ma60"]) + price = float(curr["close"]) + prev_price = float(prev["close"]) + + # 조건1: 확립된 상승 정배열 (MA5 > MA20 > MA60) + uptrend = (ma_short_val > ma_long_val > ma60_val) + + if uptrend: + # 조건2: 최근 3봉 내 MA20 근처까지 풀백 (2% 이내 접근) + recent_lows = df["low"].astype(float).iloc[-4:-1] + pullback_zone = ma_long_val * 1.02 + ma20_proximity = any( + low <= pullback_zone for low in recent_lows + ) + + # 조건3: 현재 종가 > MA20 (지지 확인 후 반등) + above_ma20 = price > ma_long_val + + # 조건4: 상승 봉 (현재 종가 > 전일 종가) + bounce_confirm = price > prev_price + + if ma20_proximity and above_ma20 and bounce_confirm: + primary.append("PS3") + engine._phase_stats["phase3_ps3_pullback"] += 1 + + # PS4: Donchian Channel 돌파 (STRONG_BULL 전용 — 독립 시그널) + _mom_ro = REGIME_OVERRIDES.get(engine._market_regime, {}) + ps4_donchian = False + if _mom_ro.get("donchian_entry") and "donchian_high" in df.columns: + prev_donchian = prev.get("donchian_high") if pd.notna(prev.get("donchian_high")) else None + if prev_donchian is not None and float(curr["close"]) > float(prev_donchian): + ps4_donchian = True + primary.append("PS4") + engine._phase_stats.setdefault("phase3_ps4_donchian", 0) + engine._phase_stats["phase3_ps4_donchian"] += 1 + + if not primary: + engine._phase_stats["phase3_no_primary"] += 1 + if code in engine._debug_tickers: + _rsi = float(curr.get("rsi", 0)) if pd.notna(curr.get("rsi")) else 0 + _ma_s = float(curr.get("ma_short", 0)) if pd.notna(curr.get("ma_short")) else 0 + _ma_l = float(curr.get("ma_long", 0)) if pd.notna(curr.get("ma_long")) else 0 + _ma60 = float(curr.get("ma60", 0)) if pd.notna(curr.get("ma60")) else 0 + _macd_h = float(curr.get("macd_hist", 0)) if pd.notna(curr.get("macd_hist")) else 0 + print( + f"[DIAG] {code} Phase3 NO PRIMARY: ma5={_ma_s:.0f} ma20={_ma_l:.0f} " + f"ma60={_ma60:.0f} ma5>ma20={_ma_s > _ma_l} rsi={_rsi:.1f} " + f"macd_hist={_macd_h:.4f}" + ) + continue + + # CF1: RSI 적정 범위 (52-78) + if pd.notna(curr["rsi"]) and engine.rsi_lower <= curr["rsi"] <= engine.rsi_upper: + confirmations.append("CF1") + + # CF2: 거래량 돌파 + if pd.notna(curr["volume_ma"]) and curr["volume_ma"] > 0: + if float(curr["volume"]) >= curr["volume_ma"] * engine.volume_multiplier: + confirmations.append("CF2") + + # CF3: 슬로우 RSI 멀티 타임프레임 확인 (28일) + if pd.notna(curr.get("rsi_slow")) and 45 <= float(curr["rsi_slow"]) <= 70: + confirmations.append("CF3") + + # PS3 전용: 추세 지속 진입은 완화된 확인 임계값 사용 + # 입증된 상승 추세이므로 RSI/거래량 기준을 낮춰도 안전 + if "PS3" in primary and not confirmations: + # CF1_R: RSI 42-82 (기존 52-78 → 완화) + if pd.notna(curr["rsi"]) and 42 <= float(curr["rsi"]) <= 82: + confirmations.append("CF1_R") + + # CF2_R: 거래량 >= MA20 × 1.0 (기존 1.5 → 완화, 대형주 안정 거래량 반영) + if pd.notna(curr["volume_ma"]) and curr["volume_ma"] > 0: + if float(curr["volume"]) >= curr["volume_ma"] * 1.0: + confirmations.append("CF2_R") + + if not confirmations: + engine._phase_stats["phase3_no_confirm"] += 1 + if code in engine._debug_tickers: + _rsi = float(curr.get("rsi", 0)) if pd.notna(curr.get("rsi")) else 0 + _vol = float(curr["volume"]) + _vol_ma = float(curr["volume_ma"]) if pd.notna(curr["volume_ma"]) else 1 + print( + f"[DIAG] {code} Phase3 NO CONFIRM: primary={primary} " + f"rsi={_rsi:.1f} vol_ratio={_vol / _vol_ma:.2f}" + ) + continue + + # 베어리시 다이버전스 필터 (가격↑ RSI↓ → 모멘텀 약화) + if engine._detect_bearish_divergence(df): + engine._phase_stats["divergence_blocks"] += 1 + continue + + + + # 시그널 강도 계산 (연속 스코어링) + adx = trend.get("adx", 0) + rsi = float(curr.get("rsi", 50)) if pd.notna(curr.get("rsi")) else 50 + vol_ratio = float(curr["volume"]) / float(curr["volume_ma"]) if pd.notna(curr["volume_ma"]) and float(curr["volume_ma"]) > 0 else 1.0 + + # PS3는 추세 지속 시그널이므로 개시 시그널(PS1/PS2) 대비 낮은 강도 + ps3_penalty = -10 if "PS3" in primary else 0 + ps4_bonus = 20 if ps4_donchian else 0 # Donchian 돌파 시 +20 + base_strength = len(primary) * 25 + len(confirmations) * 15 + ps3_penalty + ps4_bonus + trend_bonus = min(int(adx * 0.5), 25) # ADX 연속값 → 최대 25점 + stage_bonus = 15 if stage == "EARLY" else 8 if stage == "MID" else 0 + rsi_quality = max(0, int(10 - abs(rsi - 55) * 0.5)) # RSI 55 이상대 + volume_bonus = min(int((vol_ratio - 1.5) * 10), 10) if vol_ratio > 1.5 else 0 + strength = min(max(base_strength + trend_bonus + stage_bonus + rsi_quality + volume_bonus, 10), 100) + + current_price = engine._current_prices.get(code, float(curr["close"])) + + engine._signal_counter += 1 + signal = SimSignal( + id=f"sim-sig-{engine._signal_counter:04d}", + stock_code=code, + stock_name=w["name"], + type="BUY", + price=current_price, + reason=f"{'PYR_' if is_pyramid else ''}{'+'.join(primary)} {'+'.join(confirmations)} [trend={trend['strength']}, stage={stage}]", + strength=min(strength, 100), + detected_at=engine._get_current_iso(), + ) + new_signals.append((signal, trend["strength"], stage, trend.get("alignment_score", 3))) + + if code in engine._debug_tickers: + print( + f"[DIAG] {code} ✅ SIGNAL: primary={primary} confirm={confirmations} " + f"strength={strength} stage={stage} price={current_price:.0f}" + ) + + # 시그널 강도순 정렬 후 매수 실행 + new_signals.sort(key=lambda x: x[0].strength, reverse=True) + + for sig, trend_strength, trend_stage, align_score in new_signals: + if active_count >= regime_params["max_positions"]: + break + engine.signals.append(sig) + if len(engine.signals) > 100: + engine.signals = engine.signals[-100:] + + engine._execute_buy(sig, trend_strength=trend_strength, + trend_stage=trend_stage, alignment_score=align_score) + engine._phase_stats["entries_executed"] += 1 + active_count += 1 + + +def check_exits(engine: "SimulationEngine"): + """기존 Momentum Swing 청산 로직.""" + to_close: List[str] = [] + + for code, pos in engine.positions.items(): + if pos.status != "ACTIVE": + continue + if engine._exit_tag_filter and pos.strategy_tag != engine._exit_tag_filter: + continue + # 글로벌 레짐 기반 청산 파라미터 (종목별 레짐은 analytics용) + regime_exit = REGIME_EXIT_PARAMS.get(engine._market_regime, REGIME_EXIT_PARAMS["NEUTRAL"]) + + current_price = engine._current_prices.get(code, pos.current_price) + entry_price = pos.entry_price + pnl_pct = (current_price - entry_price) / entry_price + + exit_reason = None + exit_type = None + + # ATR 기반 프로그레시브 트레일링 폭 사전 계산 + atr_pct_val = 0.03 # 기본값 + df = engine._ohlcv_cache.get(code) + if df is not None and len(df) > 14: + if "atr_pct" not in df.columns: + df = engine._calculate_indicators(df.copy()) + engine._ohlcv_cache[code] = df + last_atr = df.iloc[-1].get("atr_pct") + if pd.notna(last_atr): + atr_pct_val = float(last_atr) + # 프로그레시브 트레일링: 수익 클수록 타이트한 보호 + if engine.disable_es2: + # ── 강화 트레일링 (ES2 비활성화 모드: 7단계) ── + if pnl_pct >= 0.30: + trail_mult = 2.0 # +30%+: 2×ATR, 플로어 -4% (슈퍼 위너 타이트 보호) + trail_floor = -0.04 + elif pnl_pct >= 0.25: + trail_mult = 2.5 # +25-30%: 2.5×ATR, 플로어 -5% + trail_floor = -0.05 + elif pnl_pct >= 0.20: + trail_mult = 3.0 # +20-25%: 3×ATR, 플로어 -6% (기존 ES2 대체) + trail_floor = -0.06 + elif pnl_pct >= 0.15: + trail_mult = 3.5 # +15-20%: 3.5×ATR, 플로어 -6% + trail_floor = -0.06 + elif pnl_pct >= 0.10: + trail_mult = 4.0 # +10-15%: 4×ATR, 플로어 -5% + trail_floor = -0.05 + elif pnl_pct >= 0.07: + trail_mult = 3.5 # +7-10%: 3.5×ATR, 플로어 -5% + trail_floor = -0.05 + else: + trail_mult = 3.0 # 기본: 3×ATR, 플로어 -4% + trail_floor = engine.trailing_stop_pct + else: + # ── 기존 트레일링 (4단계) ── + if pnl_pct >= 0.15: + trail_mult = 5.0 # +15%+: 5×ATR, 플로어 -8% + trail_floor = -0.08 + elif pnl_pct >= 0.10: + trail_mult = 4.0 # +10-15%: 4×ATR, 플로어 -6% + trail_floor = -0.06 + elif pnl_pct >= 0.07: + trail_mult = 3.5 # +7-10%: 3.5×ATR, 플로어 -5% + trail_floor = -0.05 + else: + trail_mult = 3.0 # 기본: 3×ATR, 플로어 -4% + trail_floor = engine.trailing_stop_pct + # 글로벌 레짐별 트레일링 오버라이드 (STRONG_BULL: 2.0×ATR 타이트) + _ro = REGIME_OVERRIDES.get(engine._market_regime, {}) + if "trail_atr_mult" in _ro: + regime_trail_mult = _ro["trail_atr_mult"] + regime_trail_floor = _ro.get("trail_floor_pct", trail_floor) + # 레짐 기반 배수가 현재보다 더 타이트하면 적용 + if regime_trail_mult < trail_mult: + trail_mult = regime_trail_mult + trail_floor = regime_trail_floor + + trail_pct = max(-trail_mult * atr_pct_val, trail_floor) + + # BULL 이격도 부분 청산 (ES1/ES2 전에 실행 — 비파괴적) + if _ro.get("disparity_partial_sell") and not pos.disparity_sold: + _disp = None + if df is not None and "disparity_20" in df.columns: + _disp = df.iloc[-1].get("disparity_20") + elif df is not None and "ma20" in df.columns: + _ma20 = df.iloc[-1].get("ma20") + if pd.notna(_ma20) and _ma20 > 0: + _disp = current_price / float(_ma20) + if _disp is not None and pd.notna(_disp) and _disp > _ro.get("disparity_threshold", 1.15): + sell_qty = max(1, int(pos.quantity * _ro.get("partial_sell_ratio", 0.5))) + if sell_qty < pos.quantity: + engine._execute_partial_sell(pos, sell_qty, "ES_DISP_PARTIAL") + pos.disparity_sold = True + + # ES1: 손절 -5% (GAP DOWN 보호: _execute_sell에서 fill price 조정) + if current_price <= entry_price * (1 + engine.stop_loss_pct): + exit_reason = "ES1 손절 -5%" + exit_type = "STOP_LOSS" + + # ES2: 익절 (체제별 동적) — disable_es2 모드에서 비활성화 + elif not engine.disable_es2 and current_price >= entry_price * (1 + regime_exit["take_profit"]): + tp_label = f"+{regime_exit['take_profit']*100:.0f}%" + exit_reason = f"ES2 익절 {tp_label}" + exit_type = "TAKE_PROFIT" + + # ES3: 트레일링 스탑 (활성화 임계 도달 후에만, ATR 기반) + elif pnl_pct >= (0.03 if engine.disable_es2 else regime_exit["trail_activation"]): + if not pos.trailing_activated: + pos.trailing_activated = True + trailing_stop_price = pos.highest_price * (1 + trail_pct) + if current_price <= trailing_stop_price: + exit_reason = "ES3 트레일링스탑" + exit_type = "TRAILING_STOP" + + # ES4: 데드크로스 (MA5/20 — 수익 포지션: 타이트 트레일링 전환) + if not exit_reason: + if df is not None and len(df) >= engine.ma_long + 2: + df_calc = df if "ma_short" in df.columns else engine._calculate_indicators(df.copy()) + if len(df_calc) >= 2: + curr_row = df_calc.iloc[-1] + prev_row = df_calc.iloc[-2] + if ( + pd.notna(curr_row.get("ma_short")) + and pd.notna(curr_row.get("ma_long")) + and pd.notna(prev_row.get("ma_short")) + and pd.notna(prev_row.get("ma_long")) + and prev_row["ma_short"] >= prev_row["ma_long"] + and curr_row["ma_short"] < curr_row["ma_long"] + ): + if pnl_pct >= 0.02: + # 수익 포지션: 즉시 청산 대신 타이트 트레일링 활성화 + # 1.5×ATR (표준 3×ATR보다 타이트) 또는 최소 -2% + tight_trail = max(-1.5 * atr_pct_val, -0.02) + pos.trailing_activated = True + pos.trailing_stop = round(current_price * (1 + tight_trail)) + elif pnl_pct < -0.02: + # 손실 -2% 초과만 청산 (경미한 손실은 회복 기회) + exit_reason = "ES4 데드크로스" + exit_type = "DEAD_CROSS" + # -2% ~ +2%: 무시 (ES1/ES3/ES5가 처리) + + # ES5: 보유기간 초과 (체제별 동적) + if not exit_reason and pos.days_held > regime_exit["max_holding"]: + exit_reason = "ES5 보유기간 초과" + exit_type = "MAX_HOLDING" + + # ES7: 리밸런스 청산 (워치리스트 탈락) — PnL 게이트 적용 + if not exit_reason and code in engine._rebalance_exit_codes: + if pos.days_held < 3 or pnl_pct <= -0.02: + exit_reason = "ES7 리밸런스 청산" + exit_type = "REBALANCE_EXIT" + engine._rebalance_exit_codes.discard(code) + elif pnl_pct > 0.02: + # 수익 포지션은 유예 (다음 리밸런스까지 보유) + engine._rebalance_exit_codes.discard(code) + else: + exit_reason = "ES7 리밸런스 청산" + exit_type = "REBALANCE_EXIT" + engine._rebalance_exit_codes.discard(code) + + if exit_reason: + to_close.append(code) + # Phase 통계: 청산 이유별 카운터 + exit_stat_map = { + "EMERGENCY_STOP": "es0_emergency_stop", + "STOP_LOSS": "es1_stop_loss", + "TAKE_PROFIT": "es2_take_profit", + "TRAILING_STOP": "es3_trailing_stop", + "DEAD_CROSS": "es4_dead_cross", + "MAX_HOLDING": "es5_max_holding", + "TIME_DECAY": "es6_time_decay", + "REBALANCE_EXIT": "es7_rebalance_exit", + } + stat_key = exit_stat_map.get(exit_type or "") + if stat_key: + engine._phase_stats[stat_key] += 1 + engine._execute_sell(pos, current_price, exit_reason, exit_type or "") + else: + # 트레일링 최고가 갱신 (ATR 기반) + if current_price > pos.highest_price: + pos.highest_price = current_price + if pos.trailing_activated: + pos.trailing_stop = round(current_price * (1 + trail_pct)) + else: + pos.trailing_stop = round(current_price * (1 + engine.trailing_stop_pct)) + + for code in to_close: + del engine.positions[code] diff --git a/ats/simulation/strategies/smc.py b/ats/simulation/strategies/smc.py new file mode 100644 index 0000000..1a29e63 --- /dev/null +++ b/ats/simulation/strategies/smc.py @@ -0,0 +1,403 @@ +""" +SMC (Smart Money Concepts) 4-Layer strategy — scan_entries / check_exits. + +Extracted from SimulationEngine to keep engine.py smaller. +All logic is identical; `self` is replaced with `engine`. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Dict, List + +import numpy as np +import pandas as pd + +from simulation.constants import ( + REGIME_PARAMS, REGIME_EXIT_PARAMS, +) +from simulation.models import SimSignal + +if TYPE_CHECKING: + from simulation.engine import SimulationEngine + + +# ══════════════════════════════════════════ +# SMC 지표 계산 +# ══════════════════════════════════════════ + +def calculate_indicators_smc(engine: "SimulationEngine", df: pd.DataFrame) -> pd.DataFrame: + """기존 지표 + SMC + OBV 통합 계산.""" + df = engine._calculate_indicators(df) + if df.empty: + return df + + # SMC: Swing Points, BOS/CHoCH, Order Blocks, FVG + from analytics.indicators import calculate_smc + df = calculate_smc(df, swing_length=engine._smc_cfg.swing_length) + + # OBV (On Balance Volume) + c = df["close"].astype(float) + v = df["volume"].astype(float) + df["obv"] = (np.sign(c.diff()).fillna(0) * v).cumsum() + df["obv_ema5"] = df["obv"].ewm(span=5, adjust=False).mean() + df["obv_ema20"] = df["obv"].ewm(span=20, adjust=False).mean() + + return df + + +# ══════════════════════════════════════════ +# SMC 4-Layer 스코어링 +# ══════════════════════════════════════════ + +def score_smc_bias(engine: "SimulationEngine", df: pd.DataFrame) -> int: + """Layer 1: SMC Bias 스코어 (0~40).""" + if len(df) < 10: + return 0 + + score = 0 + curr = df.iloc[-1] + price = float(curr["close"]) + + lookback = min(20, len(df)) + recent = df.iloc[-lookback:] + markers = recent[recent["marker"].notna()] + + if not markers.empty: + last_marker = markers.iloc[-1]["marker"] + if last_marker == "BOS_BULL": + score += 25 + elif last_marker == "CHOCH_BULL": + score += 20 + + # OB 근접도 + ob_rows = recent[recent["ob_top"].notna()] + if not ob_rows.empty: + last_ob = ob_rows.iloc[-1] + ob_top = float(last_ob["ob_top"]) + ob_bottom = float(last_ob["ob_bottom"]) + ob_range = ob_top - ob_bottom if ob_top > ob_bottom else 1.0 + if ob_bottom <= price <= ob_top: + score += 10 + elif price < ob_top and price > ob_bottom - ob_range * 0.5: + score += 5 + + # FVG 미티게이션 + if engine._smc_cfg.fvg_mitigation: + fvg_rows = recent[(recent["fvg_type"] == "bull") & recent["fvg_top"].notna()] + if not fvg_rows.empty: + last_fvg = fvg_rows.iloc[-1] + fvg_top = float(last_fvg["fvg_top"]) + fvg_bottom = float(last_fvg["fvg_bottom"]) + if fvg_bottom <= price <= fvg_top: + score += 5 + + return min(score, engine._smc_cfg.weight_smc) + + +def score_volatility(engine: "SimulationEngine", df: pd.DataFrame) -> int: + """Layer 2: Volatility Setup 스코어 (0~20).""" + if len(df) < 50: + return 0 + + score = 0 + curr = df.iloc[-1] + + bb_width = float(curr.get("bb_width", 0)) if pd.notna(curr.get("bb_width")) else 0 + bb_avg_series = df["bb_width"].rolling(window=50).mean() + bb_width_avg = float(bb_avg_series.iloc[-1]) if pd.notna(bb_avg_series.iloc[-1]) else bb_width + if bb_width_avg > 0: + squeeze_ratio = bb_width / bb_width_avg + else: + squeeze_ratio = 1.0 + + if squeeze_ratio < 0.8: + score += 15 + elif squeeze_ratio < 1.0: + score += 8 + + atr_pct = float(curr.get("atr_pct", 0)) if pd.notna(curr.get("atr_pct")) else 0 + atr_avg = df["atr_pct"].rolling(window=50).mean() + atr_avg_val = float(atr_avg.iloc[-1]) if pd.notna(atr_avg.iloc[-1]) else atr_pct + if atr_avg_val > 0 and 0.5 <= atr_pct / atr_avg_val <= 1.5: + score += 5 + + return min(score, engine._smc_cfg.weight_bb) + + +def score_obv_signal(engine: "SimulationEngine", df: pd.DataFrame) -> int: + """Layer 3a: OBV 스코어 (0~20).""" + if len(df) < 25: + return 0 + + score = 0 + curr = df.iloc[-1] + + obv_ema5 = float(curr.get("obv_ema5", 0)) if pd.notna(curr.get("obv_ema5")) else 0 + obv_ema20 = float(curr.get("obv_ema20", 0)) if pd.notna(curr.get("obv_ema20")) else 0 + + if obv_ema5 > obv_ema20: + score += 10 + if len(df) >= 6: + obv_5ago = float(df.iloc[-6].get("obv_ema5", 0)) if pd.notna(df.iloc[-6].get("obv_ema5")) else 0 + if obv_ema5 > obv_5ago: + score += 5 + + curr_vol = float(curr.get("volume", 0)) if pd.notna(curr.get("volume")) else 0 + vol_ma = float(curr.get("volume_ma", 1)) if pd.notna(curr.get("volume_ma")) else 1 + if vol_ma > 0 and curr_vol >= vol_ma * 1.3: + score += 5 + + return min(score, engine._smc_cfg.weight_obv) + + +def score_momentum_signal(engine: "SimulationEngine", df: pd.DataFrame) -> int: + """Layer 3b: ADX/MACD 모멘텀 스코어 (0~20).""" + if len(df) < 30: + return 0 + + score = 0 + curr = df.iloc[-1] + prev = df.iloc[-2] + + adx = float(curr.get("adx", 0)) if pd.notna(curr.get("adx")) else 0 + plus_di = float(curr.get("plus_di", 0)) if pd.notna(curr.get("plus_di")) else 0 + minus_di = float(curr.get("minus_di", 0)) if pd.notna(curr.get("minus_di")) else 0 + + if adx > 25 and plus_di > minus_di: + score += 10 + elif adx > 20 and plus_di > minus_di: + score += 5 + + macd_hist = float(curr.get("macd_hist", 0)) if pd.notna(curr.get("macd_hist")) else 0 + prev_macd = float(prev.get("macd_hist", 0)) if pd.notna(prev.get("macd_hist")) else 0 + + if prev_macd <= 0 and macd_hist > 0: + score += 10 + elif macd_hist > 0 and macd_hist > prev_macd: + score += 5 + + return min(score, engine._smc_cfg.weight_momentum) + + +# ══════════════════════════════════════════ +# SMC 진입 스캔 +# ══════════════════════════════════════════ + +def scan_entries(engine: "SimulationEngine"): + """ + SMC 4-Layer 스코어링 기반 진입 스캔. + Phase 0 (시장 체제) + Phase 4 (리스크 게이트) → SMC 스코어링 → 매수 실행. + """ + # ── Phase 0: 시장 체제 판단 ── + engine._update_market_regime() + + # ── Phase 4: 리스크 게이트 (사전 체크) ── + can_trade, block_reason = engine._risk_gate_check() + if not can_trade: + engine._phase_stats["phase4_risk_blocks"] += 1 + engine._add_risk_event("WARNING", f"SMC 진입 차단: {block_reason}") + return + + total_equity = engine._get_total_equity() + regime_params = REGIME_PARAMS.get(engine._market_regime, REGIME_PARAMS["NEUTRAL"]) + + active_count = len([p for p in engine.positions.values() if p.status == "ACTIVE"]) + + if active_count >= regime_params["max_positions"]: + return + + new_signals: List[tuple] = [] + + for w in engine._watchlist: + code = w["code"] + + + if code in engine.positions and engine.positions[code].status in ("ACTIVE", "PENDING"): + continue + + df = engine._ohlcv_cache.get(code) + if df is None or len(df) < 50: + continue + + df = calculate_indicators_smc(engine, df.copy()) + if df.empty or len(df) < 2: + continue + + engine._phase_stats["total_scans"] += 1 + + curr = df.iloc[-1] + + # ── SMC 4-Layer 스코어링 ── + s_smc = score_smc_bias(engine, df) + s_vol = score_volatility(engine, df) + s_obv = score_obv_signal(engine, df) + s_mom = score_momentum_signal(engine, df) + total_score = s_smc + s_vol + s_obv + s_mom + + if total_score < engine._smc_entry_threshold: + engine._phase_stats["phase3_no_primary"] += 1 + continue + + current_price = engine._current_prices.get(code, float(curr["close"])) + + engine._signal_counter += 1 + signal = SimSignal( + id=f"sim-sig-{engine._signal_counter:04d}", + stock_code=code, + stock_name=w["name"], + type="BUY", + price=current_price, + reason=f"SMC_{total_score} [L1:{s_smc} L2:{s_vol} L3a:{s_obv} L3b:{s_mom}]", + strength=min(total_score, 100), + detected_at=engine._get_current_iso(), + ) + new_signals.append((signal, "MODERATE", "MID", 3)) + + # SMC 통계 + engine._phase_stats["smc_total_score"] += total_score + engine._phase_stats["smc_entries"] += 1 + + # 시그널 강도순 정렬 후 매수 실행 + new_signals.sort(key=lambda x: x[0].strength, reverse=True) + + for sig, trend_strength, trend_stage, align_score in new_signals: + if active_count >= regime_params["max_positions"]: + break + engine.signals.append(sig) + if len(engine.signals) > 100: + engine.signals = engine.signals[-100:] + + engine._execute_buy(sig, trend_strength=trend_strength, + trend_stage=trend_stage, alignment_score=align_score) + engine._phase_stats["entries_executed"] += 1 + active_count += 1 + + +# ══════════════════════════════════════════ +# SMC 청산 로직 +# ══════════════════════════════════════════ + +def check_exits(engine: "SimulationEngine"): + """ + SMC 전용 청산 체크. + ES1(-5%) > ATR SL > ATR TP > CHoCH > ES3 트레일링 > ES5 보유기간 > ES7 리밸런스 + """ + to_close: List[str] = [] + + for code, pos in engine.positions.items(): + if pos.status != "ACTIVE": + continue + if engine._exit_tag_filter and pos.strategy_tag != engine._exit_tag_filter: + continue + # 글로벌 레짐 기반 청산 파라미터 (종목별 레짐은 analytics용) + regime_exit = REGIME_EXIT_PARAMS.get(engine._market_regime, REGIME_EXIT_PARAMS["NEUTRAL"]) + + current_price = engine._current_prices.get(code, pos.current_price) + entry_price = pos.entry_price + pnl_pct = (current_price - entry_price) / entry_price + + exit_reason = None + exit_type = None + + # ATR 조회 + atr_val = None + df = engine._ohlcv_cache.get(code) + if df is not None and len(df) > 14: + if "atr" not in df.columns: + df = engine._calculate_indicators(df.copy()) + engine._ohlcv_cache[code] = df + last_atr = df.iloc[-1].get("atr") + if pd.notna(last_atr): + atr_val = float(last_atr) + + # ES1: 손절 -5% (GAP DOWN 보호: _execute_sell에서 fill price 조정) + if current_price <= entry_price * (1 + engine.stop_loss_pct): + exit_reason = "ES1 손절 -5%" + exit_type = "STOP_LOSS" + + # ATR SL: entry - ATR * mult (2일 쿨다운) + elif atr_val and atr_val > 0: + atr_sl_price = entry_price - atr_val * engine._smc_cfg.atr_sl_mult + floor_sl_price = entry_price * (1 + engine.stop_loss_pct) + effective_sl = max(atr_sl_price, floor_sl_price) + + if current_price <= effective_sl and effective_sl > floor_sl_price: + exit_reason = "ES_SMC ATR SL" + exit_type = "ATR_STOP_LOSS" + + # ATR TP + if not exit_reason: + atr_tp_price = entry_price + atr_val * engine._smc_cfg.atr_tp_mult + if current_price >= atr_tp_price: + exit_reason = "ES_SMC ATR TP" + exit_type = "ATR_TAKE_PROFIT" + + # CHoCH Exit: 추세 반전 감지 (Phase 5: PnL 게이트 추가) + # 데이터: CHoCH exits 9/23 trades, -$2,352 → 조기 청산이 수익 기회 파괴 + # 수정: PnL < -2% (손실 확대 방지) 또는 PnL > +5% (수익 보호)만 CHoCH 청산 + # -2%~+5% "발전 구간"에서는 CHoCH 무시 → 트레이드 성숙 대기 + if not exit_reason and engine._smc_cfg.choch_exit and df is not None and len(df) > 10: + choch_pnl_gate = pnl_pct < -0.02 or pnl_pct > 0.05 + if choch_pnl_gate: + df_smc = calculate_indicators_smc(engine, df.copy()) + recent_markers = df_smc.iloc[-5:] + for _, row in recent_markers.iterrows(): + if row.get("marker") == "CHOCH_BEAR": + exit_reason = "ES_CHOCH 추세반전" + exit_type = "CHOCH_EXIT" + break + + # ES3: 트레일링 스탑 + if not exit_reason: + trail_pct = engine.trailing_stop_pct + if pnl_pct >= regime_exit["trail_activation"]: + if not pos.trailing_activated: + pos.trailing_activated = True + trailing_stop_price = pos.highest_price * (1 + trail_pct) + if current_price <= trailing_stop_price: + exit_reason = "ES3 트레일링스탑" + exit_type = "TRAILING_STOP" + + # ES5: 보유기간 초과 + if not exit_reason and pos.days_held > regime_exit["max_holding"]: + exit_reason = "ES5 보유기간 초과" + exit_type = "MAX_HOLDING" + + # ES7: 리밸런스 청산 (PnL 게이트: 수익 중이면 유예) + if not exit_reason and code in engine._rebalance_exit_codes: + if pos.days_held < 3 or pnl_pct <= -0.02: + exit_reason = "ES7 리밸런스 청산" + exit_type = "REBALANCE_EXIT" + engine._rebalance_exit_codes.discard(code) + elif pnl_pct > 0.02: + # 수익 +2%+ → 다음 리밸런스까지 유예 + engine._rebalance_exit_codes.discard(code) + else: + exit_reason = "ES7 리밸런스 청산" + exit_type = "REBALANCE_EXIT" + engine._rebalance_exit_codes.discard(code) + + if exit_reason: + to_close.append(code) + # Phase 통계 + exit_stat_map = { + "EMERGENCY_STOP": "es0_emergency_stop", + "STOP_LOSS": "es1_stop_loss", + "ATR_STOP_LOSS": "es_smc_sl", + "ATR_TAKE_PROFIT": "es_smc_tp", + "CHOCH_EXIT": "es_choch_exit", + "TRAILING_STOP": "es3_trailing_stop", + "MAX_HOLDING": "es5_max_holding", + "REBALANCE_EXIT": "es7_rebalance_exit", + } + stat_key = exit_stat_map.get(exit_type or "") + if stat_key and stat_key in engine._phase_stats: + engine._phase_stats[stat_key] += 1 + engine._execute_sell(pos, current_price, exit_reason, exit_type or "") + else: + # 트레일링 최고가 갱신 + if current_price > pos.highest_price: + pos.highest_price = current_price + + for code in to_close: + del engine.positions[code] diff --git a/ats/simulation/strategies/volatility.py b/ats/simulation/strategies/volatility.py new file mode 100644 index 0000000..5004cea --- /dev/null +++ b/ats/simulation/strategies/volatility.py @@ -0,0 +1,154 @@ +""" +Volatility Premium strategy — scan_entries / check_exits. + +Extracted from SimulationEngine to keep engine.py smaller. +All logic is identical; `self` is replaced with `engine`. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, List + +import pandas as pd + +from simulation.models import SimSignal + +if TYPE_CHECKING: + from simulation.engine import SimulationEngine + + +def scan_entries(engine: "SimulationEngine"): + """ + VIX Mean Reversion: VIX 급등 후 하락 반전 시 SPY/QQQ 매수. + 변동성 프리미엄 수확 — VIX가 평균 회귀할 때 주가 반등 포착. + + 진입 조건: + - VIX EMA20 > 22 (변동성 상승 확인) + - VIX 3일 연속 하락 (하락 반전) + - RSI(VIX 대리: 시장 RSI < 45) — 시장이 아직 과매도 영역 + + 청산: + - VIX EMA20 < 18 (정상화 완료) OR 20일 보유 OR -5% SL + """ + # VIX 데이터 필요 + if engine._vix_ema20 is None or engine._vix_ema20 <= 0: + return + + # 진입 조건: VIX 높고 하락 중 + if engine._vix_ema20 < 22: + return + + # VIX 3일 연속 하락 체크 (VIX 히스토리 필요) + vix_history = getattr(engine, '_vix_history', []) + if len(vix_history) < 4: + return + + vix_declining = all( + vix_history[-i] < vix_history[-i-1] + for i in range(1, 4) + ) + if not vix_declining: + return + + # 리스크 게이트 + can_trade, _ = engine._risk_gate_check() + if not can_trade: + return + + # 타겟: 대형 ETF 또는 시장 대표 종목 (워치리스트에서 유동성 높은 종목) + vol_targets = ["SPY", "QQQ", "AAPL", "MSFT", "AMZN", "GOOGL", "META", "NVDA"] + for code in vol_targets: + if code in engine.positions and engine.positions[code].status == "ACTIVE": + continue + + df = engine._ohlcv_cache.get(code) + if df is None or len(df) < 20: + continue + + df = engine._calculate_indicators(df.copy()) + if df.empty: + continue + engine._ohlcv_cache[code] = df + + curr = df.iloc[-1] + price = engine._current_prices.get(code, float(curr["close"])) + + # 시장 RSI < 50 (아직 반등 여지 있음) + rsi = float(curr.get("rsi", 50)) if pd.notna(curr.get("rsi")) else 50 + if rsi > 50: + continue + + # 시그널 강도: VIX 높을수록 + RSI 낮을수록 강함 + strength = min(int(30 + (engine._vix_ema20 - 22) * 5 + (50 - rsi)), 100) + + engine._signal_counter += 1 + signal = SimSignal( + id=f"sim-sig-{engine._signal_counter:04d}", + stock_code=code, + stock_name=engine._stock_names.get(code, code), + type="BUY", + price=price, + reason=f"VOL_PREMIUM VIX={engine._vix_ema20:.1f} RSI={rsi:.0f}", + strength=strength, + detected_at=engine._get_current_iso(), + ) + + if engine._collect_mode: + engine._collected_signals.append((engine.strategy_mode, signal, "MODERATE", "MID", 3)) + else: + engine._execute_buy(signal, "MODERATE", "MID", 3) + + +def check_exits(engine: "SimulationEngine"): + """Volatility Premium 청산: VIX 정상화 OR 20일 보유 OR -5% SL.""" + to_close: List[str] = [] + + for code, pos in engine.positions.items(): + if pos.status != "ACTIVE" or pos.strategy_tag != "volatility": + continue + + current_price = engine._current_prices.get(code, pos.current_price) + entry_price = pos.entry_price + pnl_pct = (current_price - entry_price) / entry_price + + exit_reason = None + exit_type = None + + # ES1: -5% 손절 + if pnl_pct <= -0.05: + exit_reason = "ES_VOL SL -5%" + exit_type = "STOP_LOSS" + + # VIX 정상화 청산 (VIX < 18) + elif engine._vix_ema20 is not None and engine._vix_ema20 < 18: + exit_reason = f"ES_VOL VIX 정상화 ({engine._vix_ema20:.1f})" + exit_type = "VOLATILITY_TP" + + # 익절: +8% + elif pnl_pct >= 0.08: + exit_reason = "ES_VOL TP +8%" + exit_type = "TAKE_PROFIT" + + # 20일 보유 초과 + elif pos.days_held > 20: + exit_reason = "ES_VOL 보유기간 20일 초과" + exit_type = "MAX_HOLDING" + + # 트레일링: +4%에서 활성 + elif pnl_pct >= 0.04: + df = engine._ohlcv_cache.get(code) + if df is not None and "atr" in df.columns: + atr_val = float(df.iloc[-1].get("atr", 0)) if pd.notna(df.iloc[-1].get("atr")) else 0 + if atr_val > 0: + trail_stop = pos.highest_price - 2.0 * atr_val + if current_price <= trail_stop: + exit_reason = "ES_VOL 트레일링" + exit_type = "TRAILING_STOP" + + if exit_reason: + to_close.append(code) + engine._execute_sell(pos, current_price, exit_reason, exit_type or "") + + for code in to_close: + if code in engine.positions: + del engine.positions[code] From 98a57909ba54c9abc4e6ea2014398e462b10afcd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=8B=A0=EB=8C=80=EC=8A=B9?= Date: Sun, 15 Mar 2026 18:03:04 +0900 Subject: [PATCH 6/6] refactor: replace strategy dispatchers with registry pattern MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase D cleanup — strategy dispatch simplification: - Add _STRATEGY_MODULES registry mapping strategy names to modules - Replace if/elif chains in _scan_entries, _check_exits, _scan_entries_multi, _check_exits_multi with single registry lookups - Remove 185 lines of dead delegating wrapper methods Engine.py: 2159 → 1942 lines. 58/58 tests pass. Co-Authored-By: Claude Opus 4.6 --- ats/simulation/engine.py | 279 +++++---------------------------------- 1 file changed, 31 insertions(+), 248 deletions(-) diff --git a/ats/simulation/engine.py b/ats/simulation/engine.py index 51aeb54..15a88c3 100644 --- a/ats/simulation/engine.py +++ b/ats/simulation/engine.py @@ -33,6 +33,26 @@ from simulation.allocator import StrategyAllocator, _compute_adx # noqa: F401 +from simulation.strategies import ( + momentum as strat_momentum, + smc as strat_smc, + mean_reversion as strat_mr, + breakout_retest as strat_brt, + arbitrage as strat_arb, + defensive as strat_def, + volatility as strat_vol, +) + +# Strategy dispatch registry — maps strategy name to module with scan_entries/check_exits +_STRATEGY_MODULES = { + "momentum": strat_momentum, + "smc": strat_smc, + "mean_reversion": strat_mr, + "breakout_retest": strat_brt, + "arbitrage": strat_arb, + "defensive": strat_def, + "volatility": strat_vol, +} class SimulationEngine: @@ -854,25 +874,11 @@ def _cluster_levels(levels: list, tolerance: float = 0.015) -> list: # ══════════════════════════════════════════ def _scan_entries(self): - """ - 전략 모드에 따라 진입 스캔 분기. - multi: 멀티 전략 동시 실행 (레짐별 비중 기반, 자동 전환) - regime_*: 레짐 고정 + multi 파이프라인 (개별 레짐 전략 테스트) - momentum/smc/breakout_retest/mean_reversion/arbitrage/defensive: 단일 전략 - """ + """전략 모드에 따라 진입 스캔 분기.""" if self.strategy_mode == "multi" or self.strategy_mode in REGIME_STRATEGY_MODES: return self._scan_entries_multi() - elif self.strategy_mode == "smc": - return self._scan_entries_smc() - elif self.strategy_mode == "breakout_retest": - return self._scan_entries_breakout_retest() - elif self.strategy_mode == "mean_reversion": - return self._scan_entries_mean_reversion() - elif self.strategy_mode == "arbitrage": - return self._scan_entries_arbitrage() - elif self.strategy_mode == "defensive": - return self._scan_entries_defensive() - return self._scan_entries_momentum() + module = _STRATEGY_MODULES.get(self.strategy_mode, strat_momentum) + module.scan_entries(self) def _scan_entries_multi(self): """ @@ -951,20 +957,9 @@ def _scan_entries_multi(self): original_mode = self.strategy_mode self.strategy_mode = strategy - if strategy == "momentum": - self._scan_entries_momentum() - elif strategy == "smc": - self._scan_entries_smc() - elif strategy == "breakout_retest": - self._scan_entries_breakout_retest() - elif strategy == "mean_reversion": - self._scan_entries_mean_reversion() - elif strategy == "defensive": - self._scan_entries_defensive() - elif strategy == "volatility": - self._scan_entries_volatility() - elif strategy == "arbitrage": - self._scan_entries_arbitrage() + module = _STRATEGY_MODULES.get(strategy) + if module: + module.scan_entries(self) self.strategy_mode = original_mode @@ -1028,193 +1023,6 @@ def _scan_entries_multi(self): self._collected_signals = [] - # ── Phase 3.3: Defensive 전략 (인버스 ETF) ── - - def _scan_entries_defensive(self): - from simulation.strategies.defensive import scan_entries - scan_entries(self) - - def _check_exits_defensive(self): - from simulation.strategies.defensive import check_exits - check_exits(self) - - # ───────────────────────────────────────────────────── - # Phase 6: Volatility Premium Strategy - # ───────────────────────────────────────────────────── - - def _scan_entries_volatility(self): - from simulation.strategies.volatility import scan_entries - scan_entries(self) - - def _check_exits_volatility(self): - from simulation.strategies.volatility import check_exits - check_exits(self) - - def _scan_entries_momentum(self): - from simulation.strategies.momentum import scan_entries - scan_entries(self) - - # ══════════════════════════════════════════ - # SMC 4-Layer 진입 스캔 - # ══════════════════════════════════════════ - - def _scan_entries_smc(self): - from simulation.strategies.smc import scan_entries - scan_entries(self) - - def _calculate_indicators_smc(self, df: pd.DataFrame) -> pd.DataFrame: - from simulation.strategies.smc import calculate_indicators_smc - return calculate_indicators_smc(self, df) - - def _score_smc_bias(self, df: pd.DataFrame) -> int: - from simulation.strategies.smc import score_smc_bias - return score_smc_bias(self, df) - - def _score_volatility(self, df: pd.DataFrame) -> int: - from simulation.strategies.smc import score_volatility - return score_volatility(self, df) - - def _score_obv_signal(self, df: pd.DataFrame) -> int: - from simulation.strategies.smc import score_obv_signal - return score_obv_signal(self, df) - - def _score_momentum_signal(self, df: pd.DataFrame) -> int: - from simulation.strategies.smc import score_momentum_signal - return score_momentum_signal(self, df) - - # ══════════════════════════════════════════ - # SMC 청산 로직 - # ══════════════════════════════════════════ - - def _check_exits_smc(self): - from simulation.strategies.smc import check_exits - check_exits(self) - - # ══════════════════════════════════════════ - # Mean Reversion 지표/진입/청산 로직 - # ══════════════════════════════════════════ - - def _calculate_indicators_mean_reversion(self, df: pd.DataFrame) -> pd.DataFrame: - from simulation.strategies.mean_reversion import calculate_indicators_mean_reversion - return calculate_indicators_mean_reversion(self, df) - - def _score_mr_signal(self, df: pd.DataFrame) -> int: - from simulation.strategies.mean_reversion import score_mr_signal - return score_mr_signal(self, df) - - def _score_mr_volatility(self, df: pd.DataFrame) -> int: - from simulation.strategies.mean_reversion import score_mr_volatility - return score_mr_volatility(self, df) - - def _score_mr_confirmation(self, df: pd.DataFrame) -> int: - from simulation.strategies.mean_reversion import score_mr_confirmation - return score_mr_confirmation(self, df) - - def _scan_entries_mean_reversion(self): - from simulation.strategies.mean_reversion import scan_entries - scan_entries(self) - - def _check_exits_mean_reversion(self): - from simulation.strategies.mean_reversion import check_exits - check_exits(self) - - # ══════════════════════════════════════════ - # Breakout-Retest 지표/진입/청산 로직 - # (extracted to simulation/strategies/breakout_retest.py) - # ══════════════════════════════════════════ - - def _calculate_indicators_breakout_retest(self, df: pd.DataFrame) -> pd.DataFrame: - from simulation.strategies.breakout_retest import calculate_indicators_breakout_retest - return calculate_indicators_breakout_retest(self, df) - - def _score_brt_structure(self, df: pd.DataFrame) -> int: - from simulation.strategies.breakout_retest import score_brt_structure - return score_brt_structure(self, df) - - def _score_brt_volatility(self, df: pd.DataFrame) -> int: - from simulation.strategies.breakout_retest import score_brt_volatility - return score_brt_volatility(self, df) - - def _score_brt_obv(self, df: pd.DataFrame) -> int: - from simulation.strategies.breakout_retest import score_brt_obv - return score_brt_obv(self, df) - - def _score_brt_momentum(self, df: pd.DataFrame) -> int: - from simulation.strategies.breakout_retest import score_brt_momentum - return score_brt_momentum(self, df) - - def _check_brt_six_conditions(self, df: pd.DataFrame) -> tuple: - from simulation.strategies.breakout_retest import check_brt_six_conditions - return check_brt_six_conditions(self, df) - - def _apply_brt_fakeout_filters(self, df: pd.DataFrame) -> tuple: - from simulation.strategies.breakout_retest import apply_brt_fakeout_filters - return apply_brt_fakeout_filters(self, df) - - def _capture_brt_retest_zones(self, df: pd.DataFrame, breakout_price: float, breakout_atr: float) -> Dict[str, Any]: - from simulation.strategies.breakout_retest import capture_brt_retest_zones - return capture_brt_retest_zones(self, df, breakout_price, breakout_atr) - - def _scan_entries_breakout_retest(self): - from simulation.strategies.breakout_retest import scan_entries - scan_entries(self) - - def _score_brt_retest_zone(self, df: pd.DataFrame, state: Dict[str, Any]) -> int: - from simulation.strategies.breakout_retest import score_brt_retest_zone - return score_brt_retest_zone(self, df, state) - - def _check_exits_breakout_retest(self): - from simulation.strategies.breakout_retest import check_exits - check_exits(self) - - - # ══════════════════════════════════════════ - # Arbitrage: Statistical Pairs (Long+Short 양방향) - # 이론 참조: futuresStrategy.md (Z-Score), BlackScholesEquation.md (IV/RV), - # future_trading_stratedy.md (Dynamic ATR), Kelly Criterion.md - # ══════════════════════════════════════════ - - def _discover_pairs(self) -> List[Dict]: - from simulation.strategies.arbitrage import discover_pairs - return discover_pairs(self) - - def _load_fixed_pairs(self) -> List[Dict]: - from simulation.strategies.arbitrage import load_fixed_pairs - return load_fixed_pairs(self) - - def _check_basis_gate(self) -> bool: - from simulation.strategies.arbitrage import check_basis_gate - return check_basis_gate(self) - - def _score_arb_correlation(self, pair: Dict) -> int: - from simulation.strategies.arbitrage import score_arb_correlation - return score_arb_correlation(self, pair) - - def _score_arb_spread(self, pair: Dict) -> int: - from simulation.strategies.arbitrage import score_arb_spread - return score_arb_spread(self, pair) - - def _score_arb_volume(self, pair: Dict) -> int: - from simulation.strategies.arbitrage import score_arb_volume - return score_arb_volume(self, pair) - - def _calculate_arb_ev(self, pair: Dict) -> bool: - from simulation.strategies.arbitrage import calculate_arb_ev - return calculate_arb_ev(self, pair) - - def _size_arb_pair(self, price_a: float, price_b: float, score: int) -> tuple: - from simulation.strategies.arbitrage import size_arb_pair - return size_arb_pair(self, price_a, price_b, score) - - def _scan_entries_arbitrage(self): - from simulation.strategies.arbitrage import scan_entries - scan_entries(self) - - def _check_exits_arbitrage(self): - from simulation.strategies.arbitrage import check_exits - check_exits(self) - - def _execute_buy( self, signal: SimSignal, @@ -1465,20 +1273,11 @@ def _execute_buy( # ══════════════════════════════════════════ def _check_exits(self): - """전략 모드에 따라 청산 체크 분기. multi/regime_* 모드에서는 strategy_tag 기반 라우팅.""" + """전략 모드에 따라 청산 체크 분기.""" if self.strategy_mode == "multi" or self.strategy_mode in REGIME_STRATEGY_MODES: return self._check_exits_multi() - elif self.strategy_mode == "smc": - return self._check_exits_smc() - elif self.strategy_mode == "breakout_retest": - return self._check_exits_breakout_retest() - elif self.strategy_mode == "mean_reversion": - return self._check_exits_mean_reversion() - elif self.strategy_mode == "arbitrage": - return self._check_exits_arbitrage() - elif self.strategy_mode == "defensive": - return self._check_exits_defensive() - return self._check_exits_momentum() + module = _STRATEGY_MODULES.get(self.strategy_mode, strat_momentum) + module.check_exits(self) def _check_exits_multi(self): """멀티 전략 모드: 각 포지션의 strategy_tag에 따라 올바른 청산 로직 라우팅.""" @@ -1500,28 +1299,12 @@ def _check_exits_multi(self): # 태그 필터 설정 — 각 exit 메서드가 해당 전략 포지션만 처리 self._exit_tag_filter = tag - if tag == "smc": - self._check_exits_smc() - elif tag == "breakout_retest": - self._check_exits_breakout_retest() - elif tag == "mean_reversion": - self._check_exits_mean_reversion() - elif tag == "arbitrage": - self._check_exits_arbitrage() - elif tag == "defensive": - self._check_exits_defensive() - elif tag == "volatility": - self._check_exits_volatility() - else: # momentum (default) - self._check_exits_momentum() + module = _STRATEGY_MODULES.get(tag, strat_momentum) + module.check_exits(self) self._exit_tag_filter = None self.strategy_mode = original_mode - def _check_exits_momentum(self): - from simulation.strategies.momentum import check_exits - check_exits(self) - def _execute_partial_sell(self, pos: 'SimPosition', sell_qty: int, exit_code: str): """포지션의 일부만 청산 (BULL 이격도 분할 청산용). 수량만 줄이고, 잔여 포지션은 계속 유지. avg_entry_price 유지.