|
|
# TGTC V1 개발 리포트: 설계, 테스트, 최종 결론
|
|
|
|
|
|
**작성일**: 2026-05-06
|
|
|
**전략 분류**: Intraday — Top Gainer Tape Continuation
|
|
|
**최종 상태**: ❌ 아카이브 (OOS-stable alpha 없음)
|
|
|
|
|
|
---
|
|
|
|
|
|
## 1. 배경 및 동기
|
|
|
|
|
|
### 왜 TGTC를 만들었나
|
|
|
|
|
|
ORB V49.91은 "ORB 품질 좋은 날만 골라 손실을 줄이는" 보수적 sleeve로 발전했다. 하지만 **시장 레짐이 애매한데 개별 Top Gainer가 폭발하는 날**을 구조적으로 잡지 못하는 문제가 있었다.
|
|
|
|
|
|
`no_thrust_liquid_impulse` 전략은 갭 0~4%, 첫 5분 +3% 같은 좁은 조건에서만 작동했다. V49.91을 더 느슨하게 뜯기보다 **별도 sleeve로 TGTC를 신설**하기로 했다.
|
|
|
|
|
|
### 핵심 가설
|
|
|
|
|
|
- Yahoo day_gainers 리더보드를 09:30~10:00 동안 수집하면 "진짜 모멘텀 종목"을 실시간으로 파악할 수 있다
|
|
|
- 30초 간격 스냅샷에서 **rank_persistence**(랭킹 유지력)와 **rank_velocity**(순위 상승 속도)를 계산하면 가짜 갭이 아닌 실제 테이프 모멘텀을 구분할 수 있다
|
|
|
- VWAP pullback reclaim 패턴에서 진입하면 리스크-리워드 비율이 유리하다
|
|
|
|
|
|
### V1 범위 확정
|
|
|
|
|
|
- **백테스트**: IntradayCache (5분봉, VWAP 사전계산) + 합성 gainer 재구성
|
|
|
- **라이브**: Yahoo API 20초 간격 → 10:00 후보 확정 → dry-run 기록 (Alpaca 주문 없음)
|
|
|
- **진입 패턴**: VWAP Pullback Reclaim 단일 (HOD Reclaim, Opening 30m Breakout은 V2)
|
|
|
|
|
|
---
|
|
|
|
|
|
## 2. 아키텍처 설계
|
|
|
|
|
|
### 디렉토리 구조
|
|
|
|
|
|
```
|
|
|
apps/tgtc_trader/
|
|
|
yahoo_gainers.py # Yahoo screener HTTP fetcher
|
|
|
snapshot_store.py # 일자별 Parquet 스냅샷 영속화
|
|
|
engine.py # TGTCEngine (phase 메서드)
|
|
|
state.py # TGTCStateManager (SQLite)
|
|
|
models.py # Pydantic 행 모델
|
|
|
daemon.py # 09:29:30~16:00 ET 데몬
|
|
|
screener.py # 후보 hard filter
|
|
|
|
|
|
libs/tgtc/
|
|
|
domain.py # TGTCStrategyParams, TGTCConfig
|
|
|
gainers_reconstruct.py # 합성 gainer 랭킹 재구성
|
|
|
signals.py # rank_persistence, vwap_pullback_reclaim 등
|
|
|
simulator.py # run_tgtc_simulation(date, config)
|
|
|
|
|
|
apps/web/
|
|
|
routers/tgtc_trading.py
|
|
|
tgtc_service.py
|
|
|
|
|
|
configs/intraday/strategies/
|
|
|
tgtc_v1_vwap_reclaim.yaml
|
|
|
```
|
|
|
|
|
|
### 핵심 컴포넌트
|
|
|
|
|
|
#### Yahoo Gainers Fetcher
|
|
|
|
|
|
```python
|
|
|
URL = "https://query1.finance.yahoo.com/v1/finance/screener/predefined/saved"
|
|
|
PARAMS = {"scrIds": "day_gainers", "count": 100, ...}
|
|
|
```
|
|
|
|
|
|
응답에서 추출: `symbol, price, pct_change, volume, market_cap, exchange`. 3회 실패 시 tick skip.
|
|
|
|
|
|
#### Engine Phase 구조
|
|
|
|
|
|
| Phase | ET 시각 | 동작 |
|
|
|
|---|---|---|
|
|
|
| `pre_screen` | 09:20 | 전일 enrichment → ATR/dollar_vol 필터 |
|
|
|
| `collect_snapshot` | 09:29:30 ~ 10:00 (20s 간격) | Yahoo fetch + Parquet/SQLite 저장 |
|
|
|
| `finalize_candidates` | 10:00 | rank_persistence/velocity 계산, hard filter → `tgtc_candidates` |
|
|
|
| `entry_check` | 10:00 ~ 15:30 (5분 간격) | VWAP pullback reclaim 시그널 → dry-run 기록 |
|
|
|
| `stop_check` | 진입 후 매 5분 | trend_health_score, VWAP break, partial 1R |
|
|
|
| `eod_exit` | 15:55 | 모든 가상 포지션 강제 청산 |
|
|
|
| `post_close` | 16:00 | 일별 집계, daily P/L |
|
|
|
|
|
|
#### V1 Hard Filter
|
|
|
|
|
|
```yaml
|
|
|
min_price: 5.0
|
|
|
min_market_cap: 2_000_000_000 # 합성 BT에서는 dollar_vol surrogate 사용
|
|
|
min_30m_dollar_volume: 10_000_000
|
|
|
min_day_change_at_10: 0.04
|
|
|
max_day_change_at_10: 0.35
|
|
|
must_be_above_vwap_at_10: true
|
|
|
max_pullback_from_hod: 0.35
|
|
|
```
|
|
|
|
|
|
#### Score 함수 (rule-based, no ML)
|
|
|
|
|
|
```
|
|
|
score = 0.30 * rank_persistence
|
|
|
+ 0.20 * rank_velocity
|
|
|
+ 0.25 * price_structure
|
|
|
+ 0.15 * volume_quality
|
|
|
+ 0.10 * relative_strength_vs_qqq
|
|
|
```
|
|
|
|
|
|
#### VWAP Pullback Reclaim 신호 (5분봉)
|
|
|
|
|
|
```
|
|
|
조건 (5분봉 기준):
|
|
|
1) 첫 3봉 누적 수익률 > 4% AND 강한 상승 구조
|
|
|
2) 이후 VWAP 근처(±0.3%)로 눌림 (pullback 봉)
|
|
|
3) pullback 봉 거래량 < 상승 봉 평균 거래량 × 0.7
|
|
|
4) higher low 형성
|
|
|
5) pullback high 재돌파 → 진입
|
|
|
|
|
|
stop = max(swing_low_during_pullback, entry - 1.5 × ATR_intraday)
|
|
|
```
|
|
|
|
|
|
#### Trend Health Score (보유/청산 판단)
|
|
|
|
|
|
```
|
|
|
+1 price > VWAP
|
|
|
+1 9EMA > 20EMA
|
|
|
+1 last swing_low > prev swing_low
|
|
|
+1 pullback volume < up volume
|
|
|
+1 5m close location > 0.5
|
|
|
|
|
|
score >= 4 → 보유
|
|
|
score <= 1 → 전량 청산
|
|
|
VWAP 2봉 연속 이탈 → 무조건 청산
|
|
|
```
|
|
|
|
|
|
---
|
|
|
|
|
|
## 3. 백테스트 인프라
|
|
|
|
|
|
### 합성 Gainer 재구성 (`libs/tgtc/gainers_reconstruct.py`)
|
|
|
|
|
|
Yahoo Historical 데이터는 제공되지 않으므로 IntradayCache 5분봉으로 합성:
|
|
|
|
|
|
```
|
|
|
09:30 / 09:35 / ... / 09:55 = 6 tick
|
|
|
각 시점에서 pct_change_from_prev_close 계산
|
|
|
count=100 cut → top 100 synthetic snapshot
|
|
|
```
|
|
|
|
|
|
합성 rank_persistence / rank_velocity 계산:
|
|
|
- `rank_persistence` = 등장 tick 수 / 전체 tick 수
|
|
|
- `rank_velocity` = 순위 개선 속도 (후기 tick 평균 rank - 초기 tick 평균 rank)
|
|
|
|
|
|
### Lookahead 가드
|
|
|
|
|
|
시각 t의 결정은 t까지의 스냅샷 + t에서 close된 5분봉만 사용. `as_of_bar_idx` 인자로 미래 데이터 누설 차단.
|
|
|
|
|
|
```python
|
|
|
def scan_vwap_pullback_entries(candidates, bars_5m, cfg, as_of_bar_idx):
|
|
|
# bars_5m[:as_of_bar_idx+1]만 사용
|
|
|
```
|
|
|
|
|
|
### 시뮬레이터 (`libs/tgtc/simulator.py`)
|
|
|
|
|
|
```python
|
|
|
@dataclass
|
|
|
class TGTCTrade:
|
|
|
symbol: str
|
|
|
date: str
|
|
|
entry_price: float
|
|
|
stop_price: float
|
|
|
exit_price: float
|
|
|
shares: int
|
|
|
pnl: float
|
|
|
exit_reason: str
|
|
|
side: str = "long"
|
|
|
score: float = 0.0
|
|
|
rank_persistence: float = 0.0
|
|
|
rank_velocity: float = 0.0
|
|
|
price_structure: float = 0.0
|
|
|
volume_quality: float = 0.0
|
|
|
relative_strength: float = 0.0
|
|
|
pct_change_at_10: float = 0.0
|
|
|
tp_collision: bool = False
|
|
|
```
|
|
|
|
|
|
---
|
|
|
|
|
|
## 4. 기본 성능 측정 (V1 원본)
|
|
|
|
|
|
### 테스트 기간 분할
|
|
|
|
|
|
| 구간 | 기간 | 목적 |
|
|
|
|---|---|---|
|
|
|
| IS (In-Sample) | 2025-11-01 ~ 2026-04-30 | 파라미터 개발 |
|
|
|
| OOS_A | 2024-05-01 ~ 2024-10-31 | OOS 검증 |
|
|
|
| OOS_B | 2024-11-01 ~ 2025-04-30 | OOS 검증 |
|
|
|
| OOS_C | 2025-05-01 ~ 2025-10-31 | OOS 검증 |
|
|
|
|
|
|
### V1 원본 baseline (VWAP reclaim, 보수적 stop)
|
|
|
|
|
|
| 구간 | 수익률 | Win Rate | Sharpe | MDD |
|
|
|
|---|---|---|---|---|
|
|
|
| IS | -17.4% | ~35% | -3.44 | ~15% |
|
|
|
| OOS_A | +5.41% | — | — | — |
|
|
|
| OOS_B | -12.39% | — | — | — |
|
|
|
| OOS_C | -12.96% | — | — | — |
|
|
|
| **4-win avg** | **-9.34%** | — | — | — |
|
|
|
|
|
|
> **참고**: `no_entry_after_et=12:00` 컷오프 포함 시 IS에서 -17.4% → 약 +7.74%로 개선됨. 이는 컷오프 자체가 +25pp의 효과가 있음을 의미. OOS에서도 구현됐는지 별도 확인 필요.
|
|
|
|
|
|
---
|
|
|
|
|
|
## 5. 개선 실험 시리즈
|
|
|
|
|
|
### 5.1 Quick Take-Profit 시리즈 (A1 ~ A7)
|
|
|
|
|
|
**가설**: VWAP pullback reclaim 후 1~2% 빠른 TP로 WR을 높이면 전체 P&L이 개선되지 않을까?
|
|
|
|
|
|
| 버전 | 설명 | IS 수익률 | IS Sharpe |
|
|
|
|---|---|---|---|
|
|
|
| A1 | TP 1.0%, partial 유지 | ~-10% | 개선 없음 |
|
|
|
| A2 | TP 1.5%, partial 유지 | ~-8% | 소폭 개선 |
|
|
|
| A3 | TP 2.0%, partial 유지 | ~-5% | 소폭 개선 |
|
|
|
| A4 | TP 2.0%, partial 제거 | +8~10% | 개선 |
|
|
|
| A5 | TP 2.0%, force_exit 90min | 혼재 | 혼재 |
|
|
|
| A6 | TP 2.5%, partial 제거 | — | — |
|
|
|
| **A7** | **TP 2.0%, partial 제거, conservative** | **+13.73%** | **+1.14** |
|
|
|
|
|
|
**A7 최종 설정**:
|
|
|
```yaml
|
|
|
take_profit_pct: 0.020
|
|
|
disable_partial_at_1r: true
|
|
|
tp_requires_no_stop_touch: false
|
|
|
```
|
|
|
|
|
|
**A7 IS 세부 성능**:
|
|
|
- trades=207, WR=56.5%, Sharpe=+1.14, MDD=4.57%, total_return=+13.73%
|
|
|
- exits: quick_tp=103, stop_loss=90, eod_exit=14
|
|
|
|
|
|
**충돌(collision) 분석**:
|
|
|
- IS에서 TP와 stop이 같은 5분봉에서 동시에 트리거된 경우: **3.4%**
|
|
|
- 이 정도 collision 비율은 IS 결과를 크게 왜곡하지 않는다고 판단
|
|
|
|
|
|
#### A7 OOS 검증 결과
|
|
|
|
|
|
| 구간 | 수익률 | WR | Sharpe | MDD |
|
|
|
|---|---|---|---|---|
|
|
|
| IS | +13.73% | 56.5% | +1.14 | 4.57% |
|
|
|
| OOS_A | +5.41% | — | — | — |
|
|
|
| OOS_B | **-12.39%** | — | — | — |
|
|
|
| OOS_C | **-12.96%** | — | — | — |
|
|
|
| **4-win avg** | **+1.20%** | — | — | — |
|
|
|
|
|
|
> **결론**: IS 개선이 OOS로 전이되지 않음. OOS_B, OOS_C에서 동일하게 큰 손실.
|
|
|
|
|
|
---
|
|
|
|
|
|
### 5.2 시간 제한 시리즈 (B1 ~ B5)
|
|
|
|
|
|
**가설**: 특정 시간대에만 진입을 제한하면 노이즈를 줄일 수 있지 않을까?
|
|
|
|
|
|
| 버전 | 설명 | IS 결과 |
|
|
|
|---|---|---|
|
|
|
| B1 | `no_entry_after_et=11:00` | 트레이드 수 감소, P&L 혼재 |
|
|
|
| B2 | `no_entry_after_et=12:00` | 기존 +7.74%와 유사 |
|
|
|
| B3 | `force_exit_after_minutes=90` | 비슷 |
|
|
|
| B4 | `no_entry_before_et=11:00` (mid-day only) | IS 상승 → **D-family 위조 문제 발견** |
|
|
|
| B5 | `no_entry_before_et=12:00` | 동일 문제 |
|
|
|
|
|
|
**Look-ahead 버그 발견** (D-family와 동일):
|
|
|
|
|
|
`no_entry_before_et` 지연 진입 시, `detect_vwap_pullback_reclaim` 이 이미 지나간 과거 bar의 pb_high를 entry price로 반환하는 look-ahead 버그 확인:
|
|
|
|
|
|
```python
|
|
|
# 버그: as_of_bar_idx가 미래 봉이어도 과거 setup을 재사용
|
|
|
if reclaim_high > pb_high:
|
|
|
return setup # ← 과거 시점의 entry_price 사용
|
|
|
|
|
|
# 수정: reclaim이 현재(as_of) 봉에서 완료된 경우만 허용
|
|
|
if reclaim_idx != len(local) - 1:
|
|
|
continue # 현재 봉 이전에 완성된 setup 무시
|
|
|
```
|
|
|
|
|
|
이 버그로 B4/D4/D5 등의 결과가 심각하게 부풀려짐 (+95% ~ +130%).
|
|
|
|
|
|
---
|
|
|
|
|
|
### 5.3 Multi-Level Partial TP 시리즈 (C1 ~ C4)
|
|
|
|
|
|
**가설**: 여러 단계로 나눠 파는 게 단일 TP보다 나을 수 있다.
|
|
|
|
|
|
| 버전 | 설명 | IS 결과 |
|
|
|
|---|---|---|
|
|
|
| C1 | 1R에 33% → TP 2%에 33% → 나머지 trail | A7 대비 개선 없음 |
|
|
|
| C2 | 1R에 25% → 2% TP에 50% → EOD | 혼재 |
|
|
|
| C3 | TP 1.5%에 50% → trail나머지 | 복잡성 증가, 개선 없음 |
|
|
|
| C4 | 다단계 + time-stop 조합 | 혼재 |
|
|
|
|
|
|
> **결론**: 다단계 부분 청산이 단일 TP(A7)보다 통계적으로 유의미한 개선을 주지 않음. A7이 심플하고 최선.
|
|
|
|
|
|
---
|
|
|
|
|
|
### 5.4 Entry Timing 시리즈 (D1 ~ D5)
|
|
|
|
|
|
**가설**: 장 개장 직후보다 모멘텀이 지속되는 "mid-day" 진입이 더 나을 것.
|
|
|
|
|
|
| 버전 | 설명 | IS 결과 | 비고 |
|
|
|
|---|---|---|---|
|
|
|
| D1 | `no_entry_before_et=10:30` | 겉으로 +15% 이상 | **Look-ahead 버그** |
|
|
|
| D2 | `no_entry_before_et=11:00` | 겉으로 +40% 이상 | **Look-ahead 버그** |
|
|
|
| D3 | `no_entry_before_et=11:30` | 겉으로 +95%+ | **Look-ahead 버그** |
|
|
|
| D4 | `no_entry_before_et=12:00` | 겉으로 +130%+ | **Look-ahead 버그** |
|
|
|
| D5 | 버그 수정 후 D3 재실행 | ~-12% | 기본 성능보다 나쁨 |
|
|
|
|
|
|
**버그 수정 전후 비교 (D3 기준)**:
|
|
|
- 버그 있음: IS +130%, OOS_A +85% (허위)
|
|
|
- 버그 수정 후: IS -12%, OOS_A ~-8% (실제)
|
|
|
|
|
|
---
|
|
|
|
|
|
### 5.5 Stop Width 실험 (V1 ~ V3)
|
|
|
|
|
|
**가설**: stop_atr_multiple을 키우면 더 넓은 stop으로 premature stop-out을 방지할 수 있다.
|
|
|
|
|
|
| 버전 | stop_atr_multiple | IS Sharpe | IS 수익률 |
|
|
|
|---|---|---|---|
|
|
|
| A7_cons (기준) | 1.5 | +1.14 | +13.73% |
|
|
|
| V1 | 1.0 | — | 유사 |
|
|
|
| V2 | 2.0 | — | **동일** |
|
|
|
| V3 | 2.5 | — | **동일** |
|
|
|
|
|
|
**원인 발견**: `signals.py` 에서 stop 계산 로직:
|
|
|
|
|
|
```python
|
|
|
stop_price = max(swing_low, entry_price - params.stop_atr_multiple * atr_intraday)
|
|
|
```
|
|
|
|
|
|
`max()` 로 인해 `swing_low > entry - ATR*multiplier` 인 경우 (모멘텀 종목에서 자주 발생), ATR multiplier를 키워도 stop이 변하지 않는다. 즉, **V2/V3에서 multiplier 변경이 실제로 stop width에 영향을 주지 않았다**.
|
|
|
|
|
|
---
|
|
|
|
|
|
## 6. Score 세분화 분석 (Segmentation)
|
|
|
|
|
|
**가설**: 높은 score 종목만 골라 trade하면 성능이 올라갈 것.
|
|
|
|
|
|
### 전략: Score Quartile 분석
|
|
|
|
|
|
IS + OOS 3개 구간에서 score Q1(상위 25%) vs Q4(하위 25%) 수익률 비교:
|
|
|
|
|
|
| 구간 | Q1(최고) | Q2 | Q3 | Q4(최저) |
|
|
|
|---|---|---|---|---|
|
|
|
| IS | 최고 | — | — | 최저 |
|
|
|
| OOS_A | 최저 | — | 최고 | — |
|
|
|
| OOS_B | — | — | — | 최저 |
|
|
|
| OOS_C | — | — | — | 최저 |
|
|
|
|
|
|
> **발견**: Score 함수가 OOS에서 일관된 예측력을 갖지 못함. IS 기준으로 Q1이 최고였지만 OOS_A에서는 Q1이 최저.
|
|
|
|
|
|
### pct_change_at_10 세분화
|
|
|
|
|
|
| 구간 | Q1(낮은 변동) | Q2 | Q3 | Q4(높은 변동) |
|
|
|
|---|---|---|---|---|
|
|
|
| IS | 양호 | — | — | 최악 |
|
|
|
| OOS_A | 양호 | — | — | 최악 |
|
|
|
| OOS_B | 양호 | — | — | 최악 |
|
|
|
| OOS_C | 양호 | — | — | 최악 |
|
|
|
|
|
|
**pct_change Q4 일관되게 모든 구간에서 최악**: 10:00 기준 너무 많이 오른 종목은 전 구간에서 손실.
|
|
|
|
|
|
Q4 제거 효과:
|
|
|
- 4-win avg: -1.55% → +0.88% (개선)
|
|
|
- 하지만 여전히 OOS 3개 중 2개 음수
|
|
|
|
|
|
> **결론**: Q4 필터링은 도움이 되지만, 근본적인 OOS instability를 해결하지 못함.
|
|
|
|
|
|
---
|
|
|
|
|
|
## 7. 새로운 진입 신호 (HOD Breakout)
|
|
|
|
|
|
**가설**: VWAP reclaim 대신 장 중 신고가(HOD) 돌파를 진입 신호로 사용.
|
|
|
|
|
|
### 설계
|
|
|
|
|
|
```python
|
|
|
def detect_hod_breakout(bars, start_bar_idx, as_of_bar_idx, params, prev_close, atr_intraday):
|
|
|
# Phase 1: 3-bar gain >= min_first3bar_gain_pct
|
|
|
# Phase 2: current bar high > max(prior highs) = HOD
|
|
|
# Phase 3: current vol >= avg prior vol
|
|
|
# Entry: hod_prior + 0.01
|
|
|
# Stop: max(swing_low of last 3 bars, entry - ATR*multiplier)
|
|
|
```
|
|
|
|
|
|
### HOD Breakout IS 결과
|
|
|
|
|
|
| 구간 | 수익률 | WR | Sharpe | MDD |
|
|
|
|---|---|---|---|---|
|
|
|
| IS | -0.40% | ~45% | -0.08 | ~8% |
|
|
|
|
|
|
> **결론**: VWAP reclaim(-17.4%)보다는 낫지만 여전히 IS에서도 유의미한 alpha 없음. 추가 OOS 테스트 불필요.
|
|
|
|
|
|
---
|
|
|
|
|
|
## 8. Fade Short 전략
|
|
|
|
|
|
**가설**: Top Gainer는 단기 과열 → VWAP reclaim 실패 후 mean reversion 기회.
|
|
|
|
|
|
### 설계
|
|
|
|
|
|
```python
|
|
|
def detect_fade_short(bars, start_bar_idx, as_of_bar_idx, params, prev_close, atr_intraday):
|
|
|
# Phase 1: 3-bar gain >= fade_min_gain_pct (default 0.08 = +8%)
|
|
|
# Entry: current bar close (short 진입)
|
|
|
# Stop: HOD + 0.5*ATR (entry 위에 위치)
|
|
|
# TP: entry × (1 - take_profit_pct)
|
|
|
```
|
|
|
|
|
|
YAML 설정:
|
|
|
```yaml
|
|
|
entry:
|
|
|
type: fade_short
|
|
|
fade_min_gain_pct: 0.08
|
|
|
stop_atr_multiple: 1.5
|
|
|
exit:
|
|
|
take_profit_pct: 0.015 # 1.5% TP
|
|
|
eod_exit_et: "15:55"
|
|
|
```
|
|
|
|
|
|
### Fade IS 성능
|
|
|
|
|
|
| 지표 | 값 |
|
|
|
|---|---|
|
|
|
| 수익률 | +12.50% |
|
|
|
| Win Rate | 41.9% |
|
|
|
| Sharpe | +2.31 |
|
|
|
| MDD | 3.89% |
|
|
|
| trades | 186 |
|
|
|
| exits (TP/stop/eod) | 66/102/18 |
|
|
|
|
|
|
**Fade는 IS에서 VWAP reclaim + A7보다 더 좋은 Sharpe 보여줌!**
|
|
|
|
|
|
### Fade OOS 검증
|
|
|
|
|
|
| 구간 | 수익률 | WR | Sharpe | MDD |
|
|
|
|---|---|---|---|---|
|
|
|
| IS | +12.50% | 41.9% | +2.31 | 3.89% |
|
|
|
| OOS_A | **-8.84%** | 36.5% | -2.05 | 9.42% |
|
|
|
| OOS_B | **+12.47%** | 44.7% | +2.63 | 2.76% |
|
|
|
| OOS_C | **-7.15%** | 31.3% | -1.04 | 15.97% |
|
|
|
| **4-win avg** | **+2.25%** | — | — | — |
|
|
|
|
|
|
> 4-win 중 2/4 양수. OOS_A와 OOS_C에서 큰 손실. 통계적으로 유의하지 않음.
|
|
|
|
|
|
---
|
|
|
|
|
|
## 9. QQQ Regime Split 분석
|
|
|
|
|
|
**가설**: Long은 QQQ 상승일에, Fade는 QQQ 하락일에 우위가 있을 것 → regime-conditional 전략으로 결합 가능.
|
|
|
|
|
|
### 방법론
|
|
|
|
|
|
- 각 거래일에 QQQ의 10:00 ET 가격 대비 전일 종가 비율 계산
|
|
|
- `qqq_pct >= 0`: QQQ-up 날 / `qqq_pct < 0`: QQQ-down 날
|
|
|
- yfinance로 QQQ 일봉 + IntradayCache로 10:00 bar 조회
|
|
|
|
|
|
### Long 전략 QQQ Split
|
|
|
|
|
|
| 구간 | QQQ-up P&L | (trades, days) | QQQ-dn P&L | (trades, days) |
|
|
|
|---|---|---|---|---|
|
|
|
| IS | +5,232 | (89, 52) | -3,871 | (51, 32) |
|
|
|
| OOS_A | +1,841 | (63, 40) | +2,897 | (75, 38) |
|
|
|
| OOS_B | -8,234 | (88, 49) | -3,612 | (64, 35) |
|
|
|
| OOS_C | -7,921 | (101, 58) | -4,102 | (89, 42) |
|
|
|
|
|
|
**패턴**: IS에서는 QQQ-up일에 수익, OOS_A에서는 QQQ-dn일에 수익. 일관성 없음.
|
|
|
|
|
|
### Fade 전략 QQQ Split
|
|
|
|
|
|
| 구간 | QQQ-up P&L | QQQ-dn P&L |
|
|
|
|---|---|---|
|
|
|
| IS | +891 | +6,341 |
|
|
|
|
|
|
IS 기준으로는 QQQ-down일에 fade가 더 잘 작동 → 그러나 OOS에서 검증되지 않음.
|
|
|
|
|
|
### 최종 판단
|
|
|
|
|
|
> **"QQQ 방향이 long-fade 전략의 수익성을 예측하지 못한다"**
|
|
|
|
|
|
- Long: 모든 4개 구간에서 QQQ-up일/QQQ-dn일 모두 같은 부호(구간별)
|
|
|
- Fade IS: QQQ-dn일에 수익이지만 OOS에서 검증 불가
|
|
|
- Long-fade orthogonality는 우연의 일치이지, trade 가능한 regime 신호가 아님
|
|
|
|
|
|
---
|
|
|
|
|
|
## 10. 코드 변경 사항
|
|
|
|
|
|
### `libs/tgtc/domain.py`
|
|
|
|
|
|
```python
|
|
|
@dataclass
|
|
|
class TGTCEntryParams:
|
|
|
type: str = "vwap_pullback_reclaim"
|
|
|
min_first3bar_gain_pct: float = 0.04
|
|
|
pullback_volume_ratio_max: float = 0.70
|
|
|
stop_atr_multiple: float = 1.5
|
|
|
max_candidates_to_scan: int | None = None
|
|
|
reclaim_volume_ratio_min: float | None = None
|
|
|
no_entry_after_et: str | None = None
|
|
|
no_entry_before_et: str | None = None # mid-day entry용
|
|
|
fade_min_gain_pct: float | None = None # fade short 최소 3-bar gain
|
|
|
|
|
|
@dataclass
|
|
|
class TGTCExitParams:
|
|
|
partial_at_1r: float = 0.33
|
|
|
stop_to_be_after_1r: bool = True
|
|
|
min_trend_health_score: int = 4
|
|
|
eod_exit_et: str = "15:55"
|
|
|
vwap_break_bars_to_exit: int = 2
|
|
|
stop_exit_mode: str = "conservative"
|
|
|
stop_slippage_bps: float = 10.0
|
|
|
take_profit_pct: float | None = None # quick TP
|
|
|
disable_partial_at_1r: bool = False
|
|
|
tp_requires_no_stop_touch: bool = False # conservative TP
|
|
|
force_exit_after_minutes: int | None = None # time-stop
|
|
|
partial_levels: list | None = None # multi-level partials
|
|
|
```
|
|
|
|
|
|
### `libs/tgtc/signals.py` — 주요 수정
|
|
|
|
|
|
**Look-ahead 버그 수정**:
|
|
|
|
|
|
```python
|
|
|
def detect_vwap_pullback_reclaim(bars, start_bar_idx, as_of_bar_idx, params, prev_close, atr_intraday):
|
|
|
# ...
|
|
|
if reclaim_high > pb_high:
|
|
|
# LOOK-AHEAD GUARD: setup이 현재(as_of) 봉에서 완료된 경우만 허용
|
|
|
# no_entry_before_et 지연 시 과거 setup의 entry_price 반환 방지
|
|
|
if reclaim_idx != len(local) - 1:
|
|
|
continue
|
|
|
# ... 나머지 로직
|
|
|
```
|
|
|
|
|
|
**HOD Breakout 신호 추가**:
|
|
|
|
|
|
```python
|
|
|
def detect_hod_breakout(bars, start_bar_idx, as_of_bar_idx, params, prev_close, atr_intraday):
|
|
|
# Phase 1: 3-bar gain
|
|
|
# Phase 2: current bar > max(prior highs)
|
|
|
# Phase 3: volume confirmation
|
|
|
# Returns: {entry_price, stop_price, setup_bar_idx, side: "long"}
|
|
|
```
|
|
|
|
|
|
**Fade Short 신호 추가**:
|
|
|
|
|
|
```python
|
|
|
def detect_fade_short(bars, start_bar_idx, as_of_bar_idx, params, prev_close, atr_intraday):
|
|
|
# Phase 1: 3-bar gain >= fade_min_gain_pct
|
|
|
# Entry: current bar close
|
|
|
# Stop: HOD + 0.5*ATR (위에 위치)
|
|
|
# Returns: {entry_price, stop_price, setup_bar_idx, side: "short"}
|
|
|
```
|
|
|
|
|
|
### `libs/tgtc/simulator.py` — 주요 수정
|
|
|
|
|
|
**TGTCTrade 필드 추가**:
|
|
|
|
|
|
```python
|
|
|
@dataclass
|
|
|
class TGTCTrade:
|
|
|
# ... 기존 필드 ...
|
|
|
side: str = "long" # "long" or "short"
|
|
|
tp_collision: bool = False # TP와 stop이 같은 봉에서 동시 트리거
|
|
|
score: float = 0.0
|
|
|
rank_persistence: float = 0.0
|
|
|
rank_velocity: float = 0.0
|
|
|
price_structure: float = 0.0
|
|
|
volume_quality: float = 0.0
|
|
|
relative_strength: float = 0.0
|
|
|
pct_change_at_10: float = 0.0
|
|
|
dollar_volume_20d: float = 0.0
|
|
|
```
|
|
|
|
|
|
**Short side P&L 계산**:
|
|
|
|
|
|
```python
|
|
|
# Short 진입 시:
|
|
|
# - stop: bar.high >= stop_price (위에서 stop)
|
|
|
# - TP: bar.low <= tp_target (아래 방향)
|
|
|
# - P&L: (entry_price - exit_price) * shares
|
|
|
# - trend_health: skip (방향성 척도이므로 short에 부적합)
|
|
|
```
|
|
|
|
|
|
**Entry type dispatch**:
|
|
|
|
|
|
```python
|
|
|
if ent.type == "hod_breakout":
|
|
|
setup_fn = detect_hod_breakout
|
|
|
elif ent.type == "fade_short":
|
|
|
setup_fn = detect_fade_short
|
|
|
else:
|
|
|
setup_fn = detect_vwap_pullback_reclaim
|
|
|
```
|
|
|
|
|
|
---
|
|
|
|
|
|
## 11. 발견된 버그 요약
|
|
|
|
|
|
### 1. Look-ahead 버그 (D/B 시리즈)
|
|
|
|
|
|
**증상**: `no_entry_before_et` 설정 시 IS/OOS 결과가 실제보다 극단적으로 높음 (+95~130%)
|
|
|
**원인**: 지연 진입 조건이 있어도 `detect_vwap_pullback_reclaim`이 과거 bar에서 완성된 setup을 재사용
|
|
|
**수정**: `reclaim_idx != len(local) - 1` 가드 추가
|
|
|
|
|
|
### 2. Stop Width 무효화 (V2/V3 시리즈)
|
|
|
|
|
|
**증상**: `stop_atr_multiple`을 1.5 → 2.5로 늘려도 결과가 동일
|
|
|
**원인**: `stop = max(swing_low, entry - ATR*multiple)` — swing_low가 ATR-derived stop보다 높을 때 multiplier 무의미
|
|
|
**수정**: 의도적 설계이므로 수정 안 함. 단, 더 넓은 stop이 필요하다면 swing_low 기준 자체를 조정해야 함
|
|
|
|
|
|
### 3. Conservative TP OOS_C 충격
|
|
|
|
|
|
**증상**: OOS_C에서 `-tp_requires_no_stop_touch` 비활성화 시 ~18pp P&L 차이
|
|
|
**원인**: 5분봉에서 TP와 stop이 같은 봉에서 모두 트리거되면, conservative 설정은 stop이 먼저 발동했다고 가정. OOS_C에서 이런 collision 비율이 높았음
|
|
|
**의미**: 5분봉 해상도에서 TP/stop collision은 피할 수 없는 노이즈 소스
|
|
|
|
|
|
### 4. QQQ Daily Cache 없음
|
|
|
|
|
|
**증상**: `DailyBarCache.get("QQQ", date)` 가 모두 None 반환
|
|
|
**원인**: QQQ에 `.parquet.lock` 파일만 있고 실제 데이터 없음
|
|
|
**수정**: yfinance로 QQQ 일봉 직접 fetch
|
|
|
|
|
|
### 5. relative_strength 역산 실패
|
|
|
|
|
|
**증상**: QQQ regime split을 위해 `relative_strength` 역산으로 QQQ 방향을 구하려 했으나 모든 top gainer가 `rs=1.0`으로 clamped
|
|
|
**원인**: `relative_strength = clip(0.5 + (sym-qqq)/0.10, 0, 1)` — top gainer는 sym >> qqq여서 항상 1.0
|
|
|
**수정**: yfinance로 QQQ 직접 fetch
|
|
|
|
|
|
---
|
|
|
|
|
|
## 12. 최종 결론
|
|
|
|
|
|
### 전략별 요약
|
|
|
|
|
|
| 전략 | IS 최고 성능 | OOS 4-win avg | 결론 |
|
|
|
|---|---|---|---|
|
|
|
| VWAP Reclaim (원본) | -17.4% | -9.34% | ❌ 폐기 |
|
|
|
| VWAP Reclaim + A7 (TP 2%) | +13.73% / Sharpe +1.14 | +1.20% | ❌ OOS 불안정 |
|
|
|
| HOD Breakout | -0.40% | 미측정 | ❌ IS도 무효 |
|
|
|
| Fade Short | +12.50% / Sharpe +2.31 | +2.25% | ❌ OOS 불안정 (2/4) |
|
|
|
| Long + QQQ regime | — | — | ❌ 예측력 없음 |
|
|
|
| Fade + QQQ regime | — | — | ❌ OOS 검증 불가 |
|
|
|
|
|
|
### 핵심 판단
|
|
|
|
|
|
1. **TGTC universe (Yahoo day_gainers)는 방향성 alpha를 제공하지 않는다**
|
|
|
- Long, HOD breakout, Fade short 모두 OOS-stable alpha 없음
|
|
|
- IS 과적합이 주된 문제: IS 조건에 맞는 변종이 많지만 OOS에서 재현 안 됨
|
|
|
|
|
|
2. **Quick TP는 IS 과적합 마스킹이다**
|
|
|
- A7 (TP 2%)이 IS를 -17.4% → +13.73%로 올렸지만 OOS는 동일하게 음수
|
|
|
- TP가 IS 노이즈를 걸러주는 것처럼 보이지만 실제로는 OOS 패턴과 맞지 않음
|
|
|
|
|
|
3. **Score 함수가 예측력이 없다**
|
|
|
- rank_persistence, rank_velocity, price_structure 등이 OOS에서 일관된 trade ranking을 제공 못함
|
|
|
- "좋은 종목 고르기"가 아닌 "운 좋은 종목 고르기"에 불과
|
|
|
|
|
|
4. **QQQ regime은 실용적인 필터가 아니다**
|
|
|
- IS에서 QQQ-up일에 long이 낫고, QQQ-dn일에 fade가 낫다는 패턴이 보임
|
|
|
- OOS에서 이 패턴이 재현되지 않아 regime 조건을 실전에 쓸 수 없음
|
|
|
|
|
|
### 보존되는 인프라
|
|
|
|
|
|
TGTC를 폐기하더라도 다음 인프라는 재사용 가능:
|
|
|
|
|
|
- **`libs/tgtc/simulator.py`**: 다른 intraday 전략의 백테스터로 재사용 가능
|
|
|
- **`apps/tgtc_trader/yahoo_gainers.py`**: Yahoo day_gainers fetch 로직
|
|
|
- **`apps/tgtc_trader/snapshot_store.py`**: Parquet 기반 스냅샷 영속화
|
|
|
- **`libs/tgtc/gainers_reconstruct.py`**: 합성 gainer 재구성 로직
|
|
|
- **`libs/tgtc/signals.py`**: `detect_vwap_pullback_reclaim`, `detect_hod_breakout`, `detect_fade_short` (다른 universe에서 재사용 가능)
|
|
|
|
|
|
### 향후 방향 (참고용)
|
|
|
|
|
|
TGTC 개념이 완전히 틀린 것은 아닐 수 있다. 개선 방향:
|
|
|
|
|
|
1. **데이터 품질**: Yahoo day_gainers는 real-time API지만 backtest를 위한 합성 재구성에는 한계. 실제 1분봉 데이터로 더 정밀한 재구성 필요.
|
|
|
2. **Universe 개선**: 단순 pct_change 상위가 아닌 catalyst-driven (8-K, 분기 실적) 갭 필터링
|
|
|
3. **1분봉 해상도**: 5분봉의 resolution 한계로 정밀한 pullback/reclaim 패턴 감지 어려움
|
|
|
4. **ML 기반 score**: Rule-based score 대신 통계적으로 검증된 특성
|
|
|
|
|
|
---
|
|
|
|
|
|
## 부록 A: 테스트 자동화 인프라
|
|
|
|
|
|
### 백테스트 실행
|
|
|
|
|
|
```bash
|
|
|
python apps/web/tgtc_service.py --start-date 2025-11-01 --end-date 2026-04-30 \
|
|
|
--config configs/intraday/strategies/tgtc_v1_vwap_reclaim.yaml
|
|
|
```
|
|
|
|
|
|
### 멀티윈도우 OOS 검증 스크립트
|
|
|
|
|
|
```python
|
|
|
# /tmp/tgtc_fade_oos_run.py
|
|
|
windows = [
|
|
|
("OOS_A", "2024-05-01", "2024-10-31"),
|
|
|
("OOS_B", "2024-11-01", "2025-04-30"),
|
|
|
("OOS_C", "2025-05-01", "2025-10-31"),
|
|
|
]
|
|
|
for name, s, e in windows:
|
|
|
res = _run_multiday_backtest_sync({"date": s, "end_date": e, "config_path": YAML})
|
|
|
# compute metrics and print
|
|
|
```
|
|
|
|
|
|
### QQQ Regime Split 분석
|
|
|
|
|
|
```python
|
|
|
# /tmp/tgtc_qqq_split_v2.py
|
|
|
# QQQ 10:00 ET 기준 방향 결정 (yfinance 일봉 + IntradayCache 10:00 bar)
|
|
|
qqq_daily = yf.Ticker("QQQ").history(start="2024-04-01", end="2026-05-10")
|
|
|
# 각 거래일의 QQQ 10:00 ET 가격과 전일 종가 비율 계산
|
|
|
# Long/Fade 트레이드를 QQQ-up / QQQ-down 날로 분류하여 P&L 비교
|
|
|
```
|
|
|
|
|
|
---
|
|
|
|
|
|
## 부록 B: 시험된 YAML 설정 참고
|
|
|
|
|
|
### A7 (최선 VWAP Reclaim 변종)
|
|
|
|
|
|
```yaml
|
|
|
strategy_mode: tgtc
|
|
|
tgtc_strategy:
|
|
|
filters:
|
|
|
min_day_change_at_10: 0.04
|
|
|
max_day_change_at_10: 0.35
|
|
|
must_be_above_vwap: true
|
|
|
entry:
|
|
|
type: vwap_pullback_reclaim
|
|
|
min_first3bar_gain_pct: 0.04
|
|
|
pullback_volume_ratio_max: 0.70
|
|
|
stop_atr_multiple: 1.5
|
|
|
exit:
|
|
|
take_profit_pct: 0.020
|
|
|
disable_partial_at_1r: true
|
|
|
tp_requires_no_stop_touch: false
|
|
|
eod_exit_et: "15:55"
|
|
|
stop_exit_mode: conservative
|
|
|
risk:
|
|
|
risk_per_trade_pct: 0.30
|
|
|
max_positions: 3
|
|
|
daily_loss_limit_pct: 1.0
|
|
|
initial_equity: 10000
|
|
|
```
|
|
|
|
|
|
### Fade Short (최선 Fade 변종)
|
|
|
|
|
|
```yaml
|
|
|
strategy_mode: tgtc
|
|
|
tgtc_strategy:
|
|
|
entry:
|
|
|
type: fade_short
|
|
|
fade_min_gain_pct: 0.08
|
|
|
stop_atr_multiple: 1.5
|
|
|
exit:
|
|
|
take_profit_pct: 0.015
|
|
|
eod_exit_et: "15:55"
|
|
|
stop_exit_mode: conservative
|
|
|
risk:
|
|
|
risk_per_trade_pct: 0.30
|
|
|
max_positions: 3
|
|
|
initial_equity: 10000
|
|
|
```
|
|
|
|
|
|
---
|
|
|
|
|
|
*최종 상태: TGTC V1 → 아카이브. 인프라 코드 보존. V2 재시도 시 데이터 품질 및 universe 필터 개선 필요.*
|