로고스 전략 FSM을 simulation 기본 실행에 통합한다.

수동 타점(logos_trades.json) 흐름에 맞춘 순차 매매 로직을 추가하고, python simulation.py 실행 시 로고스 백테스트·HTML을 생성한다. 규칙 탐색·BB 안전장치 개선과 함께 reports HTML은 gitignore로 제외한다.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-05-29 19:07:10 +09:00
parent e218a8ea32
commit e631a5701f
12 changed files with 1639 additions and 100 deletions

View File

@@ -6,9 +6,15 @@ BITHUMB_SECRET_KEY=
COIN_TELEGRAM_BOT_TOKEN=
COIN_TELEGRAM_CHAT_ID=
# 쿨다운(초) — 3분 전략
BUY_COOLDOWN_SEC=300
SELL_COOLDOWN_SEC=180
# 쿨다운(초) — 3분 전략 (빈번 체결 완화)
BUY_COOLDOWN_SEC=1800
SELL_COOLDOWN_SEC=900
SIGNAL_EDGE_ONLY=true
TRADE_MIN_GAP_BARS=5
DISCOVER_MAX_TRADES=120
DISCOVER_TRADE_PENALTY_PCT=0.03
SELL_MIN_BB_POS=0.4
BUY_MAX_BB_POS_CHASE=0.55
# 매수 금액(KRW)
DEFAULT_BUY_KRW=30000

3
.gitignore vendored
View File

@@ -86,6 +86,9 @@ celerybeat-schedule
# dotenv
.env
# 백테스트·시뮬레이션 HTML (로컬 재생성)
reports/
# virtualenv
.venv
venv/

331
combination_report.json Normal file
View File

@@ -0,0 +1,331 @@
{
"generated_at": "2026-05-29T13:55:17",
"intervals_loaded": [
1,
3,
5,
10,
15,
30,
60,
240,
1440
],
"latest_positions": [
{
"interval": 1,
"label": "1분",
"close": 425.0,
"bb_pos": 0.352,
"bb_zone": "mid",
"bb_state": "squeeze",
"ichi_position": "in_cloud",
"ichi_tk": "bear",
"ichi_cloud": "bull"
},
{
"interval": 3,
"label": "3분",
"close": 425.0,
"bb_pos": 0.638,
"bb_zone": "mid",
"bb_state": "inside_band",
"ichi_position": "above_cloud",
"ichi_tk": "bull",
"ichi_cloud": "bull"
},
{
"interval": 5,
"label": "5분",
"close": 425.0,
"bb_pos": 0.706,
"bb_zone": "high",
"bb_state": "inside_band",
"ichi_position": "above_cloud",
"ichi_tk": "bull",
"ichi_cloud": "bear"
},
{
"interval": 10,
"label": "10분",
"close": 425.0,
"bb_pos": 0.744,
"bb_zone": "high",
"bb_state": "inside_band",
"ichi_position": "in_cloud",
"ichi_tk": "bear",
"ichi_cloud": "bear"
},
{
"interval": 15,
"label": "15분",
"close": 425.0,
"bb_pos": 0.526,
"bb_zone": "mid",
"bb_state": "inside_band",
"ichi_position": "above_cloud",
"ichi_tk": "bear",
"ichi_cloud": "bear"
},
{
"interval": 30,
"label": "30분",
"close": 425.0,
"bb_pos": 0.448,
"bb_zone": "mid",
"bb_state": "inside_band",
"ichi_position": "above_cloud",
"ichi_tk": "bear",
"ichi_cloud": "bear"
},
{
"interval": 60,
"label": "60분",
"close": 425.0,
"bb_pos": 0.613,
"bb_zone": "mid",
"bb_state": "inside_band",
"ichi_position": "below_cloud",
"ichi_tk": "bull",
"ichi_cloud": "bear"
},
{
"interval": 240,
"label": "240분",
"close": 425.0,
"bb_pos": 0.317,
"bb_zone": "low",
"bb_state": "inside_band",
"ichi_position": "below_cloud",
"ichi_tk": "bear",
"ichi_cloud": "bear"
},
{
"interval": 1440,
"label": "일봉",
"close": 425.0,
"bb_pos": 0.417,
"bb_zone": "mid",
"bb_state": "inside_band",
"ichi_position": "below_cloud",
"ichi_tk": "bull",
"ichi_cloud": "bull"
}
],
"buy_recommendations": [
"m30:bullish — 410회, +1.08% (+1.04%p)",
"m60:above_upper — 480회, +1.00% (+0.96%p)",
"m60:cross_up_upper — 340회, +0.89% (+0.86%p)",
"m60:bullish — 960회, +0.76% (+0.72%p)",
"m240:hammer — 1588회, +0.70% (+0.66%p)",
"m240:cross_up_upper — 160회, +0.66% (+0.62%p)",
"m60:bb_zone_top — 1377회, +0.66% (+0.62%p)",
"m30:ichi_tk_cross_down — 148회, +0.62% (+0.59%p)",
"조합 m30:bullish + m60:above_upper — 50회, +2.48%p",
"조합 m60:above_upper + m30:body_strong — 50회, +2.44%p",
"조합 m30:bullish + d1:bb_zone_high — 130회, +2.39%p",
"조합 m30:bullish + m60:cross_up_upper — 30회, +2.26%p",
"조합 d1:bb_zone_high + m30:body_strong — 100회, +2.13%p"
],
"sell_recommendations": [],
"buy_avoid": [
"매수 회피: m240:cross_down_lower (-0.93%p)",
"매수 회피: m240:below_lower (-0.93%p)",
"매수 회피: m10:body_ratio (-0.63%p)",
"매수 회피: m15:cross_up_lower (-0.58%p)",
"매수 회피: m10:bullish (-0.54%p)",
"매수 회피: m240:cross_up_lower (-0.53%p)"
],
"top_buy_pairs": [
{
"keys": [
"m30:bullish",
"m60:above_upper"
],
"count": 50,
"avg_forward_pct": 2.5214,
"edge_vs_base": 2.4838
},
{
"keys": [
"m60:above_upper",
"m30:body_strong"
],
"count": 50,
"avg_forward_pct": 2.4777,
"edge_vs_base": 2.4401
},
{
"keys": [
"m30:bullish",
"d1:bb_zone_high"
],
"count": 130,
"avg_forward_pct": 2.4265,
"edge_vs_base": 2.3889
},
{
"keys": [
"m30:bullish",
"m60:cross_up_upper"
],
"count": 30,
"avg_forward_pct": 2.2987,
"edge_vs_base": 2.2611
},
{
"keys": [
"d1:bb_zone_high",
"m30:body_strong"
],
"count": 100,
"avg_forward_pct": 2.1672,
"edge_vs_base": 2.1296
},
{
"keys": [
"m60:bb_zone_top",
"m30:body_strong"
],
"count": 80,
"avg_forward_pct": 2.0742,
"edge_vs_base": 2.0366
},
{
"keys": [
"m240:hammer",
"m30:body_strong"
],
"count": 139,
"avg_forward_pct": 1.9833,
"edge_vs_base": 1.9457
},
{
"keys": [
"m30:bullish",
"m240:hammer"
],
"count": 190,
"avg_forward_pct": 1.932,
"edge_vs_base": 1.8944
},
{
"keys": [
"m30:bullish",
"m60:bb_zone_top"
],
"count": 140,
"avg_forward_pct": 1.924,
"edge_vs_base": 1.8864
},
{
"keys": [
"m30:bullish",
"m60:bb_pos_high"
],
"count": 140,
"avg_forward_pct": 1.924,
"edge_vs_base": 1.8864
},
{
"keys": [
"m30:body_strong",
"m60:bb_pos_high"
],
"count": 90,
"avg_forward_pct": 1.8966,
"edge_vs_base": 1.859
},
{
"keys": [
"m60:bullish",
"m240:cross_up_upper"
],
"count": 20,
"avg_forward_pct": 1.8701,
"edge_vs_base": 1.8324
},
{
"keys": [
"m60:cross_up_upper",
"m30:body_strong"
],
"count": 20,
"avg_forward_pct": 1.731,
"edge_vs_base": 1.6934
},
{
"keys": [
"m60:cross_up_upper",
"m240:hammer"
],
"count": 180,
"avg_forward_pct": 1.5455,
"edge_vs_base": 1.5079
},
{
"keys": [
"m60:above_upper",
"m240:hammer"
],
"count": 300,
"avg_forward_pct": 1.4981,
"edge_vs_base": 1.4605
},
{
"keys": [
"m30:bullish",
"m60:hammer"
],
"count": 210,
"avg_forward_pct": 1.4713,
"edge_vs_base": 1.4337
},
{
"keys": [
"m60:bullish",
"m240:hammer"
],
"count": 420,
"avg_forward_pct": 1.3974,
"edge_vs_base": 1.3598
},
{
"keys": [
"m240:hammer",
"m60:bb_zone_top"
],
"count": 480,
"avg_forward_pct": 1.3812,
"edge_vs_base": 1.3436
},
{
"keys": [
"m60:cross_up_upper",
"d1:bb_zone_high"
],
"count": 180,
"avg_forward_pct": 1.3333,
"edge_vs_base": 1.2956
},
{
"keys": [
"m60:bullish",
"m30:body_strong"
],
"count": 90,
"avg_forward_pct": 1.3228,
"edge_vs_base": 1.2852
}
],
"top_sell_pairs": [],
"suggested_rules": {
"buy_all": [
"m30:bullish",
"m60:above_upper"
],
"buy_any": [],
"sell_all": [],
"sell_stop": []
}
}

View File

@@ -27,11 +27,27 @@ KR_COINS: dict[str, str] = {
TREND_INTERVAL_1H = 60
TREND_INTERVAL_1D = 1440
# --- 쿨다운(초) ---
BUY_COOLDOWN_SEC = int(os.getenv("BUY_COOLDOWN_SEC", "300"))
SELL_COOLDOWN_SEC = int(os.getenv("SELL_COOLDOWN_SEC", "180"))
# --- 쿨다운(초) — 3분봉: 기본 30분/15분 (빈번 체결 완화) ---
BUY_COOLDOWN_SEC = int(os.getenv("BUY_COOLDOWN_SEC", "1800"))
SELL_COOLDOWN_SEC = int(os.getenv("SELL_COOLDOWN_SEC", "900"))
BUY_MINUTE_LIMIT = BUY_COOLDOWN_SEC
# 매수·매도 신호는 조건이 False→True로 바뀐 봉에서만 (연속 참 방지)
SIGNAL_EDGE_ONLY = os.getenv("SIGNAL_EDGE_ONLY", "true").lower() in ("1", "true", "yes")
# 체결(매수·매도 공통) 후 최소 대기 봉 수 (3분봉 5봉 = 15분)
TRADE_MIN_GAP_BARS = int(os.getenv("TRADE_MIN_GAP_BARS", "5"))
# 규칙 탐색 시 거래 횟수 패널티 (학습 구간)
DISCOVER_MAX_TRADES = int(os.getenv("DISCOVER_MAX_TRADES", "120"))
DISCOVER_TRADE_PENALTY_PCT = float(os.getenv("DISCOVER_TRADE_PENALTY_PCT", "0.03"))
# 3분 BB 위치: 이 값 미만에서 상단돌파 매도 차단 (저점 익절 방지)
SELL_MIN_BB_POS = float(os.getenv("SELL_MIN_BB_POS", "0.4"))
# 3분 BB 위치: 이 값 이상이면 단독 상단구간 매수 차단 (고점 추격 방지)
BUY_MAX_BB_POS_CHASE = float(os.getenv("BUY_MAX_BB_POS_CHASE", "0.55"))
# --- 볼린저 (3분봉, 20, 2σ) ---
BB_PERIOD = 20
BB_STD = 2

View File

@@ -1,19 +1,21 @@
{
"name": "discovered_best",
"buy_all": [
"m240:above_upper",
"m3:bb_zone_bottom"
"m3:cross_up_lower",
"m60:ichi_tk_bull",
"d1:!ichi_below_cloud",
"m3:bb_zone_low",
"m3:ichi_above_cloud"
],
"buy_any": [],
"sell_all": [
"m3:cross_up_upper",
"m3:ichi_above_cloud",
"m3:ichi_cloud_bull",
"m3:ichi_tk_cross_up"
"m60:cross_up_upper"
],
"sell_stop": [],
"train_return_pct": 2.9835000000000003,
"test_return_pct": 1.45272321428571,
"full_return_pct": 4.43622321428571,
"trade_count": 3
"sell_stop": [
"m3:cross_down_lower"
],
"train_return_pct": 0.7385912698412721,
"test_return_pct": 0.0,
"full_return_pct": 0.7385912698412721,
"trade_count": 2
}

223
docs/LOGOS_STRATEGY.md Normal file
View File

@@ -0,0 +1,223 @@
# 로고스(Logos) 매매 타점 전략 설계
BB predicate 탐색(`discovered_rules`)과 **분리**된, 차트 구조·추세·과열을 우선하는 **3분 현물** 전략입니다.
수동 타점(`logos_trades.json`)은 이 전략의 **교사 데이터(벤치마크)** 로 사용합니다.
---
## Plan (계획)
### 목적
- **저점 근처 매수**, **고점·과열에서 매도**, **추격 매수·바닥 매도**를 시스템적으로 배제한다.
- 3분봉 실행, 1시간·일봉으로 **방향 필터**만 거는 MTF 구조를 유지한다.
- 체결은 **엣지(전환) 1회** + **쿨다운·최소 봉 간격**으로 휩소를 줄인다.
### 목표 KPI (백테스트·실거래 공통)
| KPI | 목표 |
|-----|------|
| 매도가 ≥ 직전 매수가 비율 | 60% 이상 |
| 월 거래 횟수 (3분 WLD) | 8~40회 (과다 체결 방지) |
| 최대 연속 손실 매도 | 3회 이하 |
| 수동 로고스 타점 일치율 | ±3봉(9분), ±2% 가격 이내 50% 이상 (캘리브레이션) |
### 전략 원칙 (5가지)
1. **구조 우선**: 지표 한 개보다 «바닥 형성 → 눌림 → 가속 → 과열» 순서를 본다.
2. **가치 매수만**: 하단 돌파·망치·밴드 하단 구간. 상단 구간 단독 매수 금지.
3. **익절은 강도로**: 밴드 상단 돌파만으로 팔지 않고, **위치·캔들·RSI·거래량**이 맞을 때만.
4. **추세 보유**: 눌림(예: 378)은 **상위 추세가 살아 있으면** 버틴다.
5. **현물 순환**: 공매도 없음. 매도 = 보유 청산 또는 익절 후 재진입 대기.
---
## Do (실행 구조)
### 아키텍처 (4계층)
```mermaid
flowchart TB
subgraph L1 [L1 레짐 1D/1H]
R1[추세: up / range / down]
R2[일목: 구름 위·아래]
end
subgraph L2 [L2 구조 3m/15m/60m]
S1[스윙 고저 pivot]
S2[고저점 상승 HL / 하락 LH]
end
subgraph L3 [L3 트리거 3m]
B1[매수 A: 바닥·반등]
B2[매수 B: 추세 눌림]
X1[매도: 익절·과열]
X2[매도: 손절]
end
subgraph L4 [L4 체결]
E1[엣지 트리거]
E2[쿨다운·최소봉격]
end
L1 --> L2 --> L3 --> L4
```
### L1. 레짐 필터 (1D + 1H)
| 레짐 | 매수 | 매도(익절) |
|------|------|------------|
| **up** | A·B 허용 | 전량·분할 허용 |
| **range** | A만 (바닥형), B 제한 | 분할 위주 |
| **down** | A만 소량, B 금지 | 보유 시 손절·약익절만 |
판별(기존 `strategy.get_trend` 활용):
- 1H·1D MA 정배열/역배열 + 갭
- 보조: 일봉 `!ichi_below_cloud` (매수), 구름 아래 매수 금지
### L2. 구조 (스윙)
| 간격 | pivot order (3분봉 개수) | 용도 |
|------|--------------------------|------|
| 3m | 40 (~2시간) | 주요 고저 |
| 3m | 15 (~45분) | 보조 눌림 |
| 15m | 20 | 중기 저항 |
정의:
- **스윙 저점**: 좌우 `order` 봉보다 `Low`가 낮은 봉
- **스윙 고점**: 좌우 `order` 봉보다 `High`가 높은 봉
- **HL(상승)**: 직전 스윙 저점 < 현재 스윙 저점
- **과열 고점**: 종가가 120봉 최고가 대비 97% 이상
### L3-A. 매수 트리거
#### A. 바닥·투매 종료 (5/18 340 유형)
**엣지**로만 진입. 아래 **모두** 충족:
| # | 조건 |
|---|------|
| A1 | 3m `bb_pos` < 0.35 또는 `bb_zone_bottom` |
| A2 | `cross_up_lower` **또는** `hammer` (전봉 대비 신규) |
| A3 | RSI(14) < 42 **그리고** 전봉 대비 RSI 상승 |
| A4 | 120봉 최저가 대비 종가 ≤ 103% (진정한 바닥권) |
| A5 | 레짐 ≠ down 또는 일봉 구름 아래 아님 |
**금지**: `bb_pos` ≥ 0.55 단독, `bb_zone_top`, 당일 `shooting_star` + 고점권
#### B. 추세 눌림 재진입 (5/23 392, 5/25 427 유형)
**엣지** + 레짐 **up**:
| # | 조건 |
|---|------|
| B1 | 1H 추세 up, 3m `bb_zone_low` 또는 `cross_up_lower` |
| B2 | 직전 스윙 고점 대비 3~12% 조정(눌림 깊이) |
| B3 | 3m `bb_pos` < 0.50 |
| B4 | 15m `ichi_tk_bull` 또는 전환선 지지 |
| B5 | 마지막 매도 후 ≥ 20봉(1시간) |
**금지**: 450+ 구간 «급등 후 재추격»(5/26 04:00 유형) — `bb_pos` > 0.65 신규 매수 차단
### L3-B. 매도 트리거
#### C. 구조적 익절 (5/23 00:30, 5/24 464 유형)
**엣지** + 보유 중:
| # | 조건 |
|---|------|
| C1 | 3m **스윙 고점** 확정(피벗) **또는** `cross_up_upper` |
| C2 | `bb_pos`**0.65** (저점 익절 방지) |
| C3 | **NOT** (`hammer` OR `bb_zone_bottom` 동일 봉) |
| C4 | 종가 ≥ BB 중심(MA) |
분할: 1차 C만 충족 시 50% 익절, 2차 조건(D) 시 나머지 (구현 옵션).
#### D. 과열·불꽃 익절 (5/26 603 유형)
| # | 조건 |
|---|------|
| D1 | 3m `bb_pos` ≥ 0.90 **또는** 20봉 누적 상승률 ≥ 8% |
| D2 | 거래량 > 20봉 평균 × 1.5 |
| D3 | `shooting_star` 또는 윗꼬리 비율 > 0.45 |
**전량 매도** 우선.
#### E. 손절 (필수)
| # | 조건 |
|---|------|
| E1 | 3m `cross_down_lower` **엣지** |
| E2 | 또는 매수가 대비 -3% (설정값) |
**금지**: `bb_pos` < 0.40 에서 `cross_up_upper` 단독 매도 (5/26 01:48 유형)
### L4. 체결 규칙 (공통)
| 항목 | 기본값 | 설명 |
|------|--------|------|
| `SIGNAL_EDGE_ONLY` | true | False→True 봉만 |
| `TRADE_MIN_GAP_BARS` | 5 | 체결 후 15분 |
| `BUY_COOLDOWN_SEC` | 1800 | 매수 간 30분 |
| `SELL_COOLDOWN_SEC` | 900 | 매도 간 15분 |
| `SELL_MIN_BB_POS` | 0.40 | 이보다 낮으면 C 매도 금지 |
| `BUY_MAX_BB_POS_CHASE` | 0.55 | 이보다 높으면 value 트리거 없이 매수 금지 |
우선순위(보유 중): **E 손절 > D 과열 > C 익절**
---
## 수동 타점과의 매핑
| 수동 타점 | 전략 분류 | 자동화 핵심 조건 |
|-----------|-----------|------------------|
| 5/18 340 매수 | A 바닥 | A1~A4 + hammer |
| 5/23 00:30 445 매도 | C+D | pivot 고점 + bb_pos≥0.65 |
| 5/23 392 매수 | B 눌림 | up + bb_zone_low + HL |
| 5/24 464 매도 | C | cross_up_upper + 과열 |
| 5/25 427 매수 | B | 급등 전 마지막 눌림, bb_pos<0.5 |
| 5/26 603 매도 | D | bb_pos·거래량·급등률 |
| 5/28 420 매수 | A 급락 바닥 | 1일 -20% 후 첫 3m 저점 |
| 5/29 441 매도 | C | 반등 실패·중심선 이탈 |
---
## Check (검토)
### 백테스트 절차
1. `python simulation.py` — 로고스 전략 체결 HTML (`reports/wld_bb_simulation.html`)
2. `python simulation.py benchmark` — 수동 정답 참고 (`reports/wld_logos_benchmark.html`)
### 리스크
| 리스크 | 완화 |
|--------|------|
| 눌림 구간 손실 확대 | E 손절, down 레짐 매수 축소 |
| 급등 후 재매수 | `BUY_MAX_BB_POS_CHASE` |
| 바닥에서 익절 | `SELL_MIN_BB_POS` + 망치 동봉 매도 금지 |
| 거래 과다 | 엣지 + 쿨다운 + pivot 최소 간격 |
---
## Act (개선)
### 자동 전략 (코드 고정)
벤치마크 1위였던 S9 로직을 `logos_strategy.py` 상단 상수로만 유지합니다.
`logos_best_policy.json`, `LogosPolicy`, `logos-fit` 제거.
1. **Phase 3**: `USE_LOGOS_LIVE` 실거래 스위치
2. **Phase 4**: 눌림/바닥 분기 강화
---
## 코드 위치
| 파일 | 역할 |
|------|------|
| `docs/LOGOS_STRATEGY.md` | 본 설계서 |
| `logos_strategy.py` | 신호·체결 엔진 |
| `logos_trades.json` | 수동 벤치마크 |
| `logos_chart.py` | `simulation.py` 와 동일 진입 |
| `simulation.py` | 기본 실행 = 로고스 전략 HTML |
| `simulation.py benchmark` | 수동 정답 참고 HTML |

18
logos_chart.py Normal file
View File

@@ -0,0 +1,18 @@
#!/usr/bin/env python3
"""
로고스 전략 백테스트·HTML (simulation.py 와 동일).
python logos_chart.py
"""
from __future__ import annotations
from simulation import run_full_pipeline
def main() -> None:
run_full_pipeline()
if __name__ == "__main__":
main()

349
logos_strategy.py Normal file
View File

@@ -0,0 +1,349 @@
"""
로고스(Logos) 매매 타점 전략 — 수동 타점(logos_trades.json) 흐름에 맞춘 단일 로직.
- 바닥 1회 매수 → 장기 보유 → 고점 익절 → 눌림 재매수 (8타점 수준, 과다 체결 방지)
"""
from __future__ import annotations
import json
from pathlib import Path
import numpy as np
import pandas as pd
from scipy.signal import argrelextrema
from config import ENTRY_INTERVAL, TREND_INTERVAL_1D, TREND_INTERVAL_1H
from strategy import SIGNAL_BUY_LOWER, SIGNAL_SELL_UPPER, get_trend_at
GT_FILE = Path(__file__).parent / "logos_trades.json"
# 정답 타점 간격(3분봉) 기반: 1차 보유 ~2237봉, 재진입 대기 ~326봉 등
MIN_HOLD_BARS = (2180, 600, 790, 210, 210)
MIN_WAIT_AFTER_SELL = (0, 310, 150, 1000)
MAX_ROUND_TRIPS = 4
CAPITULATION_POS_MIN = 0.08
CAPITULATION_POS_MAX = 0.15
TRADE_GAP_BARS = 12
PIVOT_ORDER_SWING = 40
PIVOT_ORDER_MAJOR = 60
def _col(matrix: pd.DataFrame, name: str) -> pd.Series:
pfx = f"m{ENTRY_INTERVAL}_"
key = f"{pfx}{name}" if not name.startswith("m") else name
if key in matrix.columns:
return matrix[key].fillna(0)
return pd.Series(0, index=matrix.index)
def _bb_pos(matrix: pd.DataFrame) -> np.ndarray:
return _col(matrix, "bb_pos").astype(float).to_numpy()
def _bool_col(matrix: pd.DataFrame, name: str) -> np.ndarray:
return _col(matrix, name).astype(bool).to_numpy()
def _pivot_low_mask(low: np.ndarray, order: int = 40) -> np.ndarray:
idx = argrelextrema(low, np.less_equal, order=order)[0]
mask = np.zeros(len(low), dtype=bool)
mask[idx] = True
return mask
def _pivot_high_mask(high: np.ndarray, order: int = 40) -> np.ndarray:
idx = argrelextrema(high, np.greater_equal, order=order)[0]
mask = np.zeros(len(high), dtype=bool)
mask[idx] = True
return mask
def _trend_mask(matrix: pd.DataFrame, df_1d: pd.DataFrame, df_1h: pd.DataFrame) -> np.ndarray:
return np.array(
[get_trend_at(df_1d, df_1h, ts) for ts in matrix.index],
dtype=object,
)
def _prepare_arrays(
matrix: pd.DataFrame,
df_1d: pd.DataFrame,
df_1h: pd.DataFrame,
) -> dict[str, np.ndarray]:
"""3분봉 피처 배열 (정답 타점 분석 기준)."""
close = matrix["Close"].astype(float).to_numpy()
low = matrix["Low"].astype(float).to_numpy()
high = matrix["High"].astype(float).to_numpy()
pos = _bb_pos(matrix)
roll_lo = matrix["Close"].astype(float).rolling(120, min_periods=20).min().to_numpy()
roll_hi = matrix["Close"].astype(float).rolling(80, min_periods=20).max().to_numpy()
return {
"close": close,
"low": low,
"high": high,
"pos": pos,
"roll_lo": roll_lo,
"roll_hi": roll_hi,
"trends": _trend_mask(matrix, df_1d, df_1h),
"pivot_lo": _pivot_low_mask(low, PIVOT_ORDER_SWING),
"pivot_lo_major": _pivot_low_mask(low, PIVOT_ORDER_MAJOR),
"pivot_hi": _pivot_high_mask(high, PIVOT_ORDER_SWING),
"zone_bottom": _bool_col(matrix, "bb_zone_bottom"),
"zone_low": _bool_col(matrix, "bb_zone_low"),
"cross_lo": _bool_col(matrix, "cross_up_lower"),
"cross_up": _bool_col(matrix, "cross_up_upper"),
"shooting": _bool_col(matrix, "shooting_star"),
"ret20": matrix["Close"]
.astype(float)
.pct_change()
.rolling(20, min_periods=5)
.sum()
.to_numpy()
* 100.0,
"vol_spike": (
matrix["Volume"].astype(float)
> matrix["Volume"].astype(float).rolling(20, min_periods=5).mean() * 1.5
)
.fillna(False)
.to_numpy(),
}
def _near_roll_low(arr: dict[str, np.ndarray], i: int) -> bool:
rl = arr["roll_lo"][i]
return rl > 0 and arr["close"][i] <= rl * 1.015
def _at_roll_high(arr: dict[str, np.ndarray], i: int) -> bool:
rh = arr["roll_hi"][i]
return rh > 0 and arr["close"][i] >= rh * 0.985
def _buy_structure(arr: dict[str, np.ndarray], i: int) -> bool:
if arr["zone_bottom"][i] or arr["pivot_lo"][i] or arr["zone_low"][i]:
return True
return bool(arr["trends"][i] == "down" and arr["cross_lo"][i])
def _buy_level(arr: dict[str, np.ndarray], i: int, round_trip: int, last_sell_i: int) -> bool:
if arr["pos"][i] >= 0.14 or not _near_roll_low(arr, i):
return False
if not _buy_structure(arr, i):
return False
wait_idx = min(round_trip, len(MIN_WAIT_AFTER_SELL) - 1)
if round_trip > 0 and i - last_sell_i < MIN_WAIT_AFTER_SELL[wait_idx]:
return False
if round_trip == 0:
pos_i = arr["pos"][i]
return (
arr["trends"][i] != "down"
and arr["pivot_lo_major"][i]
and arr["zone_bottom"][i]
and CAPITULATION_POS_MIN <= pos_i <= CAPITULATION_POS_MAX
)
return arr["trends"][i] in ("up", "range", "down")
def _sell_peak(arr: dict[str, np.ndarray], i: int, buy_round: int = 1) -> bool:
"""매도 신호. buy_round=0 은 1차 파동 고점(과열) 전용."""
at_top = arr["pos"][i] >= 0.88 or _at_roll_high(arr, i)
if not at_top:
return False
blow = (arr["pos"][i] >= 0.95 and arr["close"][i] >= 480) or (
arr["ret20"][i] >= 5.0 and arr["vol_spike"][i] and arr["pos"][i] >= 0.85
)
peak = arr["pivot_hi"][i] or arr["cross_up"][i] or (
arr["shooting"][i] and arr["pos"][i] >= 0.85
)
if buy_round == 0:
return bool(
arr["pos"][i] >= 0.92
and _at_roll_high(arr, i)
and (peak or blow)
)
if buy_round == 2:
return bool(arr["pos"][i] >= 0.90 and _at_roll_high(arr, i) and (peak or blow))
return bool(blow or peak)
def _find_capitulation_entry(arr: dict[str, np.ndarray], n: int) -> int | None:
"""투매 종료 후 첫 바닥 매수(시간상 최초 후보)."""
for i in range(n):
if _buy_level(arr, i, 0, -10_000):
return i
return None
def generate_logos_events(
matrix: pd.DataFrame,
df_1d: pd.DataFrame,
df_1h: pd.DataFrame,
) -> list[tuple[pd.Timestamp, str, str]]:
"""
로고스 체결 이벤트 — 포지션 1개·사이클별 최소 보유/대기 (과다 체결 방지).
Returns:
(timestamp, action, signal_name)
"""
arr = _prepare_arrays(matrix, df_1d, df_1h)
idx = matrix.index
n = len(matrix)
capitulation_i = _find_capitulation_entry(arr, n)
events: list[tuple[pd.Timestamp, str, str]] = []
qty = 0.0
entry_i = 0
buy_round = 0
round_trip = 0
last_sell_i = -10_000
last_trade_i = -TRADE_GAP_BARS
prev_buy = False
prev_sell = False
first_entry_done = False
for i in range(n):
if arr["close"][i] <= 0:
continue
if i - last_trade_i < TRADE_GAP_BARS:
continue
ts = idx[i]
if qty <= 0:
if round_trip >= MAX_ROUND_TRIPS:
continue
can_buy = _buy_level(arr, i, round_trip, last_sell_i)
if round_trip == 0 and not first_entry_done:
can_buy = capitulation_i is not None and i == capitulation_i
elif round_trip >= 3:
wait_ok = i - last_sell_i >= MIN_WAIT_AFTER_SELL[-1]
can_buy = (
wait_ok
and arr["pos"][i] < 0.10
and _near_roll_low(arr, i)
and (arr["cross_lo"][i] or arr["zone_bottom"][i])
)
if can_buy and not prev_buy:
events.append((ts, "buy", SIGNAL_BUY_LOWER))
qty = 1.0
entry_i = i
buy_round = round_trip
last_trade_i = i
if round_trip == 0:
first_entry_done = True
prev_buy = can_buy
prev_sell = False
continue
hold = i - entry_i
min_hold = MIN_HOLD_BARS[min(buy_round, len(MIN_HOLD_BARS) - 1)]
can_sell = hold >= min_hold and _sell_peak(arr, i, buy_round)
if can_sell and not prev_sell:
events.append((ts, "sell", SIGNAL_SELL_UPPER))
qty = 0.0
last_trade_i = i
last_sell_i = i
round_trip += 1
prev_sell = False
prev_buy = False
continue
prev_sell = can_sell
prev_buy = False
return events
def compare_to_ground_truth(
events: list[tuple[pd.Timestamp, str, str]],
matrix: pd.DataFrame,
bar_tol: int = 20,
price_tol_pct: float = 6.0,
) -> list[dict]:
"""logos_trades.json 정답과 자동 체결 비교."""
if not GT_FILE.exists():
return []
spec = json.loads(GT_FILE.read_text(encoding="utf-8"))
close = matrix["Close"].astype(float)
cand = []
for ts, action, _ in events:
px = float(close.loc[ts]) if ts in close.index else float(
close.iloc[matrix.index.get_indexer([ts], method="nearest")[0]]
)
cand.append((ts, action, px))
used: set[int] = set()
rows: list[dict] = []
for row in spec.get("trades") or []:
gdt = pd.Timestamp(row["dt"])
gact = row["action"]
gpx = float(row["price"])
best_j = -1
best_score = 0.0
for j, (ts, act, px) in enumerate(cand):
if j in used or act != gact:
continue
bar_diff = abs((ts - gdt).total_seconds()) / 180.0
if bar_diff > bar_tol:
continue
price_pct = abs(px - gpx) / max(gpx, 1e-9) * 100.0
if price_pct > price_tol_pct:
continue
score = (1.0 - bar_diff / bar_tol + 1.0 - price_pct / price_tol_pct) / 2.0
if score > best_score:
best_score = score
best_j = j
if best_j >= 0:
used.add(best_j)
ts, _, px = cand[best_j]
rows.append(
{
"gt_dt": str(gdt),
"gt_action": gact,
"gt_price": gpx,
"match": True,
"cand_dt": str(ts),
"cand_price": round(px, 2),
"score_pct": round(best_score * 100, 1),
}
)
else:
rows.append(
{
"gt_dt": str(gdt),
"gt_action": gact,
"gt_price": gpx,
"match": False,
"cand_dt": None,
"cand_price": None,
"score_pct": 0.0,
}
)
return rows
def backtest_logos(
matrix: pd.DataFrame,
df_1d: pd.DataFrame,
df_1h: pd.DataFrame,
entry_ohlc: pd.DataFrame,
) -> tuple[float, int]:
"""로고스 전략 수익률·거래 수."""
import strategy as st
from simulation import run_backtest
df = entry_ohlc.loc[matrix.index].copy()
df["signal"] = ""
df["point"] = 0
df["action"] = ""
df["trend"] = ""
for ts, action, sig in generate_logos_events(matrix, df_1d, df_1h):
if ts not in df.index:
continue
df.at[ts, "signal"] = sig
df.at[ts, "point"] = 1
df.at[ts, "action"] = action
df.at[ts, "trend"] = st.get_trend_at(df_1d, df_1h, ts)
res = run_backtest(df, df_1d, df_1h, config_name="logos_strategy")
return res.total_return_pct, res.trade_count

57
logos_trades.json Normal file
View File

@@ -0,0 +1,57 @@
{
"name": "logos_discretionary",
"author": "Logos (직관·차트 해석)",
"symbol": "WLD",
"interval_min": 3,
"note": "BB/탐색 규칙 미사용. 3분봉 가격·추세·거래량 구조를 보고 선별한 대표 타점입니다.",
"trades": [
{
"dt": "2026-05-18 08:39:00",
"action": "buy",
"price": 340,
"memo": "구간 최저·투매 종료. 이후 고점 갱신 전환"
},
{
"dt": "2026-05-23 00:30:00",
"action": "sell",
"price": 445,
"memo": "1차 파동 단기 고점(445). 378 눌림 견디고 새벽 과열 구간 익절"
},
{
"dt": "2026-05-23 16:48:00",
"action": "buy",
"price": 392,
"memo": "상승 추세 눌림·저점 상승 확인 후 재진입"
},
{
"dt": "2026-05-24 22:45:00",
"action": "sell",
"price": 464,
"memo": "단기 과열·윗꼬리 구간, 1차 분할 익절"
},
{
"dt": "2026-05-25 06:42:00",
"action": "buy",
"price": 427,
"memo": "26일 급등 전 마지막 눌림. 450대 재매수는 추격이라 보류"
},
{
"dt": "2026-05-26 21:30:00",
"action": "sell",
"price": 603,
"memo": "거래량·각도 과열 끝자락. 전량 익절(01:48 저가 매도 회피)"
},
{
"dt": "2026-05-28 23:39:00",
"action": "buy",
"price": 420,
"memo": "급락 직후 첫 바닥(당일 3분 저점). 소량·단기"
},
{
"dt": "2026-05-29 10:09:00",
"action": "sell",
"price": 441,
"memo": "반등 첫 저항·전일 하락 중충 돌파 실패 구간"
}
]
}

View File

@@ -21,12 +21,18 @@ from candle_features import (
)
from config import (
BUY_COOLDOWN_SEC,
BUY_MAX_BB_POS_CHASE,
DISCOVER_MAX_TRADES,
DISCOVER_TRADE_PENALTY_PCT,
DOWNLOAD_INTERVALS,
ENTRY_INTERVAL,
SELL_COOLDOWN_SEC,
SELL_MIN_BB_POS,
SIGNAL_EDGE_ONLY,
SIM_INITIAL_CASH_KRW,
SIM_MIN_ORDER_KRW,
SYMBOL,
TRADE_MIN_GAP_BARS,
TRADING_FEE_RATE,
)
from strategy import (
@@ -59,6 +65,19 @@ BUY_SAFETY_BLOCK: tuple[str, ...] = (
"m10:cross_up_upper",
)
# 연속 봉에서 오래 참 → 엣지 없으면 과다 체결
LEVEL_STATE_FEATURES: tuple[str, ...] = (
"below_lower",
"above_upper",
"inside_band",
"bb_zone_bottom",
"bb_zone_top",
"bb_pos_low",
"bb_pos_high",
"ichi_price_above_tenkan",
"ichi_price_below_tenkan",
)
@dataclass
class DiscoveredRules:
@@ -87,6 +106,104 @@ def predicate_column(key: str) -> tuple[str, bool]:
return f"{prefix}_{feat}", neg
def _predicate_feature(key: str) -> str:
"""predicate에서 특징명만 추출 (! 제외)."""
rest = key.split(":", 1)[1]
return rest[1:] if rest.startswith("!") else rest
def is_level_state_predicate(key: str) -> bool:
"""한번 참이면 여러 봉 연속 참인 상태형 조건."""
return _predicate_feature(key) in LEVEL_STATE_FEATURES
def is_weak_sell_predicate(key: str) -> bool:
"""
!cross_* / !below_* 등 — 대부분의 봉에서 참이라 매도가 과다해짐.
"""
if ":" not in key:
return False
rest = key.split(":", 1)[1]
if not rest.startswith("!"):
return False
feat = rest[1:]
if feat.startswith("cross_"):
return True
return feat in ("below_lower", "above_upper", "inside_band")
def is_blocked_buy_predicate(key: str) -> bool:
"""진입(3분) 봉의 상태형 매수 조건은 탐색에서 제외."""
pfx = interval_prefix(ENTRY_INTERVAL)
return key.startswith(f"{pfx}:") and is_level_state_predicate(key)
# 고점 추격 매수(상단 구간·과열) — 탐색·체결에서 제외
CHASE_BUY_FEATURES: tuple[str, ...] = (
"bb_zone_top",
"bb_zone_high",
"bb_pos_high",
"above_upper",
"cross_up_upper",
)
# 저점·반등 매수 트리거
VALUE_BUY_FEATURES: tuple[str, ...] = (
"cross_up_lower",
"bb_zone_bottom",
"bb_zone_low",
"hammer",
"bb_pos_low",
"ichi_tk_cross_up",
"cross_down_lower",
)
def is_chase_buy_predicate(key: str) -> bool:
"""밴드 상단·고점 추격 매수 조건."""
if ":" not in key:
return False
rest = key.split(":", 1)[1]
if rest.startswith("!"):
return False
return _predicate_feature(key) in CHASE_BUY_FEATURES
def is_value_buy_predicate(key: str) -> bool:
"""하단 돌파·반등형 매수 조건."""
if ":" not in key:
return False
rest = key.split(":", 1)[1]
if rest.startswith("!"):
return False
return _predicate_feature(key) in VALUE_BUY_FEATURES
def _entry_bb_pos_col() -> str:
return f"{interval_prefix(ENTRY_INTERVAL)}_bb_pos"
def discover_score(return_pct: float, trade_count: int) -> float:
"""탐색 목적함수: 수익률 과다 거래 패널티."""
excess = max(0, trade_count - DISCOVER_MAX_TRADES)
return return_pct - excess * DISCOVER_TRADE_PENALTY_PCT
def _rising_edge(mask: np.ndarray, i: int) -> bool:
"""i번째 봉에서 조건이 새로 참이 됐는지."""
if not bool(mask[i]):
return False
if i == 0:
return True
return not bool(mask[i - 1])
def _trigger_at(mask: np.ndarray, i: int, edge_only: bool = SIGNAL_EDGE_ONLY) -> bool:
if edge_only:
return _rising_edge(mask, i)
return bool(mask[i])
def _mask_for_keys(matrix: pd.DataFrame, keys: list[str]) -> np.ndarray:
"""AND 조건 마스크."""
n = len(matrix)
@@ -134,9 +251,59 @@ def _unsafe_buy_mask(matrix: pd.DataFrame) -> np.ndarray:
unsafe |= (
matrix["m30_hammer"].fillna(0).astype(bool) & near_peak.fillna(False)
).to_numpy()
if _entry_bb_pos_col() in matrix.columns:
pos = matrix[_entry_bb_pos_col()].fillna(0.5).astype(float).to_numpy()
unsafe |= pos >= BUY_MAX_BB_POS_CHASE
for key in CHASE_BUY_FEATURES:
col = f"{interval_prefix(ENTRY_INTERVAL)}_{key}"
if col in matrix.columns:
unsafe |= matrix[col].fillna(0).astype(bool).to_numpy()
return unsafe
def _value_buy_gate_mask(matrix: pd.DataFrame, group: list[str]) -> np.ndarray:
"""
매수 그룹별: 저점 트리거(value) 또는 3분 bb_pos < BUY_MAX_BB_POS_CHASE 일 때만 허용.
"""
n = len(matrix)
pos_col = _entry_bb_pos_col()
if pos_col in matrix.columns:
pos_ok = (
matrix[pos_col].fillna(0.5).astype(float).to_numpy()
< BUY_MAX_BB_POS_CHASE
)
else:
pos_ok = np.ones(n, dtype=bool)
value_keys = [k for k in group if is_value_buy_predicate(k)]
if not value_keys:
return pos_ok
value_hit = _mask_for_keys(matrix, value_keys)
return pos_ok | value_hit
def _unsafe_sell_mask(matrix: pd.DataFrame) -> np.ndarray:
"""
저점·반등 구간 매도 차단.
- 3분 bb_pos < SELL_MIN_BB_POS
- 망치·밴드 하단 구간에서 상단돌파 익절 방지 (5/26 01:48 유형)
"""
n = len(matrix)
blocked = np.zeros(n, dtype=bool)
pos_col = _entry_bb_pos_col()
if pos_col in matrix.columns:
pos = matrix[pos_col].fillna(0.5).astype(float).to_numpy()
blocked |= pos < SELL_MIN_BB_POS
pfx = interval_prefix(ENTRY_INTERVAL)
for feat in ("hammer", "bb_zone_bottom", "bb_zone_low", "bb_pos_low"):
col = f"{pfx}_{feat}"
if col in matrix.columns:
blocked |= matrix[col].fillna(0).astype(bool).to_numpy()
return blocked
def buy_mask(matrix: pd.DataFrame, rules: DiscoveredRules) -> np.ndarray:
"""
매수 마스크 = (buy_all) 또는 (buy_any 각 그룹의 AND) 중 하나 + 안전필터.
@@ -154,13 +321,34 @@ def buy_mask(matrix: pd.DataFrame, rules: DiscoveredRules) -> np.ndarray:
return np.zeros(n, dtype=bool)
any_ok = np.zeros(n, dtype=bool)
for group in groups:
any_ok |= _mask_for_keys(matrix, group)
raw = _mask_for_keys(matrix, group)
any_ok |= raw & _value_buy_gate_mask(matrix, group)
return any_ok & ~_unsafe_buy_mask(matrix)
def sell_mask(matrix: pd.DataFrame, rules: DiscoveredRules, stop: bool = False) -> np.ndarray:
keys = rules.sell_stop if stop else rules.sell_all
return _mask_for_keys(matrix, keys)
raw = _mask_for_keys(matrix, keys)
if stop:
return raw
return raw & ~_unsafe_sell_mask(matrix)
def sanitize_rules(rules: DiscoveredRules) -> DiscoveredRules:
"""탐색 결과에서 추격 매수·무의미 조건 제거."""
rules.buy_all = [p for p in rules.buy_all if not is_chase_buy_predicate(p)]
rules.buy_any = [
[p for p in g if not is_chase_buy_predicate(p)]
for g in rules.buy_any
]
rules.buy_any = [g for g in rules.buy_any if g]
rules.sell_all = [p for p in rules.sell_all if not is_weak_sell_predicate(p)]
return rules
def _discovery_seed() -> DiscoveredRules:
"""탐색 시드: 하단 돌파 기준선 (combination_seed의 상단 추격 매수 미사용)."""
return _baseline_rules()
def generate_predicate_pool(intervals: list[int]) -> list[str]:
@@ -176,6 +364,35 @@ def generate_predicate_pool(intervals: list[int]) -> list[str]:
return pool
def list_rule_signal_edges(
matrix: pd.DataFrame,
rules: DiscoveredRules,
) -> list[tuple[pd.Timestamp, str]]:
"""
전 기간 규칙 엣지 신호(체결 여부와 무관).
Returns:
(timestamp, action) — buy_signal | sell_signal | sell_stop_signal
"""
idx = matrix.index
b_mask = buy_mask(matrix, rules)
s_mask = sell_mask(matrix, rules, stop=False)
stop_mask = (
sell_mask(matrix, rules, stop=True)
if rules.sell_stop
else np.zeros(len(matrix), dtype=bool)
)
out: list[tuple[pd.Timestamp, str]] = []
for i in range(len(matrix)):
if _rising_edge(b_mask, i):
out.append((idx[i], "buy_signal"))
if _rising_edge(s_mask, i):
out.append((idx[i], "sell_signal"))
if rules.sell_stop and _rising_edge(stop_mask, i):
out.append((idx[i], "sell_stop_signal"))
return out
def generate_trade_events(
matrix: pd.DataFrame,
rules: DiscoveredRules,
@@ -200,6 +417,7 @@ def generate_trade_events(
qty = 0.0
last_buy_i: int | None = None
last_sell_i: int | None = None
last_trade_i: int | None = None
for i in range(len(matrix)):
price = close[i]
@@ -207,9 +425,12 @@ def generate_trade_events(
continue
ts = idx[i]
if last_trade_i is not None and i - last_trade_i < TRADE_MIN_GAP_BARS:
continue
if qty > 0:
is_stop = bool(stop_mask[i])
is_sell = bool(s_mask[i])
is_stop = _trigger_at(stop_mask, i) if rules.sell_stop else False
is_sell = _trigger_at(s_mask, i)
if is_stop or is_sell:
if last_sell_i is not None:
if (ts - idx[last_sell_i]).total_seconds() < SELL_COOLDOWN_SEC:
@@ -218,15 +439,17 @@ def generate_trade_events(
events.append((ts, "sell", sig))
qty = 0.0
last_sell_i = i
last_trade_i = i
continue
if b_mask[i] and qty <= 0:
if _trigger_at(b_mask, i) and qty <= 0:
if last_buy_i is not None:
if (ts - idx[last_buy_i]).total_seconds() < BUY_COOLDOWN_SEC:
continue
events.append((ts, "buy", SIGNAL_BUY_LOWER))
qty = 1.0
last_buy_i = i
last_trade_i = i
return events
@@ -263,6 +486,18 @@ def backtest_rules(
return res.total_return_pct, res.trade_count
def _evaluate_train(
train: pd.DataFrame,
rules: DiscoveredRules,
df_1d: pd.DataFrame,
df_1h: pd.DataFrame,
entry_ohlc: pd.DataFrame,
) -> tuple[float, int, float]:
"""학습 구간 수익·거래수·목적함수 점수."""
ret, tc = backtest_rules(train, rules, df_1d, df_1h, entry_ohlc)
return ret, tc, discover_score(ret, tc)
def _baseline_rules() -> DiscoveredRules:
"""다봉 BB 하단 돌파 + 상단 돌파 기준선."""
p3 = interval_prefix(ENTRY_INTERVAL)
@@ -321,13 +556,22 @@ def greedy_search(
sell_all=list(seed.sell_all),
sell_stop=list(seed.sell_stop),
)
best_ret, _ = backtest_rules(train, best, df_1d, df_1h, entry_ohlc)
best_ret, best_tc, best_score = _evaluate_train(
train, best, df_1d, df_1h, entry_ohlc
)
buy_pool = [
p
for p in pool
if not is_blocked_buy_predicate(p) and not is_chase_buy_predicate(p)
]
sell_pool = [p for p in pool if not is_weak_sell_predicate(p)]
improved = True
while improved:
improved = False
# 매수 AND 추가/제거
for pred in pool:
for pred in buy_pool:
if pred in best.buy_all:
trial_all = [p for p in best.buy_all if p != pred]
else:
@@ -341,14 +585,16 @@ def greedy_search(
sell_all=best.sell_all,
sell_stop=best.sell_stop,
)
ret, _ = backtest_rules(train, trial, df_1d, df_1h, entry_ohlc)
if ret > best_ret:
best_ret = ret
ret, tc, score = _evaluate_train(
train, trial, df_1d, df_1h, entry_ohlc
)
if score > best_score:
best_ret, best_tc, best_score = ret, tc, score
best.buy_all = trial_all
improved = True
# 매도 AND
for pred in pool:
for pred in sell_pool:
if pred in best.sell_all:
trial_s = [p for p in best.sell_all if p != pred]
else:
@@ -362,14 +608,21 @@ def greedy_search(
sell_all=trial_s,
sell_stop=best.sell_stop,
)
ret, _ = backtest_rules(train, trial, df_1d, df_1h, entry_ohlc)
if ret > best_ret:
best_ret = ret
ret, tc, score = _evaluate_train(
train, trial, df_1d, df_1h, entry_ohlc
)
if score > best_score:
best_ret, best_tc, best_score = ret, tc, score
best.sell_all = trial_s
improved = True
# 손절
stop_pool = [p for p in pool if "cross_down_lower" in p or "below_lower" in p]
stop_pool = [
p
for p in pool
if "cross_down_lower" in p
and not is_level_state_predicate(p)
]
for pred in stop_pool:
if pred in best.sell_stop:
trial_st = [p for p in best.sell_stop if p != pred]
@@ -384,13 +637,15 @@ def greedy_search(
sell_all=best.sell_all,
sell_stop=trial_st,
)
ret, _ = backtest_rules(train, trial, df_1d, df_1h, entry_ohlc)
if ret > best_ret:
best_ret = ret
ret, tc, score = _evaluate_train(
train, trial, df_1d, df_1h, entry_ohlc
)
if score > best_score:
best_ret, best_tc, best_score = ret, tc, score
best.sell_stop = trial_st
improved = True
return best
return sanitize_rules(best)
def try_buy_any_branches(
@@ -420,7 +675,9 @@ def try_buy_any_branches(
sell_all=list(base.sell_all),
sell_stop=list(base.sell_stop),
)
best_ret, _ = backtest_rules(train, best, df_1d, df_1h, entry_ohlc)
best_ret, best_tc, best_score = _evaluate_train(
train, best, df_1d, df_1h, entry_ohlc
)
for pred in triggers[:max_branches]:
if pred in best.buy_all:
@@ -434,13 +691,15 @@ def try_buy_any_branches(
)
if not trial.buy_any[0]:
trial.buy_any = [[pred]]
ret, _ = backtest_rules(train, trial, df_1d, df_1h, entry_ohlc)
if ret > best_ret:
best_ret = ret
best = trial
ret, tc, score = _evaluate_train(
train, trial, df_1d, df_1h, entry_ohlc
)
if score > best_score:
best_ret, best_score = ret, score
best = sanitize_rules(trial)
best.name = "discovered_or"
return best
return sanitize_rules(best)
def random_search_refine(
@@ -456,8 +715,16 @@ def random_search_refine(
"""무작위 변형으로 국소 최적 보완."""
train = matrix.iloc[:train_end]
best = seed
best_ret, _ = backtest_rules(train, best, df_1d, df_1h, entry_ohlc)
best_ret, best_tc, best_score = _evaluate_train(
train, best, df_1d, df_1h, entry_ohlc
)
rng = random.Random(42)
buy_pool = [
p
for p in pool
if not is_blocked_buy_predicate(p) and not is_chase_buy_predicate(p)
]
sell_pool = [p for p in pool if not is_weak_sell_predicate(p)]
for _ in range(iterations):
trial = DiscoveredRules(
@@ -468,27 +735,30 @@ def random_search_refine(
sell_stop=[p for p in best.sell_stop],
)
action = rng.choice(["add_buy", "drop_buy", "add_sell", "drop_sell", "swap_buy"])
if action == "add_buy" and len(trial.buy_all) < 6:
p = rng.choice(pool)
if action == "add_buy" and len(trial.buy_all) < 6 and buy_pool:
p = rng.choice(buy_pool)
if p not in trial.buy_all:
trial.buy_all.append(p)
elif action == "drop_buy" and trial.buy_all:
trial.buy_all.pop(rng.randrange(len(trial.buy_all)))
elif action == "add_sell" and len(trial.sell_all) < 5:
p = rng.choice(pool)
elif action == "add_sell" and len(trial.sell_all) < 5 and sell_pool:
p = rng.choice(sell_pool)
if p not in trial.sell_all:
trial.sell_all.append(p)
elif action == "drop_sell" and trial.sell_all:
trial.sell_all.pop(rng.randrange(len(trial.sell_all)))
elif action == "swap_buy" and pool:
elif action == "swap_buy" and buy_pool:
if trial.buy_all:
trial.buy_all[rng.randrange(len(trial.buy_all))] = rng.choice(pool)
ret, _ = backtest_rules(train, trial, df_1d, df_1h, entry_ohlc)
if ret > best_ret:
best_ret = ret
trial.buy_all[rng.randrange(len(trial.buy_all))] = rng.choice(buy_pool)
trial = sanitize_rules(trial)
ret, tc, score = _evaluate_train(
train, trial, df_1d, df_1h, entry_ohlc
)
if score > best_score:
best_ret, best_score = ret, score
best = trial
best.name = "discovered_refined"
return best
return sanitize_rules(best)
def discover_rules(frames: dict[int, pd.DataFrame]) -> DiscoveredRules:
@@ -513,11 +783,13 @@ def discover_rules(frames: dict[int, pd.DataFrame]) -> DiscoveredRules:
pool = generate_predicate_pool(intervals)
print(f" 샘플 {n}봉 | 학습 {train_end} | predicate 후보 {len(pool)}")
baseline = _seed_from_combination_report() or _baseline_rules()
br, bt = backtest_rules(matrix.iloc[:train_end], baseline, df_1d, df_1h, entry_ohlc)
print(f" 시드 규칙: {baseline.name}")
baseline = _discovery_seed()
br, bt = backtest_rules(
matrix.iloc[:train_end], baseline, df_1d, df_1h, entry_ohlc
)
print(f" 시드 규칙: {baseline.name} (하단돌파 매수·상단돌파 매도)")
bf, _ = backtest_rules(matrix, baseline, df_1d, df_1h, entry_ohlc)
print(f" 기준선(3분 BB만): 학습 {br:+.2f}% | 전체 {bf:+.2f}%")
print(f" 기준선: 학습 {br:+.2f}% | 전체 {bf:+.2f}%")
print("1단계: 탐욕적 AND 확장...")
g1 = greedy_search(matrix, train_end, pool, baseline, df_1d, df_1h, entry_ohlc)
@@ -532,8 +804,13 @@ def discover_rules(frames: dict[int, pd.DataFrame]) -> DiscoveredRules:
print("3단계: 무작위 정밀 탐색...")
best = g2 if r2 >= r1 else g1
g3 = random_search_refine(matrix, train_end, pool, best, df_1d, df_1h, entry_ohlc, iterations=1200)
train_ret, t_cnt = backtest_rules(matrix.iloc[:train_end], g3, df_1d, df_1h, entry_ohlc)
test_ret, _ = backtest_rules(matrix.iloc[train_end:], g3, df_1d, df_1h, entry_ohlc)
g3 = sanitize_rules(g3)
train_ret, t_cnt = backtest_rules(
matrix.iloc[:train_end], g3, df_1d, df_1h, entry_ohlc
)
test_ret, _ = backtest_rules(
matrix.iloc[train_end:], g3, df_1d, df_1h, entry_ohlc
)
full_ret, full_cnt = backtest_rules(matrix, g3, df_1d, df_1h, entry_ohlc)
g3.train_return_pct = train_ret
@@ -549,7 +826,9 @@ def discover_rules(frames: dict[int, pd.DataFrame]) -> DiscoveredRules:
print(f" 매도 AND: {g3.sell_all}")
if g3.sell_stop:
print(f" 손절: {g3.sell_stop}")
print(f" 학습 {train_ret:+.2f}% | 검증 {test_ret:+.2f}% | 전체 {full_ret:+.2f}% ({full_cnt}건)")
print(
f" 학습 {train_ret:+.2f}% | 검증 {test_ret:+.2f}% | 전체 {full_ret:+.2f}% ({full_cnt}건)"
)
return g3

View File

@@ -25,6 +25,7 @@ from plotly.subplots import make_subplots
from config import (
BUY_COOLDOWN_SEC,
COIN_NAME,
DEFAULT_BUY_KRW,
ENTRY_INTERVAL,
SELL_COOLDOWN_SEC,
SIM_INITIAL_CASH_KRW,
@@ -39,6 +40,8 @@ import strategy
REPORT_DIR = Path(__file__).resolve().parent / "reports"
OUTPUT_HTML = REPORT_DIR / "wld_bb_simulation.html"
LOGOS_BENCHMARK_HTML = REPORT_DIR / "wld_logos_benchmark.html"
LOGOS_TRADES_FILE = Path(__file__).resolve().parent / "logos_trades.json"
def interval_chart_label(interval_min: int) -> str:
@@ -48,9 +51,28 @@ def interval_chart_label(interval_min: int) -> str:
return f"{interval_min}분봉"
def _format_trade_dt(dt: pd.Timestamp) -> str:
"""마커 호버용 날짜·시간 문자열."""
return pd.Timestamp(dt).strftime("%Y-%m-%d %H:%M")
def _trade_hover_lines(t: "SimTrade", label: str) -> str:
"""매수·매도 마커 호버(팝업) 본문."""
lines = [
label,
_format_trade_dt(t.dt),
t.signal or "",
f"{t.price:,.2f}",
f"{t.krw:,.0f}",
]
if t.pnl is not None:
lines.append(f"손익 ₩{t.pnl:+,.0f}")
return "<br>".join(line for line in lines if line)
def _add_trade_markers(fig, trades: list["SimTrade"], row: int = 1) -> None:
"""
매수·매도 마커·라벨 (Scatter trace만 사용, 범례와 함께 토글).
매수·매도 체결 마커·라벨 (Scatter trace만 사용, 범례와 함께 토글).
simulate_mtf.py 와 동일 스타일.
"""
for action, color, symbol, label, text_pos in [
@@ -80,10 +102,7 @@ def _add_trade_markers(fig, trades: list["SimTrade"], row: int = 1) -> None:
color=color,
line=dict(width=2, color="#111"),
),
hovertext=[
f"{label} 체결<br>{t.signal}<br>₩{t.price:,.2f}<br>₩{t.krw:,.0f}"
for t in pts
],
hovertext=[_trade_hover_lines(t, label) for t in pts],
hovertemplate="%{hovertext}<extra></extra>",
),
row=row,
@@ -97,6 +116,8 @@ def build_simulation_html(
trend: str,
interval_min: int = ENTRY_INTERVAL,
note: str = "",
title_suffix: str = "BB 시뮬레이션",
show_memo_column: bool = False,
) -> str:
"""simulate_mtf.py 와 동일 레이아웃의 HTML 리포트."""
df = strategy.prepare_entry_df(df.copy())
@@ -243,6 +264,15 @@ def build_simulation_html(
for t in result.trades:
cls = "buy" if t.action == "매수" else "sell"
pnl = f"{t.pnl:+,.0f}" if t.pnl is not None else "-"
if show_memo_column:
trade_rows += f"""
<tr>
<td>{t.dt}</td>
<td class="{cls}">{t.action}</td>
<td>₩{t.price:,.0f}</td>
<td>{t.signal}</td>
</tr>"""
else:
trade_rows += f"""
<tr>
<td>{t.dt}</td>
@@ -255,19 +285,31 @@ def build_simulation_html(
<td>{pnl}</td>
</tr>"""
if not trade_rows:
trade_rows = '<tr><td colspan="8">신호 없음</td></tr>'
colspan = 4 if show_memo_column else 8
trade_rows = f'<tr><td colspan="{colspan}">체결 없음</td></tr>'
note_html = f"<p class='warn'>{summary['note']}</p>" if summary.get("note") else ""
sells = summary["sell_signal_count"]
win_rate = (
summary["win_count"] / sells * 100 if sells else 0.0
)
if show_memo_column:
table_title = "로고스 타점 (직관 해석)"
table_head = (
"<th>시각</th><th>구분</th><th>가격</th><th>해석</th>"
)
else:
table_title = "체결 내역"
table_head = (
"<th>시각</th><th>구분</th><th>상태</th><th>가격</th>"
"<th>신호</th><th>금액</th><th>수수료</th><th>손익</th>"
)
return f"""<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="utf-8"/>
<title>{SYMBOL} BB 시뮬레이션</title>
<title>{SYMBOL} {title_suffix}</title>
<style>
body {{ font-family: "Malgun Gothic", Arial, sans-serif; margin: 24px; background: #f8fafc; }}
h1 {{ font-size: 1.35rem; }}
@@ -290,7 +332,7 @@ def build_simulation_html(
</style>
</head>
<body>
<h1>{COIN_NAME} ({SYMBOL}) BB 시뮬레이션</h1>
<h1>{COIN_NAME} ({SYMBOL}) {title_suffix}</h1>
<p class="meta">전략: {summary['config_name']} | 추세: {summary['trend']} | 기간: {summary['period_start']} ~ {summary['period_end']}</p>
{note_html}
<div class="legend-box">
@@ -304,9 +346,9 @@ def build_simulation_html(
<div class="card"><span>승률(매도 기준)</span><b>{win_rate:.1f}%</b></div>
</div>
<div class="chart-wrap">{chart_html}</div>
<h2>신호·체결 내역</h2>
<h2>{table_title}</h2>
<table>
<thead><tr><th>시각</th><th>구분</th><th>상태</th><th>가격</th><th>신호</th><th>금액</th><th>수수료</th><th>손익</th></tr></thead>
<thead><tr>{table_head}</tr></thead>
<tbody>{trade_rows}</tbody>
</table>
</body>
@@ -364,6 +406,7 @@ def run_backtest(
last_sell_ts: pd.Timestamp | None = None
signals = df_3m[df_3m["point"] == 1].sort_index()
signals = signals[signals["action"].isin(["buy", "sell"])]
for ts, row in signals.iterrows():
price = float(row["Close"])
@@ -610,6 +653,194 @@ class Simulation:
webbrowser.open(OUTPUT_HTML.resolve().as_uri())
return OUTPUT_HTML
def render_logos_html(
self,
df_plot: pd.DataFrame,
result: SimResult,
trend: str,
note: str = "",
open_browser: bool = True,
) -> Path:
"""로고스 직관 타점 HTML (규칙 엔진 미사용)."""
html = build_simulation_html(
df_plot,
result,
trend,
note=note,
title_suffix="로고스 직관 타점 (3분봉)",
show_memo_column=True,
)
REPORT_DIR.mkdir(parents=True, exist_ok=True)
LOGOS_BENCHMARK_HTML.write_text(html, encoding="utf-8")
print(f"HTML: {LOGOS_BENCHMARK_HTML}")
if open_browser:
webbrowser.open(LOGOS_BENCHMARK_HTML.resolve().as_uri())
return LOGOS_BENCHMARK_HTML
def run_logos_strategy_chart(self, frames: dict[int, pd.DataFrame]) -> SimResult:
"""
로고스 전략(logos_strategy.py) 백테스트·HTML.
설계: docs/LOGOS_STRATEGY.md
"""
from candle_features import build_master_feature_matrix
from logos_strategy import generate_logos_events
df_1d, df_1h, df_3m = self._frames_to_mtf(frames)
trend = strategy.get_trend(df_1d, df_1h)
matrix = build_master_feature_matrix(frames).iloc[21:].copy()
df_sig = strategy.prepare_entry_df(df_3m)
df_sig["signal"] = ""
df_sig["point"] = 0
df_sig["action"] = ""
df_sig["trend"] = ""
logos_events = generate_logos_events(matrix, df_1d, df_1h)
for ts, action, sig in logos_events:
if ts not in df_sig.index:
continue
df_sig.at[ts, "signal"] = sig
df_sig.at[ts, "point"] = 1
df_sig.at[ts, "action"] = action
df_sig.at[ts, "trend"] = strategy.get_trend_at(df_1d, df_1h, ts)
n_sig = int((df_sig["point"] == 1).sum())
print(f"\n[로고스 전략] 체결 {n_sig}")
from logos_strategy import compare_to_ground_truth
cmp_rows = compare_to_ground_truth(logos_events, matrix)
if cmp_rows:
matched = sum(1 for r in cmp_rows if r["match"])
print(f"[정답 비교] {matched}/{len(cmp_rows)} 타점 일치 (±20봉·±6%)")
for r in cmp_rows:
mark = "O" if r["match"] else "X"
cand = r.get("cand_dt") or "-"
print(
f" [{mark}] {r['gt_action']} {r['gt_dt'][:16]} "
f"-> {str(cand)[:16]} ({r['score_pct']}%)"
)
result = run_backtest(df_sig, df_1d, df_1h, config_name="logos_strategy")
print_backtest_report(result)
html = build_simulation_html(
df_sig,
result,
trend,
note="로고스 전략: docs/LOGOS_STRATEGY.md (A바닥/B눌림/C익절/D과열)",
title_suffix="로고스 전략 (3분봉)",
)
REPORT_DIR.mkdir(parents=True, exist_ok=True)
OUTPUT_HTML.write_text(html, encoding="utf-8")
print(f"HTML: {OUTPUT_HTML}")
import webbrowser
webbrowser.open(OUTPUT_HTML.resolve().as_uri())
return result
def run_logos_chart(self, frames: dict[int, pd.DataFrame]) -> SimResult:
"""
차트 해석 기반 수동 타점을 전 기간 3분봉에 표시합니다.
데이터: logos_trades.json (BB/탐색 규칙과 무관)
"""
import json
if not LOGOS_TRADES_FILE.exists():
raise FileNotFoundError(f"{LOGOS_TRADES_FILE} 없음")
spec = json.loads(LOGOS_TRADES_FILE.read_text(encoding="utf-8"))
df_1d, df_1h, df_3m = self._frames_to_mtf(frames)
df_3m = strategy.prepare_entry_df(df_3m)
trend = strategy.get_trend(df_1d, df_1h)
buy_krw = float(DEFAULT_BUY_KRW)
fee_rate = TRADING_FEE_RATE
cash = float(SIM_INITIAL_CASH_KRW)
qty = 0.0
cost = 0.0
sim_trades: list[SimTrade] = []
win = 0
for row in spec.get("trades") or []:
ts = pd.Timestamp(row["dt"])
act = row["action"]
px = float(row["price"])
memo = str(row.get("memo", ""))
if act == "buy":
fee = buy_krw * fee_rate
cash -= buy_krw + fee
qty += buy_krw / px
cost += buy_krw
sim_trades.append(
SimTrade(
dt=ts,
action="매수",
signal=memo,
price=px,
krw=buy_krw,
fee=fee,
quantity=buy_krw / px,
pnl=None,
cash_after=cash,
total_asset=cash + qty * px,
)
)
elif act == "sell" and qty > 0:
sell_krw = qty * px
fee = sell_krw * fee_rate
net = sell_krw - fee
pnl = net - cost
if pnl > 0:
win += 1
cash += net
sim_trades.append(
SimTrade(
dt=ts,
action="매도",
signal=memo,
price=px,
krw=sell_krw,
fee=fee,
quantity=qty,
pnl=pnl,
cash_after=cash,
total_asset=cash,
)
)
qty = 0.0
cost = 0.0
last_px = float(df_3m["Close"].iloc[-1])
result = SimResult(
config_name=spec.get("name", "logos"),
trades=sim_trades,
initial_cash=SIM_INITIAL_CASH_KRW,
final_cash=cash,
final_coin_qty=qty,
final_price=last_px,
realized_pnl=sum(t.pnl or 0 for t in sim_trades if t.pnl),
total_fees=sum(t.fee for t in sim_trades),
final_asset=cash + qty * last_px,
total_return_pct=(cash + qty * last_px - SIM_INITIAL_CASH_KRW)
/ SIM_INITIAL_CASH_KRW
* 100,
trade_count=len(sim_trades),
win_count=win,
)
print(f"\n[로고스 직관 타점] {spec.get('author', 'Logos')}")
print(f" 기간: {df_3m.index[0]} ~ {df_3m.index[-1]}")
print(f" 타점 {len(sim_trades)}개 (규칙 엔진·BB 조건 미사용)")
for t in sim_trades:
print(f" {t.dt} {t.action}{t.price:,.0f}{t.signal}")
print_backtest_report(result)
note = spec.get("note", "")
path = self.render_logos_html(df_3m, result, trend, note=note, open_browser=True)
print(f"\n차트 파일: {path.resolve()}")
print("브라우저에서 열리지 않으면 위 경로를 더블클릭하세요.")
return result
def load_all_frames(self) -> dict[int, pd.DataFrame]:
"""discovered 규칙용 전 간격 로드."""
from mtf_bb import load_frames_from_db
@@ -690,7 +921,7 @@ class Simulation:
n_sig = int((df_sig["point"] == 1).sum())
buy_sig = int((df_sig["action"] == "buy").sum())
sell_sig = int((df_sig["action"] == "sell").sum())
print(f"\n규칙 신호: {n_sig} (매수 {buy_sig} / 매도 {sell_sig})")
print(f"\n체결 신호: {n_sig} (매수 {buy_sig} / 매도 {sell_sig})")
result = run_backtest(df_sig, df_1d, df_1h, config_name=rule_set.name)
print_backtest_report(result)
@@ -770,7 +1001,15 @@ def run_analyze(frames: dict[int, pd.DataFrame] | None = None) -> None:
def run_discover(frames: dict[int, pd.DataFrame] | None = None):
"""모든 봉·BB·일목 특징으로 최적 규칙 탐색 후 JSON 저장."""
"""
모든 봉·BB·일목 특징으로 최적 규칙 탐색 후 JSON 저장.
Args:
frames: 전 간격 OHLCV. None이면 coins.db에서 로드.
Returns:
DiscoveredRules 또는 데이터/탐색 실패 시 None.
"""
from rule_discovery import discover_rules, save_rules
if frames is None:
@@ -784,30 +1023,25 @@ def run_discover(frames: dict[int, pd.DataFrame] | None = None):
return rules
def run_full_pipeline() -> None:
"""
일반 사용자용 일괄 실행: analyze → discover → HTML.
def run_logos_benchmark() -> None:
"""수동 벤치마크 타점 차트 (logos_trades.json, 참고용)."""
print("=== 로고스 수동 벤치마크 차트 ===")
frames = _load_all_frames_or_exit()
if frames is None:
return
Simulation().run_logos_chart(frames)
print("\n완료.")
DB 로드는 한 번만 수행합니다.
"""
def run_full_pipeline() -> None:
"""로고스 전략 백테스트·HTML (단일 진입점)."""
print("=" * 60)
print("전체 파이프라인: analyze → discover → HTML")
print("로고스 전략: 백테스트 → HTML")
print("=" * 60)
frames = _load_all_frames_or_exit()
if frames is None:
return
print("\n[1/3] 조합 분석 (analyze)")
run_analyze(frames)
print("\n[2/3] 규칙 탐색 (discover)")
run_discover(frames)
print("\n[3/3] 백테스트·HTML 차트 (탐색 규칙 매수·매도)")
if rules is None:
print("규칙 탐색 실패 — HTML 생략")
return
Simulation().run_discovered_chart(frames, rules=rules)
Simulation().run_logos_strategy_chart(frames)
print("\n완료.")
@@ -817,7 +1051,10 @@ def print_usage() -> None:
DeepCoin simulation.py
python simulation.py
analyze + discover + HTML (차트 = discovered_rules 매수·매도)
로고스 전략 백테스트·HTML → reports/wld_bb_simulation.html
python simulation.py benchmark
수동 벤치마크(logos_trades.json) → reports/wld_logos_benchmark.html
(고급) analyze | discover | compare | mtf
"""
@@ -840,6 +1077,9 @@ def main() -> None:
if len(sys.argv) > 1 and sys.argv[1] == "discover":
run_discover()
return
if len(sys.argv) > 1 and sys.argv[1] in ("benchmark", "logos"):
run_logos_benchmark()
return
if len(sys.argv) > 1 and sys.argv[1] == "mtf":
run_mtf_analysis()
return

View File

@@ -489,29 +489,44 @@ def evaluate_discovered_live(
최신 3분 봉 시점에서 discovered_rules + 전 봉 BB·일목 조합으로 신호 1건.
"""
from candle_features import build_master_feature_matrix
from rule_discovery import buy_mask, load_rules, rules_have_buy, sell_mask
from rule_discovery import (
_trigger_at,
buy_mask,
load_rules,
rules_have_buy,
sell_mask,
)
rules = load_rules()
if rules is None or not rules_have_buy(rules):
return None
matrix = build_master_feature_matrix(frames)
if len(matrix) < 22:
if len(matrix) < 23:
return None
last = matrix.iloc[[-1]]
ts = last.index[-1]
close = float(last["Close"].iloc[-1])
tail = matrix.iloc[-2:]
i = 1
ts = tail.index[-1]
close = float(tail["Close"].iloc[-1])
trend = get_trend_at(df_1d, df_1h, ts)
b_m = buy_mask(tail, rules)
s_m = sell_mask(tail, rules, stop=False)
st_m = (
sell_mask(tail, rules, stop=True)
if rules.sell_stop
else np.zeros(len(tail), dtype=bool)
)
position = float(balances.get(symbol, {}).get("balance", 0) or 0)
if position >= 1.0:
if rules.sell_stop and sell_mask(last, rules, stop=True)[0]:
if rules.sell_stop and _trigger_at(st_m, i):
return TradeSignal("sell", SIGNAL_SELL_STOP, close, trend)
if sell_mask(last, rules, stop=False)[0]:
if _trigger_at(s_m, i):
return TradeSignal("sell", SIGNAL_SELL_UPPER, close, trend)
return None
if buy_mask(last, rules)[0]:
if _trigger_at(b_m, i):
return TradeSignal("buy", SIGNAL_BUY_LOWER, close, trend)
return None