Compare commits

..

37 Commits

Author SHA1 Message Date
joungmin
6e0c4508fa refactor: MVC 구조 분리 + 미사용 파일 archive 정리
- tick_trader.py를 Controller로 축소, 로직을 3개 모듈로 분리:
  - core/signal.py: 시그널 감지, 지표 계산 (calc_vr, calc_atr, detect_signal)
  - core/order.py: Upbit 주문 실행 (매수/매도/취소/조회)
  - core/position_manager.py: 포지션 관리, DB sync, 복구, 청산 조건
- type hints, Google docstring, 구체적 예외 타입 적용
- 50줄 초과 함수 분리 (process_signal, restore_positions)
- 미사용 파일 58개 archive/ 폴더로 이동
- README.md 추가

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 20:46:47 +09:00
joungmin
976c53ed66 feat: 트레일링 스탑 전환 + 사전 필터 강화 + 예산 증액
- cascade/LLM 매도 제거 -> 트레일링 스탑 (고점 -1.5%, 손절 -2%, 타임아웃 4h)
- 사전 필터 3종 추가: 횡보/고점/연속양봉(>=2) -> LLM 호출 57% 절감
- 현재가 매수 (LLM 가격 제안 제거)
- 종목 30개 -> 10개, BTC 제외
- 예산: 100K/3pos -> 1M/5pos (종목당 200K)
- VOL_KRW_MIN: 2M -> 5M, BUY_TIMEOUT: 60 -> 180초
- LLM 프롬프트: 연패 무시, get_trade_history 제거
- 3월 백테스트: 승률 52.1%, PNL +17,868원
- STRATEGY.md 전면 재작성

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 20:33:15 +09:00
joungmin
872163a3d1 fix: 저가 코인 소수점 표시 + VOL_MIN 6.0 조정
- fp() 헬퍼: 100원 미만 코인 소수점 표시 (HOLO 등)
- VOL_MIN 8→6: 신호 빈도 적정화
- LLM 로그 가격도 :,.2f 통일

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 22:54:56 +09:00
joungmin
9944b55f94 fix: restore_positions에서 locked 잔고 포함 + 기존 매도주문 취소
- balance + locked으로 보유 수량 판단 (지정가 매도 중이면 locked에 잡힘)
- 복구 시 기존 미체결 매도 주문 취소 후 새로 제출
- 취소 후 실제 가용 수량 재조회

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 22:38:54 +09:00
joungmin
526003c979 fix: 포지션 한도 초과 방지 + LLM 호출 최적화
- VOL_MIN 4→8 복원 (시그널 빈도 과다)
- process_signal: LLM 호출 전/후 포지션 한도 재확인
- check_pending_buys: 체결 시점 한도 초과면 즉시 취소
- LLM tool 중복 호출 방지 (같은 tool+args → 캐시 응답)
- 모든 tool 호출 완료 시 tool 제거해 강제 텍스트 응답
- max_rounds 8→5 축소
- 재시작 시 Upbit 잔고 기반 포지션 자동 복구
- LLM 모델: google/gemini-2.5-flash로 전환

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 22:34:35 +09:00
joungmin
4f9e2c44c7 feat: LLM 판단 상세 로깅 + 텔레그램 메시지에 LLM 응답 포함
- get_entry_price/get_exit_price가 전체 dict 반환 (action, price, confidence, reason, market_status, watch_needed)
- 매수: 시그널→LLM 승인/스킵 사유, 확신도, 시장 상태 텔레그램 전송
- 매도: LLM 지정가 설정 시 진입 대비 수익률, 확신도, 관망 여부 텔레그램 전송
- 청산: LLM/cascade 구분 태그 (텔레그램 + 로그)
- cascade fallback 전환 시 로그 명시

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 21:57:19 +09:00
joungmin
19a35e1009 feat: LLM-driven buy decisions with limit orders + robust JSON parsing
- Add get_entry_price() for LLM buy decisions (BTC trend, trade history, context tools)
- Replace market buy with LLM-determined limit buy price
- Lower signal threshold (VOL_MIN 8→4) — LLM makes final buy/skip decision
- Restructure tick_trader: detect_signal() inside lock, LLM call outside
- Add pending_buys tracking with timeout cancellation
- Remove unused enter_position() and do_buy() functions
- Fix JSON parsing: extract JSON from mixed text responses

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 21:52:44 +09:00
joungmin
7f1921441b feat: OpenRouter LLM 매도 어드바이저 + 종목 컨텍스트 수집 데몬
- llm_advisor: Anthropic → OpenRouter API 전환 (claude-haiku-4.5)
- llm_advisor: get_ticker_context DB tool 추가 (24h/7d 가격, 뉴스)
- llm_advisor: 구조화 JSON 응답 (confidence, reason, market_status, watch_needed)
- llm_advisor: LLM primary + cascade fallback (llm_active 플래그)
- llm_advisor: SQL bind variable 버그 수정 (INTERVAL → NUMTODSINTERVAL)
- tick_collector: backtest_ohlcv 1분봉 실시간 갱신 추가 (60초 주기)
- context_collector: 신규 데몬 — 1시간마다 price_stats + SearXNG 뉴스 수집
- ecosystem: tick-collector, tick-trader, context-collector PM2 등록

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 21:39:02 +09:00
joungmin
ab5c963803 feat: WF always reads from DB + vol bypass threshold
- buy() now always loads WF history from trade_results DB directly
  (not lazy-cached in memory) → restart-safe, no stale-history issue
- Added WF_VOL_BYPASS_THRESH (default 10.0x): if vol_ratio at entry
  exceeds this threshold, WF filter is skipped regardless of win rate
- buy() now accepts vol_ratio param; runner.py passes it from
  get_active_signals() for both fast-poll and main scan loops

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-04 10:54:52 +09:00
joungmin
2499ea08ef docs: rewrite STRATEGY.md for 10m vol-lead strategy
- Remove legacy 40m resampling, velocity-based entry, Bear regime sections
- Add F&G 3-tier vol threshold system (<=40->6x, 41-50->5x, >50->blocked)
- Add undying signal and vol refresh behavior docs
- Add watch alert (4x-6x approaching threshold) documentation
- Add 1yr 10m backtest results and THRESH sweep table
- Add F&G greed blocking effect comparison table
- Update file list, env vars, and sim run commands
- Add change history from 2026-03-03 to 2026-03-04

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-04 09:51:39 +09:00
joungmin
cfbacdacbc feat: rewrite strategy to 10m vol-lead with undying signal + watch alert
- core/strategy.py: full rewrite to Volume Lead strategy
  - 10m candle direct detection (no 40m resampling)
  - F&G 3-tier vol threshold: <=40->6x, 41-50->5x, >50->blocked
  - Undying signal: price drop does not cancel signal (sig_p fixed)
  - Vol refresh: stronger vol_r updates signal price and timer
  - Watch alert: 4x-6x approaching threshold notifies via Telegram
  - WATCH_VOL_THRESH=4.0, WATCH_COOLDOWN_MIN=30, WATCH_VOL_JUMP=0.5
- daemon/runner.py: remove FNG_MIN_ENTRY block and Bear regime block
  - Only FNG_MAX_ENTRY(>50) blocks scan (greed/extreme greed)
  - Fast-poll loop cleaned of regime check
- core/notify.py: add notify_watch() for near-signal Telegram alerts
  - Shows vol_r, distance to threshold, price, quiet pct
- tests/: add 1y data collection and simulation scripts
  - collect_1y_data.py, refresh_cache.py
  - sim_10m_vol.py, sim_current.py, sim_regime_1y.py
  - sim_regime_sweep.py, sim_vol_override.py

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-04 09:28:13 +09:00
joungmin
6580cda017 docs: update STRATEGY.md with F&G filter and project structure changes
- Add F&G filter section: FNG_MIN_ENTRY=41, 1-year backtest proof
  (no filter: +95K KRW → F&G>=41: +1.72M KRW over 1 year)
- Add F&G backtest result (section D) with adaptive param findings
- Update section E: rename velocity backtest (was D)
- Split file table into production core vs tests/ scripts
- Update simulation commands to use tests/ path prefix
- Update env config: add FNG_MIN_ENTRY=41
- Add changelog entries for F&G filter, project structure, ecosystem.config.js

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-03 16:14:50 +09:00
joungmin
6b2c962ed8 refactor: reorganize project structure into tests/, data/, logs/
- Move all backtest/simulation scripts to tests/
  - Add sys.path.insert to each script for correct import resolution
- Move pkl cache files to data/ (git-ignored)
- Move log files to logs/ (git-ignored)
- Update main.py: trading.log path → logs/trading.log
- Add ecosystem.config.js: pm2 log paths → logs/pm2*.log
- Update .gitignore: ignore data/ and logs/ instead of *.pkl/*.log
- core/fng.py: increase cache TTL 3600→86400s (API updates daily at KST 09:00)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-03 16:08:50 +09:00
joungmin
bfe0b4d40c fix: reduce F&G log noise and skip scan loop when blocked
- fng.py: downgrade per-ticker block log to DEBUG
- runner.py: skip entire scan (continue) when F&G < FNG_MIN_ENTRY
  instead of iterating 20 tickers each blocked individually

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-03 15:57:12 +09:00
joungmin
27189b1ad9 feat: add Fear & Greed filter to entry logic
- core/fng.py: F&G API wrapper with 1h cache (alternative.me)
  - FNG_MIN_ENTRY=41 (env-configurable), blocks entry below threshold
- core/strategy.py: call is_entry_allowed() before volume/regime checks
- daemon/runner.py: log F&G status on every scan cycle
- core/notify.py: include F&G value in buy/signal/status notifications
- core/trader.py: pass current F&G value to notify_buy

Backtest evidence (1y / 18 tickers / 1h candles):
  - No filter:   820 trades, 32.7% WR, avg +0.012%, KRW +95k
  - F&G >= 41:   372 trades, 39.5% WR, avg +0.462%, KRW +1.72M
  - Blocked 452 trades (avg -0.372%, saved ~1.68M KRW loss)

Also add:
- backtest_db.py: Oracle DB storage for backtest runs/results/trades
- fng_1y_backtest.py, fng_adaptive_backtest.py, fng_sim_comparison.py

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-03 15:56:17 +09:00
joungmin
673ce08d84 feat: add velocity entry, fast-poll thread, tighten BEAR threshold
- Add velocity-based entry signal in strategy.py (VELOCITY_THRESHOLD=0.10,
  VELOCITY_MIN_MOVE=0.5%, VELOCITY_MIN_AGE_M=5)
- Add fast-poll thread in daemon/runner.py (SIGNAL_POLL_INTERVAL=15s)
  for sub-minute velocity event detection
- Add vol_ratio tiered condition and get_active_signals() to strategy.py
- Change BEAR_THRESHOLD -1.0 → -0.5 in market_regime.py to catch
  slow downtrends earlier (weighted 2h score)
- Expand sell_reason VARCHAR2(500) in price_db.py DDL
- Add velocity_backtest.py and sim10m.py for strategy experimentation
- Update STRATEGY.md: correct regime algorithm description (weighted 2h
  score, not BTC 1h ±5%), add fast-poll/velocity sections, add backtest
  section D, add change history table

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-03 10:17:08 +09:00
joungmin
612055162e docs: update backtest results and WF_WINDOW param after tuning 2026-03-02 15:16:00 +09:00
joungmin
3e2cdeb2c5 feat: relax WF filter WF_WINDOW 2→4 for 40min candle strategy
40min candles generate signals more frequently, making 2-consecutive-loss
blocking too aggressive. Analysis showed WF was blocking trades with 55.9%
win rate vs 43.9% for accepted trades.

WF_WINDOW=4 (4연패 시 차단) reduces blocked trades from 34→3 out of 91,
improving 45-day return from +14.87% to +44.56% with lower drawdown (-3.90%).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-02 15:15:37 +09:00
joungmin
6a685a7852 feat: add sim_45m40.py and update STRATEGY.md with 40min backtest results 2026-03-02 15:02:45 +09:00
joungmin
c6c6b0020f docs: clarify backtest sections with candle unit and data source 2026-03-02 14:58:26 +09:00
joungmin
bd802fb896 docs: update STRATEGY.md to 40min candle strategy 2026-03-02 14:55:06 +09:00
joungmin
a479bccee6 feat: switch vol-lead strategy from 1h to 40min candles
Simulation sweep showed 40min candles outperform 1h:
- 40min: 91 trades, 48.4% WR, +119% PnL, -11% DD
- 60min: 65 trades, 50.8% WR, +88% PnL, -12% DD

Changes:
- strategy.py: fetch minute10, resample to 40min for vol spike detection
  - LOCAL_VOL_CANDLES=7 (was LOCAL_VOL_HOURS=5, 5h/40min = 7 candles)
- monitor.py: ATR calculated from 40min candles
  - ATR_CANDLES=7 (was 5, now 5h in 40min units)
  - ATR_CACHE_TTL=2400s (was 600s, aligned to 40min candle)
- interval_sweep.py: new interval comparison tool (10/20/30/40/50/60min)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-02 14:52:48 +09:00
joungmin
4b6cb8ca0e fix: persist WF shadow state to DB and tighten ATR max stop
- core/price_db.py: add wf_state table CRUD (ensure/upsert/load/delete)
  to persist shadow_cons_wins across restarts
- core/trader.py: save WF blocked state on shadow enter/close,
  restore shadow_cons_wins on startup from DB
- core/monitor.py: lower ATR_MAX_STOP 4.0% → 2.0% based on sweep results
- atr_sweep.py: new ATR_MAX_STOP sweep tool using real ATR calc from DB

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-02 13:49:32 +09:00
joungmin
324d69dde0 feat: volume-lead strategy with compounding, WF filter, and DB-backed simulation
- core/strategy.py: replace trend strategy with volume-lead accumulation
  (vol spike + 2h quiet → signal, +4.8% rise → entry)
- core/trader.py: compound budget adjusts on both profit and loss (floor 30%)
- core/notify.py: add accumulation signal telegram notification
- ohlcv_db.py: Oracle ADB OHLCV cache (insert, load, incremental update)
- sim_365.py: 365-day compounding simulation loading from DB
- krw_sim.py: KRW-based simulation with MAX_POSITIONS constraint
- ticker_sim.py: ticker count expansion comparison
- STRATEGY.md: full strategy documentation
- .gitignore: exclude *.pkl cache files

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-02 01:46:03 +09:00
joungmin
7c7fb08693 feat: replace trend strategy with volume-lead accumulation strategy
- strategy.py: rewrite should_buy() with volume-lead logic
  - detect accumulation: vol spike + 2h quiet price → record signal_price
  - entry: price rises ≥ TREND_AFTER_VOL% from signal_price
  - signal reset: timeout (8h) or price drops below signal_price
- .env: add PRICE_QUIET_PCT=2.0, TREND_AFTER_VOL=4.8, SIGNAL_TIMEOUT_H=8.0
- vol_lead_sim.py: add parameter sweep 0.5~5.0% + fine sweep 4.0~5.0%

Backtest result (9 tickers, 2026-01-15~): +4.8% threshold
  26 trades | 69% win rate | +73.38% cumulative (vs A 33 trades 45% +24.25%)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 00:22:20 +09:00
joungmin
54ce327c50 chore: add WF/shadow/momentum analysis simulation scripts
Scripts used to analyze and validate strategy changes:
- wf_cmp.py: WF window size comparison on 42 real trades
- wf_cmp2.py: WF comparison extended with price_history simulation
- shadow_sim.py: shadow rehabilitation sim without strategy filters
- shadow_sim2.py: post-rehabilitation performance simulation
- shadow_sim3.py: shadow rehabilitation sim with full strategy filters
- momentum_cmp.py: momentum filter A/B comparison
- trend_check.py: 2h price gain distribution analysis per ticker

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 23:58:42 +09:00
joungmin
29d48f0fe9 feat: add shadow trading rehabilitation for WF-blocked tickers
When WF filter blocks a ticker, automatically start a virtual (shadow)
position with the same trailing/time stop logic. After WF_SHADOW_WINS
consecutive shadow wins, reset WF history to re-enable real trading.

- trader.py: add _shadow_positions, _shadow_cons_wins state;
  _shadow_enter(), get_shadow_positions(), update_shadow_peak(),
  close_shadow() functions; trigger shadow entry on WF block in buy()
- monitor.py: add _check_shadow_position() with ATR trailing + time stop;
  check shadow positions every 10s in run_monitor()
- Env: WF_SHADOW_WINS=2 (2 consecutive wins to rehabilitate)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 23:57:45 +09:00
joungmin
16b4c932a2 feat: bear block, trend-continuation entry, partial TP backtest
1. daemon/runner.py: skip scan entirely in bear regime
   - calls get_regime() at start of each scan loop
   - logs bear block with score before sleeping

2. core/strategy.py: trend-continuation entry filter
   - check_trend_6h(): 6h price trend >= 1% (rejects flash spikes)
   - 15-min confirmation watchlist (_watchlist dict)
   - should_buy() adds watchlist to existing 12h+regime+momentum logic
   - CONFIRM_SECONDS env var (default 900 = 15min)
   - TREND_6H_MIN_PCT env var (default 1.0%)

3. backtest.py: partial take-profit scenario comparison (--tp-cmp)
   - simulate(): partial_tp_pct / partial_tp_ratio params
   - blended pnl = ratio * partial_pnl + (1-ratio) * remaining_pnl
   - main_tp_cmp(): 3 scenarios A/B/C (none / +5% 50% / +3% 50%)
   - result: partial TP reduces cumulative return (-56% → -63%)
     big winners carry the strategy; trimming them hurts expected value

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-01 10:51:02 +09:00
joungmin
b0f0b3e82a feat: ATR adaptive trailing stop and 2-decimal formatting
- monitor.py: replace fixed 1.5% stop with ATR-based adaptive stop
  recent 5x 1h candles avg range × 1.5 mult, clamped 1.0%~4.0%
  10min cache per ticker to minimize API calls
  all log numbers formatted to 2 decimal places
- notify.py: apply 2 decimal places to price, P&L, fee, cum_profit

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-01 10:32:24 +09:00
joungmin
83a229dd26 feat: add market regime filter and compound reinvestment
- Add market_regime.py: BTC/ETH/SOL/XRP weighted 2h trend score
  Bull(≥+1.5%) / Neutral / Bear(<-1%) regime detection with 10min cache
- strategy.py: dynamic TREND/VOL thresholds based on current regime
  Bull: 3%/1.5x, Neutral: 5%/2.0x, Bear: 8%/3.5x
- price_collector.py: always include leader coins in price history
- trader.py: compound reinvestment (profit added to budget, floor at initial)
- notify.py: regime info in hourly report, P&L icons (/, 💚/🔴)
- main.py: hourly status at top-of-hour, filter positions held 1h+
- backtest.py: timestop/combo comparison modes

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-01 10:14:36 +09:00
joungmin
035b3e2f30 fix: use actual order fills for weighted avg sell price
After sell_market_order, query Upbit /v1/order API to get actual
trade fills. If split across multiple fills, compute weighted average
price and use actual paid_fee instead of estimate.

Falls back to get_current_price if order query fails.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-01 06:11:29 +09:00
joungmin
bcef128155 feat: add trade_id + full trade record to trade_results
Each buy generates a UUID trade_id stored in positions table.
Each sell links via same trade_id in trade_results, enabling
round-trip grouping of buy→sell pairs.

Additional fields saved per trade:
- fee_krw: commission amount (0.05% each side)
- krw_profit: net KRW profit/loss after fees
- buy_price / sell_price: exact prices
- invested_krw: capital deployed
- sell_reason: trailing stop / time stop / etc.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-01 05:54:06 +09:00
joungmin
60e739d18b fix: use local 5h volume baseline instead of 23h global average
23h average includes high-volume daytime periods, causing false negatives
at early morning hours. Now compare last 1h candle against the previous
5h local average (same time-of-day context) with 1.2x multiplier.

Also add momentum failure debug logs to show exact reason for rejection.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-01 05:46:25 +09:00
joungmin
5df56a933e feat: use 60min volume, add KRW P&L log, relax re-entry after win
- strategy: replace daily volume check with 60-min candle volume
  (daily volume at 5am is tiny -> BTC/ETH never matched; now uses
  last 1h candle vs previous 23h avg × 2)
- trader: log actual KRW net profit and fee on every sell
- trader: skip re-entry +1% block when last trade was a win
  (allow re-entry on new trend signal even below last sell price)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-01 05:38:37 +09:00
joungmin
d2a5c3ae9e fix: persist sell prices to DB and add WF filter bootstrap
- price_db: add sell_prices table (ensure/upsert/load/delete)
- trader: restore _last_sell_prices from DB on startup so re-entry
  block survives restarts; persist each sell price immediately
- market: retry chunk requests up to 3 times with backoff on 429

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-01 05:19:22 +09:00
joungmin
0b264b304c feat: add backtest module with DB cache and scenario comparison
Backtest improvements:
- Add backtest.py with Oracle DB-backed OHLCV cache (no repeated API calls)
- Add backtest_trades table to cache simulation results by params hash
  (same params -> instant load, skip re-simulation)
- Add walk-forward scenario comparison (--walkforward-cmp)
- Add trend ceiling filter (--trend-cmp, max gain threshold)
- Add ticker win-rate filter (--ticker-cmp, SQL-based instant analysis)
- Precompute daily_features once per data load (not per scenario)

Live bot fixes:
- monitor: add hard stop-loss from buy price (in addition to trailing)
- strategy: fix re-entry condition to require +1% above last sell price
- price_collector: add 48h backfill on startup for trend calculation
- main: call backfill_prices() at startup

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-28 23:28:27 +09:00
joungmin
4888aa0faa feat: add walk-forward trade filter to prevent re-entry on losing tickers
- Add trade_results table to Oracle DB for persistent trade history
- Record win/loss after each sell with pnl_pct
- Load last N trades per ticker from DB on startup (survives restarts)
- Block buy() when recent win rate (last 5 trades) < 40% threshold
- Configurable via WF_WINDOW and WF_MIN_WIN_RATE env vars
- Backtest showed improvement from -7.5% to +37.4% cumulative return

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-28 23:28:07 +09:00
81 changed files with 19761 additions and 570 deletions

3
.gitignore vendored
View File

@@ -2,4 +2,5 @@
__pycache__/
*.pyc
.venv/
*.log
logs/
data/

100
README.md Normal file
View File

@@ -0,0 +1,100 @@
# upbit-trader
Upbit WebSocket 기반 20초봉 자동매매 봇. LLM(Gemini 2.5 Flash) 매수 판단 + 트레일링 스탑 청산.
## 프로젝트 구조
```
upbit-trader/
STRATEGY.md -- 전략 상세 문서
ecosystem.config.js -- PM2 프로세스 설정
backtest_march.py -- 3월 백테스트 시뮬레이션
core/ -- Model / Service 레이어
signal.py -- 시그널 감지 (양봉 + VOL + 사전필터 3종)
order.py -- Upbit 주문 실행 (매수/매도/취소/조회)
position_manager.py -- 포지션 관리, 청산 조건, DB sync, 복구
llm_advisor.py -- LLM 매수 어드바이저 (OpenRouter + tool calling)
notify.py -- 텔레그램 알림
daemons/ -- Controller / 데몬
tick_trader.py -- 주력 트레이더 (WebSocket -> 봉 집계 -> 매매)
tick_collector.py -- price_tick + 1분봉 Oracle 수집
context_collector.py -- 종목 컨텍스트 수집 (뉴스 + 가격 통계)
state_sync.py -- 포지션 상태 동기화
archive/ -- 미사용 파일 보관
```
## 매매 전략 요약
### 진입
1. 20초봉 확정 시 시그널 감지 (양봉 + 거래량 5x + 거래대금 5M+)
2. 사전 필터: 횡보(15봉 변동 < 0.3%), 고점(30분 구간 90%+), 연속양봉(2봉+)
3. LLM(Gemini 2.5 Flash) 매수 판단 -> 현재가 지정가 매수
### 청산
| 조건 | 값 |
|------|------|
| 트레일링 스탑 | 고점 대비 -1.5% (최소 수익 +0.5%) |
| 손절 | -2% |
| 타임아웃 | 4시간 |
### 운용 설정
| 항목 | 값 |
|------|------|
| 감시 종목 | 10개 (ETH, XRP, SOL, DOGE, SIGN, BARD, KITE, CFG, SXP, ARDR) |
| 총 예산 | 1,000,000원 |
| 최대 포지션 | 5개 |
| 종목당 투자 | 200,000원 |
## 기술 스택
| 구성 | 기술 |
|------|------|
| 거래소 | Upbit API (REST + WebSocket) |
| DB | Oracle ADB |
| LLM | Gemini 2.5 Flash via OpenRouter |
| 알림 | Telegram Bot API |
| 뉴스 | SearXNG (self-hosted) |
| 프로세스 | PM2 |
## 실행
```bash
# 환경 설정
cp .env.example .env
# .env에 API 키 입력
# PM2로 실행
pm2 start ecosystem.config.js
# 개별 실행
.venv/bin/python3 daemons/tick_trader.py
```
## PM2 데몬
| 이름 | 파일 | 설명 |
|------|------|------|
| tick-trader | `daemons/tick_trader.py` | 주력 트레이더 |
| tick-collector | `daemons/tick_collector.py` | 가격 데이터 수집 |
| context-collector | `daemons/context_collector.py` | 종목 컨텍스트 수집 |
| state-sync | `daemons/state_sync.py` | 포지션 상태 동기화 |
## 백테스트 결과 (2026-03-01~06)
| 항목 | 값 |
|------|------|
| 거래 수 | 48건 |
| 승률 | 52.1% |
| 총 PNL | +17,868원 (+17.9%) |
| 평균 PNL | +1.22% |
## 주의사항
- `.env` 변경 후 PM2 재시작 시 `pm2 restart --update-env` 필수
- 로그: `/tmp/tick_trader.log`

236
STRATEGY.md Normal file
View File

@@ -0,0 +1,236 @@
# upbit-trader 전략 가이드
## 시스템 개요
| 데몬 | 전략 | 상태 |
|------|------|------|
| `tick-trader` | WebSocket 20초봉 + LLM 매수 + 트레일링 청산 | **운용 중** |
| `upbit-trader` | 10분봉 Volume Lead 매집 전략 | 중지 (2026-03-06~) |
---
## 1. tick-trader (WebSocket 20초봉)
### 1.1 아키텍처
```
WebSocket (Upbit trade tick)
-> 20초봉 집계 (on_tick -> finalize_bars)
-> 시그널 감지 (양봉 + VOL >= 5x + 사전 필터 3종)
-> LLM 매수 판단 (get_entry_price)
-> 지정가 매수 (현재가)
-> 트레일링 스탑 / 손절 / 타임아웃 청산
```
### 1.2 감시 종목 (10개)
```
ETH, XRP, SOL, DOGE, SIGN, BARD, KITE, CFG, SXP, ARDR
```
### 1.3 진입 조건
**시그널 감지** -- 20초봉 확정 시 다음 조건 동시 충족:
| 순서 | 조건 | 파라미터 | 값 |
|------|------|----------|------|
| 1 | 양봉 (close > open) | -- | 필수 |
| 2 | 거래량 >= 이전 61봉 평균 x N배 (trimmed mean, 상위 10% 제거) | `VOL_MIN` | 5.0x |
| 3 | 20초봉 거래대금 >= 하한 | `VOL_KRW_MIN` | 5,000,000원 |
| 4 | 횡보 필터: 최근 15봉 변동폭 >= 0.3% | `SPREAD_MIN` | 0.3% |
| 5 | 고점 필터: 30분 구간 내 90%+ 위치 & 변동 1%+ 아닐 것 | `HIGHPOS` | 90% / 1.0% |
| 6 | 연속 양봉 필터: 직전 2봉 이상 연속 양봉 | -- | >= 2 |
**LLM 매수 판단** -- 사전 필터 통과 후 LLM에게 매수 여부 위임:
- LLM이 DB Tool로 시장 데이터 조회 후 `buy` / `skip` 판단
- `buy` 시 현재가로 지정가 매수 (LLM은 가격 결정 안 함)
- `skip` 시 텔레그램 알림 + 사유 기록
- 과거 연패/승률은 고려하지 않도록 프롬프팅 (get_trade_history 제거)
**중복/한도 방지**:
- 이미 보유(`positions`) 또는 매수대기(`pending_buys`) 종목은 스킵
- LLM 호출 전/후 포지션 한도(`MAX_POS`) 이중 체크
- 예산 체크: MAX_BUDGET - (보유 투자금 + 미체결 투자금)
- 미체결 180초 초과 시 자동 취소
### 1.4 청산: 트레일링 스탑
LLM 매도는 제거됨. 규칙 기반 트레일링 스탑으로 청산.
| 조건 | 파라미터 | 값 | 설명 |
|------|----------|------|------|
| 트레일링 스탑 | `TRAIL_PCT` | -1.5% | 고점 대비 하락 시 시장가 청산 |
| 최소 수익 | `MIN_PROFIT_PCT` | +0.5% | 트레일 발동 최소 수익률 |
| 손절 | `STOP_LOSS_PCT` | -2.0% | 진입가 대비 -2% 시 시장가 청산 |
| 타임아웃 | `TIMEOUT_SECS` | 14,400초 (4h) | 경과 시 시장가 청산 |
- 실시간 tick마다 peak 갱신 + 손절/트레일 체크 (`update_positions`)
- 20초봉 확정 시에도 체크 (`check_filled_positions`)
### 1.5 LLM 어드바이저
**모델**: Google Gemini 2.5 Flash (OpenRouter API)
**비용**: ~5원/일 (~150원/월) -- 매도 LLM 제거 + 사전 필터로 대폭 절감
**DB Tool 4개** (매수 판단용):
| Tool | 데이터 소스 | 용도 |
|------|-----------|------|
| `get_price_ticks` | Oracle `price_tick` | 최근 N분 가격 틱 (단기 추세) |
| `get_ohlcv` | Oracle `backtest_ohlcv` | 1분봉 OHLCV (지지/저항) |
| `get_ticker_context` | Oracle `ticker_context` | 종목 평판 (가격 변동, 뉴스) |
| `get_btc_trend` | Oracle `backtest_ohlcv` | BTC 4시간봉 추세 |
**최적화**:
- Tool call 중복 제거: 동일 tool+args 호출 시 캐시된 결과 반환
- max_rounds=5: 최대 5라운드 tool calling 후 강제 응답
### 1.6 재시작 복구
PM2 재시작 시 `restore_positions()`:
1. Upbit `get_balances()`로 보유 종목 조회 (balance + locked)
2. 포지션 복구 (트레일링 스탑 모드)
3. 미체결 매수 주문도 `pending_buys`에 복구
4. `entry_ts` 백데이팅으로 즉시 활성화
### 1.7 가격 표시
`fp()` 헬퍼로 가격대별 소수점 자동 조정:
- >= 100원: 정수 (예: 106,177,000)
- >= 10원: 소수 1자리 (예: 47.5)
- < 10원: 소수 2자리 (예: 0.85)
---
## 2. upbit-trader (10분봉 Volume Lead) [중지됨]
> 중지 사유: tick-trader와 동일 계좌에서 동시 운용 시 예산 초과 문제
- 10분봉 Volume Lead 매집 전략
- 횡보 중 거래량 급증 -> 신호 기록 -> +4.8% 상승 확인 후 진입
- F&G 필터: <=40->6x / 41~50->5x / >50->차단
- ATR 트레일링 스탑 + 타임스탑(8h)
- WF 필터: Oracle DB 영속 저장
---
## 3. 공통 인프라
### 3.1 데이터 수집 데몬
| 데몬 | 주기 | 역할 |
|------|------|------|
| `tick-collector` | 30초 | `price_tick` 30초봉 + `backtest_ohlcv` 1분봉 Oracle 저장 |
| `context-collector` | 1시간 | 종목별 `ticker_context` (가격 통계 + SearXNG 뉴스) |
### 3.2 기술 스택
| 구성 | 기술 |
|------|------|
| 거래소 | Upbit API (REST + WebSocket) |
| DB | Oracle ADB (price_tick, backtest_ohlcv, ticker_context, trade_log, wf_state, position_sync) |
| LLM | Google Gemini 2.5 Flash via OpenRouter |
| 알림 | Telegram Bot API |
| 뉴스 | SearXNG (self-hosted) |
| 프로세스 | PM2 (tick-trader, tick-collector, context-collector) |
### 3.3 예산 관리
| 파라미터 | 값 | 설명 |
|----------|------|------|
| `MAX_BUDGET` | 1,000,000원 | 총 운용 예산 |
| `MAX_POSITIONS` | 5 | 최대 동시 보유 종목 |
| 종목당 예산 | 200,000원 | `MAX_BUDGET / MAX_POSITIONS` |
---
## 4. 프로젝트 구조
### 프로덕션
| 파일 | 역할 |
|------|------|
| `daemons/tick_trader.py` | WebSocket 20초봉 트레이더 (LLM 매수 + 트레일링 청산) |
| `daemons/tick_collector.py` | price_tick + 1분봉 수집 |
| `daemons/context_collector.py` | 종목 컨텍스트 수집 (뉴스 + 가격 통계) |
| `core/llm_advisor.py` | OpenRouter LLM 매수 어드바이저 (tool calling) |
| `core/notify.py` | 텔레그램 알림 |
### 설정
| 파일 | 역할 |
|------|------|
| `.env` | API 키, 전략 파라미터, DB 설정 |
| `ecosystem.config.js` | PM2 프로세스 설정 |
### 백테스트
| 파일 | 역할 |
|------|------|
| `backtest_march.py` | 3월 1분봉 시뮬레이션 (Oracle DB 데이터) |
---
## 5. 운용 설정 (.env)
```env
# 총 운용 예산 / 최대 동시 보유 종목
MAX_BUDGET=1000000
MAX_POSITIONS=5
# LLM (OpenRouter)
OPENROUTER_API_KEY=sk-or-v1-...
LLM_MODEL=google/gemini-2.5-flash
# Oracle ADB
ORACLE_USER=admin
ORACLE_PASSWORD=...
ORACLE_DSN=...
ORACLE_WALLET=...
# Telegram
TELEGRAM_TRADE_TOKEN=...
TELEGRAM_CHAT_ID=...
```
---
## 6. 백테스트 결과
### 3월 시뮬레이션 (1분봉, 연속양봉 필터 적용)
> 기간: 2026-03-01 ~ 2026-03-06 / 10종목 / MAX_POS=3
| 항목 | 필터 전 | 연속양봉 >= 2 적용 |
|------|---------|-------------------|
| 시그널 | 112건 | 48건 (-57%) |
| 승률 | 38.4% | **52.1%** |
| 총 PNL | +11,530원 | **+17,868원** |
| 평균 PNL | +0.13% | **+1.22%** |
| 손절 | 31건 | 12건 |
| 트레일 익절 | 13건 | 18건 |
---
## 7. 변경 이력
| 날짜 | 변경 내용 |
|------|---------|
| 2026-03-06 | 연속 양봉 >= 2 필터 추가 (승률 38% -> 52%) |
| 2026-03-06 | 예산 변경: 100K/3pos -> 1M/5pos (종목당 200K) |
| 2026-03-06 | LLM 매도 제거 -> 트레일링 스탑 전환 (TRAIL -1.5%, SL -2%, 4h timeout) |
| 2026-03-06 | cascade 매도 제거 |
| 2026-03-06 | 사전 필터 3종 추가 (횡보/고점/연속양봉) -> LLM 호출 ~57% 절감 |
| 2026-03-06 | 종목 30개 -> 10개 축소, BTC 제외 |
| 2026-03-06 | 현재가 매수 (LLM 가격 제안 제거) |
| 2026-03-06 | LLM 매수 프롬프트: 연패 무시, get_trade_history 제거 |
| 2026-03-06 | VOL_KRW_MIN: 2M -> 5M, BUY_TIMEOUT: 60 -> 180초 |
| 2026-03-06 | 예산 체크: MAX_BUDGET - 투자금 합계 방식 |
| 2026-03-06 | `_round_price` 호가 단위 수정: 10만~50만 구간 50->100원 |
| 2026-03-06 | 매도 주문 실패 시 자동 재시도 |
| 2026-03-06 | upbit-trader (보조 봇) 중지 |
| 2026-03-05 | `tick-trader` LLM-driven 매수/매도 전환, cascade fallback 구현 |
| 2026-03-05 | LLM 모델 Claude Haiku 4.5 -> Gemini 2.5 Flash (비용 7.5x 절감) |
| 2026-03-05 | `restore_positions()`: 잔고 기반 포지션 복구 |
| 2026-03-05 | 포지션 한도 초과 방지 3중 체크 |

71
archive/core/fng.py Normal file
View File

@@ -0,0 +1,71 @@
"""공포탐욕지수(F&G) 조회 모듈.
alternative.me API로 일일 F&G 값을 가져와 메모리에 캐시한다.
캐시 TTL은 24시간 (F&G는 하루 1회 KST 09:00 업데이트).
환경변수:
FNG_MIN_ENTRY (기본값 41): 이 값 미만이면 진입 차단
"""
from __future__ import annotations
import json
import logging
import os
import time
import urllib.request
from datetime import datetime
logger = logging.getLogger(__name__)
FNG_MIN_ENTRY = int(os.getenv("FNG_MIN_ENTRY", "41")) # 진입 허용 최소 F&G 값
_FNG_API_URL = "https://api.alternative.me/fng/?limit=1&format=json"
_CACHE_TTL = 86400 # 24시간 (API는 하루 1회 KST 09:00 업데이트)
_fng_value: int | None = None
_fng_cached_at: float = 0.0
_fng_date_str: str = ""
def get_fng() -> int:
"""오늘의 F&G 지수 반환 (0~100). API 실패 시 50(중립) 반환."""
global _fng_value, _fng_cached_at, _fng_date_str
now = time.time()
if _fng_value is not None and (now - _fng_cached_at) < _CACHE_TTL:
return _fng_value
try:
with urllib.request.urlopen(_FNG_API_URL, timeout=5) as r:
data = json.loads(r.read())
entry = data["data"][0]
_fng_value = int(entry["value"])
_fng_cached_at = now
_fng_date_str = entry.get("timestamp", "")
logger.info(
f"[F&G] 지수={_fng_value} ({entry.get('value_classification','')}) "
f"날짜={datetime.fromtimestamp(int(_fng_date_str)).strftime('%Y-%m-%d') if _fng_date_str else '?'}"
)
except Exception as e:
logger.warning(f"[F&G] API 조회 실패: {e} → 캐시/중립값 사용")
if _fng_value is None:
_fng_value = 50 # 폴백: 중립
return _fng_value # type: ignore[return-value]
def is_entry_allowed() -> bool:
"""현재 F&G 기준으로 진입 허용 여부 반환.
F&G ≥ FNG_MIN_ENTRY(41) 이면 True.
극공포/공포 구간(< 41)이면 False → 진입 차단.
"""
fv = get_fng()
allowed = fv >= FNG_MIN_ENTRY
if not allowed:
label = (
"극공포" if fv <= 25 else
"공포" if fv <= 40 else
"약공포"
)
logger.debug(f"[F&G] 진입 차단 — F&G={fv} ({label}) < {FNG_MIN_ENTRY}")
return allowed

View File

@@ -29,15 +29,27 @@ def get_top_tickers() -> list[str]:
if not all_tickers:
return []
# 100개씩 나눠서 조회 (URL 길이 제한)
# 100개씩 나눠서 조회 (URL 길이 제한, 429 재시도 포함)
chunk_size = 100
ticker_data = []
for i in range(0, len(all_tickers), chunk_size):
chunk = all_tickers[i:i + chunk_size]
params = {"markets": ",".join(chunk)}
resp = requests.get(_TICKER_URL, params=params, timeout=5)
resp.raise_for_status()
ticker_data.extend(resp.json())
for attempt in range(3):
try:
resp = requests.get(_TICKER_URL, params=params, timeout=5)
if resp.status_code == 429:
wait = 2 ** attempt # 1s → 2s → 4s
logger.warning(f"429 Rate Limit, {wait}s 대기 후 재시도 ({attempt+1}/3)")
time.sleep(wait)
continue
resp.raise_for_status()
ticker_data.extend(resp.json())
break
except Exception as e:
if attempt == 2:
raise
time.sleep(1)
# 스테이블코인 제외
EXCLUDE = {"KRW-USDT", "KRW-USDC", "KRW-DAI", "KRW-BUSD"}

View File

@@ -0,0 +1,110 @@
"""시장 레짐(Bull/Neutral/Bear) 판단.
BTC·ETH·SOL·XRP 가중 평균 2h 추세로 레짐을 결정하고
매수 조건 파라미터(trend_pct, vol_mult)를 동적으로 반환한다.
계산된 현재가는 price_history DB에 저장해 재활용한다.
"""
from __future__ import annotations
import logging
import time
import pyupbit
from .price_db import get_price_n_hours_ago, insert_prices
logger = logging.getLogger(__name__)
# 대장 코인 가중치
LEADERS: dict[str, float] = {
"KRW-BTC": 0.40,
"KRW-ETH": 0.30,
"KRW-SOL": 0.15,
"KRW-XRP": 0.15,
}
TREND_HOURS = 2 # 2h 추세 기준
BULL_THRESHOLD = 1.5 # score ≥ 1.5% → Bull
BEAR_THRESHOLD = -0.5 # score < -0.5% → Bear
# 레짐별 매수 조건 파라미터
REGIME_PARAMS: dict[str, dict] = {
"bull": {"trend_pct": 3.0, "vol_mult": 1.5, "emoji": "🟢"},
"neutral": {"trend_pct": 5.0, "vol_mult": 2.0, "emoji": "🟡"},
"bear": {"trend_pct": 8.0, "vol_mult": 3.5, "emoji": "🔴"},
}
# 10분 캐시 (스캔 루프마다 API 호출 방지)
_cache: dict = {}
_cache_ts: float = 0.0
_CACHE_TTL = 600
def get_regime() -> dict:
"""현재 시장 레짐 반환.
Returns:
{
'name': 'bull' | 'neutral' | 'bear',
'score': float, # 가중 평균 2h 추세(%)
'trend_pct': float, # 매수 추세 임계값
'vol_mult': float, # 거래량 배수 임계값
'emoji': str,
}
"""
global _cache, _cache_ts
if _cache and (time.time() - _cache_ts) < _CACHE_TTL:
return _cache
score = 0.0
current_prices: dict[str, float] = {}
for ticker, weight in LEADERS.items():
try:
current = pyupbit.get_current_price(ticker)
if not current:
continue
current_prices[ticker] = current
# DB에서 2h 전 가격 조회 → 없으면 API 캔들로 대체
past = get_price_n_hours_ago(ticker, TREND_HOURS)
if past is None:
df = pyupbit.get_ohlcv(ticker, interval="minute60", count=4)
if df is not None and len(df) >= 3:
past = float(df["close"].iloc[-3])
if past:
trend = (current - past) / past * 100
score += trend * weight
logger.debug(f"[레짐] {ticker} {trend:+.2f}% (기여 {trend*weight:+.3f})")
except Exception as e:
logger.warning(f"[레짐] {ticker} 오류: {e}")
# 현재가 DB 저장 (다음 레짐 계산 및 추세 판단에 재활용)
if current_prices:
try:
insert_prices(current_prices)
except Exception as e:
logger.warning(f"[레짐] 가격 저장 오류: {e}")
# 레짐 결정
if score >= BULL_THRESHOLD:
name = "bull"
elif score < BEAR_THRESHOLD:
name = "bear"
else:
name = "neutral"
params = REGIME_PARAMS[name]
result = {"name": name, "score": round(score, 3), **params}
logger.info(
f"[레짐] score={score:+.3f}% → {params['emoji']} {name.upper()} "
f"(TREND≥{params['trend_pct']}% / VOL≥{params['vol_mult']}x)"
)
_cache = result
_cache_ts = time.time()
return result

222
archive/core/monitor.py Normal file
View File

@@ -0,0 +1,222 @@
"""트레일링 스탑 + 타임 스탑 감시 - 백그라운드 스레드에서 실행."""
import logging
import os
import time
from datetime import datetime
import pyupbit
from .market import get_current_price
from . import trader
logger = logging.getLogger(__name__)
CHECK_INTERVAL = 10 # 10초마다 체크
# 타임 스탑: N시간 경과 후 수익률이 M% 미만이면 청산
TIME_STOP_HOURS = float(os.getenv("TIME_STOP_HOURS", "24"))
TIME_STOP_MIN_GAIN_PCT = float(os.getenv("TIME_STOP_MIN_GAIN_PCT", "3"))
# ATR 기반 적응형 트레일링 스탑 파라미터
ATR_CANDLES = 7 # 최근 N개 40분봉으로 자연 진폭 계산 (≈5h, int(5*60/40)=7)
ATR_MULT = 1.5 # 평균 진폭 × 배수 = 스탑 임계값
ATR_MIN_STOP = 0.010 # 최소 스탑 1.0% (너무 좁아지는 거 방지)
ATR_MAX_STOP = 0.020 # 최대 스탑 2.0% (너무 넓어지는 거 방지)
# ATR 캐시: 종목별 (스탑비율, 계산시각) — 40분마다 갱신
_atr_cache: dict[str, tuple[float, float]] = {}
_ATR_CACHE_TTL = 2400 # 40분
def _resample_40m(df):
"""minute10 DataFrame → 40분봉으로 리샘플링."""
return (
df.resample("40min")
.agg({"open": "first", "high": "max", "low": "min", "close": "last", "volume": "sum"})
.dropna(subset=["close"])
)
def _get_adaptive_stop(ticker: str) -> float:
"""최근 ATR_CANDLES개 40분봉 평균 진폭 × ATR_MULT 로 적응형 스탑 비율 반환.
캐시(40분)를 활용해 API 호출 최소화.
계산 실패 시 ATR_MIN_STOP 반환.
"""
now = time.time()
cached = _atr_cache.get(ticker)
if cached and (now - cached[1]) < _ATR_CACHE_TTL:
return cached[0]
try:
fetch_n = (ATR_CANDLES + 2) * 4 # 40분봉 N개 = 10분봉 N*4개
df10 = pyupbit.get_ohlcv(ticker, interval="minute10", count=fetch_n)
if df10 is None or df10.empty:
return ATR_MIN_STOP
df = _resample_40m(df10)
if len(df) < ATR_CANDLES:
return ATR_MIN_STOP
ranges = (df["high"] - df["low"]) / df["low"]
avg_range = ranges.iloc[-ATR_CANDLES:].mean()
stop = float(max(ATR_MIN_STOP, min(ATR_MAX_STOP, avg_range * ATR_MULT)))
except Exception as e:
logger.debug(f"[ATR] {ticker} 계산 실패: {e}")
stop = ATR_MIN_STOP
_atr_cache[ticker] = (stop, now)
return stop
def _check_trailing_stop(ticker: str, pos: dict, current: float) -> bool:
"""적응형 트레일링 스탑(최고가 기준) + 고정 스탑(매수가 기준) 체크."""
trader.update_peak(ticker, current)
pos = trader.get_positions().get(ticker)
if pos is None:
return False
peak = pos["peak_price"]
buy_price = pos["buy_price"]
stop_pct = _get_adaptive_stop(ticker)
drop_from_peak = (peak - current) / peak
drop_from_buy = (buy_price - current) / buy_price
if drop_from_peak >= stop_pct:
reason = (
f"트레일링스탑 | 최고가={peak:,.2f}원 → "
f"현재={current:,.2f}원 ({drop_from_peak:.2%} 하락 | 스탑={stop_pct:.2%})"
)
return trader.sell(ticker, reason=reason)
if drop_from_buy >= stop_pct:
reason = (
f"스탑로스 | 매수가={buy_price:,.2f}원 → "
f"현재={current:,.2f}원 ({drop_from_buy:.2%} 하락 | 스탑={stop_pct:.2%})"
)
return trader.sell(ticker, reason=reason)
return False
def _check_time_stop(ticker: str, pos: dict, current: float) -> bool:
"""타임 스탑 체크. 매도 시 True 반환.
조건: 보유 후 TIME_STOP_HOURS 경과 AND 수익률 < TIME_STOP_MIN_GAIN_PCT%
"""
entry_time = pos.get("entry_time")
if entry_time is None:
return False
elapsed_hours = (datetime.now() - entry_time).total_seconds() / 3600
if elapsed_hours < TIME_STOP_HOURS:
return False
pnl_pct = (current - pos["buy_price"]) / pos["buy_price"] * 100
if pnl_pct >= TIME_STOP_MIN_GAIN_PCT:
return False
reason = (
f"타임스탑 | {elapsed_hours:.2f}시간 경과 후 "
f"수익률={pnl_pct:+.2f}% (기준={TIME_STOP_MIN_GAIN_PCT:+.2f}% 미달)"
)
trader.sell(ticker, reason=reason)
return True
def _check_shadow_position(ticker: str, spos: dict) -> None:
"""Shadow 포지션 청산 조건 체크 (트레일링 + 타임 스탑).
실제 포지션과 동일한 로직을 적용하되 주문 없이 결과만 기록.
"""
current = get_current_price(ticker)
if current is None:
return
trader.update_shadow_peak(ticker, current)
# 갱신 후 최신 값 재조회
spos = trader.get_shadow_positions().get(ticker)
if spos is None:
return
buy_price = spos["buy_price"]
peak = spos["peak_price"]
entry_time = spos["entry_time"]
stop_pct = _get_adaptive_stop(ticker)
drop_from_peak = (peak - current) / peak
elapsed_hours = (datetime.now() - entry_time).total_seconds() / 3600
pnl_pct = (current - buy_price) / buy_price * 100
reason = None
if drop_from_peak >= stop_pct:
reason = (
f"트레일링스탑 | 최고={peak:,.2f}→현재={current:,.2f}"
f" ({drop_from_peak:.2%} | 스탑={stop_pct:.2%})"
)
elif elapsed_hours >= TIME_STOP_HOURS and pnl_pct < TIME_STOP_MIN_GAIN_PCT:
reason = (
f"타임스탑 | {elapsed_hours:.1f}h 경과 "
f"수익률={pnl_pct:+.2f}% (기준={TIME_STOP_MIN_GAIN_PCT:+.2f}%)"
)
if reason:
trader.close_shadow(ticker, current, pnl_pct, reason)
def _check_position(ticker: str, pos: dict) -> None:
"""단일 포지션 전체 체크 (트레일링 스탑 → 타임 스탑 순서)."""
current = get_current_price(ticker)
if current is None:
return
buy_price = pos["buy_price"]
pnl = (current - buy_price) / buy_price * 100
peak = pos["peak_price"]
drop_from_peak = (peak - current) / peak
drop_from_buy = (buy_price - current) / buy_price
stop_pct = _get_adaptive_stop(ticker)
entry_time = pos.get("entry_time", datetime.now())
elapsed_hours = (datetime.now() - entry_time).total_seconds() / 3600
logger.info(
f"[감시] {ticker} 현재={current:,.2f} | 매수가={buy_price:,.2f} | 최고={peak:,.2f} | "
f"수익률={pnl:+.2f}% | peak하락={drop_from_peak:.2%} | buy하락={drop_from_buy:.2%} | "
f"스탑={stop_pct:.2%} | 보유={elapsed_hours:.2f}h"
)
# 1순위: 적응형 트레일링 스탑
if _check_trailing_stop(ticker, pos, current):
return
# 2순위: 타임 스탑
_check_time_stop(ticker, pos, current)
def run_monitor(interval: int = CHECK_INTERVAL) -> None:
"""전체 포지션 감시 루프."""
logger.info(
f"모니터 시작 | 체크={interval}초 | ATR×{ATR_MULT} "
f"(최소={ATR_MIN_STOP:.2%} / 최대={ATR_MAX_STOP:.2%}) | "
f"타임스탑={TIME_STOP_HOURS:.0f}h/{TIME_STOP_MIN_GAIN_PCT:+.2f}%"
)
while True:
# 실제 포지션 감시
positions_snapshot = dict(trader.get_positions())
for ticker, pos in positions_snapshot.items():
try:
_check_position(ticker, pos)
except Exception as e:
logger.error(f"모니터 오류 {ticker}: {e}")
# Shadow 포지션 감시 (WF차단 종목 재활 추적)
shadow_snapshot = trader.get_shadow_positions()
for ticker, spos in shadow_snapshot.items():
try:
_check_shadow_position(ticker, spos)
except Exception as e:
logger.error(f"Shadow 모니터 오류 {ticker}: {e}")
time.sleep(interval)

View File

@@ -0,0 +1,94 @@
"""10분마다 상위 종목 현재가를 Oracle DB에 저장하는 수집기."""
from __future__ import annotations
import logging
import time
import pyupbit
import requests
from .market import get_top_tickers
from .market_regime import LEADERS
from .price_db import cleanup_old_prices, insert_prices, insert_prices_with_time
logger = logging.getLogger(__name__)
COLLECT_INTERVAL = 600 # 10분 (초)
CLEANUP_EVERY = 6 # 1시간(10분 × 6)마다 오래된 데이터 정리
def backfill_prices(hours: int = 48) -> None:
"""시작 시 과거 N시간치 1시간봉 종가를 DB에 백필.
price_history에 데이터가 없으면 추세 판단이 불가능하므로
봇 시작 직후 한 번 호출해 과거 데이터를 채운다.
"""
tickers = get_top_tickers()
if not tickers:
logger.warning("[백필] 종목 목록 없음, 스킵")
return
# 대장 코인 항상 포함
for leader in LEADERS:
if leader not in tickers:
tickers = tickers + [leader]
count = hours + 2 # 여유 있게 요청
total_rows = 0
for ticker in tickers:
try:
df = pyupbit.get_ohlcv(ticker, interval="minute60", count=count)
if df is None or df.empty:
continue
rows = [
(ticker, float(row["close"]), ts.to_pydatetime())
for ts, row in df.iterrows()
]
insert_prices_with_time(rows)
total_rows += len(rows)
time.sleep(0.1)
except Exception as e:
logger.error(f"[백필] {ticker} 오류: {e}")
logger.info(f"[백필] 완료 — {len(tickers)}개 종목 / {total_rows}개 레코드 저장")
def run_collector(interval: int = COLLECT_INTERVAL) -> None:
"""가격 수집 루프."""
logger.info(f"가격 수집기 시작 (주기={interval//60}분)")
time.sleep(30) # 스캐너와 동시 API 호출 방지
cycle = 0
while True:
try:
tickers = get_top_tickers()
if not tickers:
continue
# 대장 코인은 top20 밖이어도 항상 포함
for leader in LEADERS:
if leader not in tickers:
tickers = tickers + [leader]
resp = requests.get(
"https://api.upbit.com/v1/ticker",
params={"markets": ",".join(tickers)},
timeout=5,
)
resp.raise_for_status()
data = resp.json()
valid = {
item["market"]: item["trade_price"]
for item in data
if item.get("trade_price")
}
insert_prices(valid)
logger.info(f"[수집] {len(valid)}개 종목 가격 저장")
cycle += 1
if cycle % CLEANUP_EVERY == 0:
cleanup_old_prices(keep_hours=48)
logger.info("오래된 가격 데이터 정리 완료")
except Exception as e:
logger.error(f"가격 수집 오류: {e}")
time.sleep(interval)

380
archive/core/price_db.py Normal file
View File

@@ -0,0 +1,380 @@
"""Oracle ADB price_history CRUD."""
from __future__ import annotations
import os
from contextlib import contextmanager
from typing import Generator, Optional
import oracledb
_pool: Optional[oracledb.ConnectionPool] = None
def _get_pool() -> oracledb.ConnectionPool:
global _pool
if _pool is None:
kwargs: dict = dict(
user=os.environ["ORACLE_USER"],
password=os.environ["ORACLE_PASSWORD"],
dsn=os.environ["ORACLE_DSN"],
min=1,
max=3,
increment=1,
)
wallet = os.environ.get("ORACLE_WALLET")
if wallet:
kwargs["config_dir"] = wallet
_pool = oracledb.create_pool(**kwargs)
return _pool
@contextmanager
def _conn() -> Generator[oracledb.Connection, None, None]:
pool = _get_pool()
conn = pool.acquire()
try:
yield conn
conn.commit()
except Exception:
conn.rollback()
raise
finally:
pool.release(conn)
def insert_prices(ticker_prices: dict[str, float]) -> None:
"""여러 종목의 현재가를 한 번에 저장 (recorded_at = 현재 시각)."""
if not ticker_prices:
return
rows = [(ticker, price) for ticker, price in ticker_prices.items()]
sql = "INSERT INTO price_history (ticker, price) VALUES (:1, :2)"
with _conn() as conn:
conn.cursor().executemany(sql, rows)
def insert_prices_with_time(rows: list[tuple]) -> None:
"""(ticker, price, recorded_at) 튜플 리스트를 한 번에 저장 (백필용)."""
if not rows:
return
sql = """
INSERT INTO price_history (ticker, price, recorded_at)
VALUES (:1, :2, :3)
"""
with _conn() as conn:
conn.cursor().executemany(sql, rows)
def get_price_n_hours_ago(ticker: str, hours: float) -> Optional[float]:
"""N시간 전 가장 가까운 가격 반환. 데이터 없으면 None."""
sql = """
SELECT price FROM price_history
WHERE ticker = :ticker
AND recorded_at BETWEEN
SYSTIMESTAMP - INTERVAL ':h' HOUR - INTERVAL '10' MINUTE
AND SYSTIMESTAMP - INTERVAL ':h' HOUR + INTERVAL '10' MINUTE
ORDER BY ABS(CAST(recorded_at AS DATE) -
CAST(SYSTIMESTAMP - INTERVAL ':h' HOUR AS DATE))
FETCH FIRST 1 ROWS ONLY
"""
# Oracle INTERVAL bind param 미지원으로 직접 포맷
h = int(hours)
sql = f"""
SELECT price FROM price_history
WHERE ticker = :ticker
AND recorded_at BETWEEN
SYSTIMESTAMP - ({h}/24) - (10/1440)
AND SYSTIMESTAMP - ({h}/24) + (10/1440)
ORDER BY ABS(CAST(recorded_at AS DATE) -
CAST(SYSTIMESTAMP - ({h}/24) AS DATE))
FETCH FIRST 1 ROWS ONLY
"""
with _conn() as conn:
cursor = conn.cursor()
cursor.execute(sql, {"ticker": ticker})
row = cursor.fetchone()
return float(row[0]) if row else None
def cleanup_old_prices(keep_hours: int = 48) -> None:
"""N시간 이상 오래된 데이터 삭제 (DB 용량 관리)."""
sql = f"DELETE FROM price_history WHERE recorded_at < SYSTIMESTAMP - ({keep_hours}/24)"
with _conn() as conn:
conn.cursor().execute(sql)
# ── 포지션 영구 저장 (재시작 후 실제 매수가 복원용) ──────────────────────────
def upsert_position(
ticker: str,
buy_price: float,
peak_price: float,
amount: float,
invested_krw: int,
entry_time: str, # ISO 포맷 문자열
trade_id: str = "",
) -> None:
"""포지션 저장 또는 갱신 (MERGE)."""
sql = """
MERGE INTO positions p
USING (SELECT :ticker AS ticker FROM dual) s
ON (p.ticker = s.ticker)
WHEN MATCHED THEN
UPDATE SET peak_price = :peak_price,
amount = :amount,
invested_krw = :invested_krw,
updated_at = SYSTIMESTAMP
WHEN NOT MATCHED THEN
INSERT (ticker, buy_price, peak_price, amount, invested_krw, entry_time, trade_id)
VALUES (:ticker, :buy_price, :peak_price, :amount, :invested_krw,
TO_TIMESTAMP(:entry_time, 'YYYY-MM-DD"T"HH24:MI:SS.FF6'), :trade_id)
"""
with _conn() as conn:
conn.cursor().execute(sql, {
"ticker": ticker,
"buy_price": buy_price,
"peak_price": peak_price,
"amount": amount,
"invested_krw": invested_krw,
"entry_time": entry_time,
"trade_id": trade_id,
})
def delete_position(ticker: str) -> None:
"""포지션 삭제 (매도 완료 시)."""
with _conn() as conn:
conn.cursor().execute(
"DELETE FROM positions WHERE ticker = :ticker", {"ticker": ticker}
)
# ── Walk-forward 거래 이력 ────────────────────────────────────────────────────
def ensure_trade_results_table() -> None:
"""trade_results 테이블이 없으면 생성."""
ddl = """
CREATE TABLE trade_results (
id NUMBER GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
ticker VARCHAR2(20) NOT NULL,
is_win NUMBER(1) NOT NULL,
pnl_pct NUMBER(10,4),
traded_at TIMESTAMP DEFAULT SYSTIMESTAMP NOT NULL
)
"""
idx = "CREATE INDEX idx_tr_ticker ON trade_results (ticker, traded_at DESC)"
with _conn() as conn:
for sql in (ddl, idx):
try:
conn.cursor().execute(sql)
except oracledb.DatabaseError as e:
if e.args[0].code not in (955, 1408):
raise
# sell_reason 컬럼이 100 BYTE 이하이면 500으로 확장
try:
conn.cursor().execute(
"ALTER TABLE trade_results MODIFY sell_reason VARCHAR2(500)"
)
except oracledb.DatabaseError:
pass # 이미 500 이상이거나 컬럼 없으면 무시
def record_trade(
ticker: str,
is_win: bool,
pnl_pct: float,
fee_krw: float = 0.0,
krw_profit: float = 0.0,
trade_id: str = "",
buy_price: float = 0.0,
sell_price: float = 0.0,
invested_krw: int = 0,
sell_reason: str = "",
) -> None:
"""거래 결과 저장 (수수료·KRW 손익·trade_id 포함)."""
with _conn() as conn:
conn.cursor().execute(
"INSERT INTO trade_results "
"(ticker, is_win, pnl_pct, fee_krw, krw_profit, "
" trade_id, buy_price, sell_price, invested_krw, sell_reason) "
"VALUES (:t, :w, :p, :f, :k, :tid, :bp, :sp, :ikrw, :sr)",
{
"t": ticker,
"w": 1 if is_win else 0,
"p": round(pnl_pct, 4),
"f": round(fee_krw, 2),
"k": round(krw_profit, 2),
"tid": trade_id,
"bp": buy_price,
"sp": sell_price,
"ikrw": invested_krw,
"sr": sell_reason,
},
)
def get_cumulative_krw_profit() -> float:
"""전체 거래 누적 KRW 손익 반환 (수수료 차감 후). 데이터 없으면 0."""
with _conn() as conn:
cur = conn.cursor()
cur.execute("SELECT SUM(krw_profit) FROM trade_results WHERE krw_profit IS NOT NULL")
row = cur.fetchone()
return float(row[0]) if row and row[0] is not None else 0.0
def load_recent_wins(ticker: str, n: int = 5) -> list[bool]:
"""직전 N건 거래의 승/패 리스트 반환 (오래된 순). 없으면 빈 리스트."""
sql = """
SELECT is_win FROM (
SELECT is_win FROM trade_results
WHERE ticker = :t
ORDER BY traded_at DESC
FETCH FIRST :n ROWS ONLY
) ORDER BY ROWNUM DESC
"""
with _conn() as conn:
cur = conn.cursor()
cur.execute(sql, {"t": ticker, "n": n})
rows = cur.fetchall()
return [bool(r[0]) for r in rows]
# ── 직전 매도가 영구 저장 (재시작 후 재매수 차단 유지용) ──────────────────────
def ensure_sell_prices_table() -> None:
"""sell_prices 테이블이 없으면 생성."""
ddl = """
CREATE TABLE sell_prices (
ticker VARCHAR2(20) NOT NULL PRIMARY KEY,
price NUMBER(20,8) NOT NULL,
updated_at TIMESTAMP DEFAULT SYSTIMESTAMP NOT NULL
)
"""
with _conn() as conn:
try:
conn.cursor().execute(ddl)
except oracledb.DatabaseError as e:
if e.args[0].code != 955: # ORA-00955: 이미 존재
raise
def upsert_sell_price(ticker: str, price: float) -> None:
"""직전 매도가 저장 또는 갱신."""
sql = """
MERGE INTO sell_prices s
USING (SELECT :ticker AS ticker FROM dual) d
ON (s.ticker = d.ticker)
WHEN MATCHED THEN
UPDATE SET price = :price, updated_at = SYSTIMESTAMP
WHEN NOT MATCHED THEN
INSERT (ticker, price) VALUES (:ticker, :price)
"""
with _conn() as conn:
conn.cursor().execute(sql, {"ticker": ticker, "price": price})
def load_sell_prices() -> dict[str, float]:
"""저장된 직전 매도가 전체 로드."""
with _conn() as conn:
cur = conn.cursor()
cur.execute("SELECT ticker, price FROM sell_prices")
return {r[0]: float(r[1]) for r in cur.fetchall()}
def delete_sell_price(ticker: str) -> None:
"""매도가 기록 삭제 (더 이상 필요 없을 때)."""
with _conn() as conn:
conn.cursor().execute(
"DELETE FROM sell_prices WHERE ticker = :ticker", {"ticker": ticker}
)
# ── WF 상태 영구 저장 (재시작 후 shadow 재활 상태 유지) ──────────────────────
def ensure_wf_state_table() -> None:
"""wf_state 테이블이 없으면 생성."""
ddl = """
CREATE TABLE wf_state (
ticker VARCHAR2(20) NOT NULL PRIMARY KEY,
is_blocked NUMBER(1) DEFAULT 0 NOT NULL,
shadow_cons_wins NUMBER DEFAULT 0 NOT NULL,
updated_at TIMESTAMP DEFAULT SYSTIMESTAMP NOT NULL
)
"""
with _conn() as conn:
try:
conn.cursor().execute(ddl)
except oracledb.DatabaseError as e:
if e.args[0].code != 955: # ORA-00955: 이미 존재
raise
def upsert_wf_state(ticker: str, is_blocked: bool, shadow_cons_wins: int) -> None:
"""WF 차단 상태 저장 또는 갱신."""
sql = """
MERGE INTO wf_state w
USING (SELECT :ticker AS ticker FROM dual) d
ON (w.ticker = d.ticker)
WHEN MATCHED THEN
UPDATE SET is_blocked = :is_blocked,
shadow_cons_wins = :shadow_cons_wins,
updated_at = SYSTIMESTAMP
WHEN NOT MATCHED THEN
INSERT (ticker, is_blocked, shadow_cons_wins)
VALUES (:ticker, :is_blocked, :shadow_cons_wins)
"""
with _conn() as conn:
conn.cursor().execute(sql, {
"ticker": ticker,
"is_blocked": 1 if is_blocked else 0,
"shadow_cons_wins": shadow_cons_wins,
})
def load_wf_states() -> dict[str, dict]:
"""저장된 WF 상태 전체 로드.
Returns:
{ticker: {"is_blocked": bool, "shadow_cons_wins": int}}
"""
with _conn() as conn:
cur = conn.cursor()
cur.execute("SELECT ticker, is_blocked, shadow_cons_wins FROM wf_state")
return {
r[0]: {"is_blocked": bool(r[1]), "shadow_cons_wins": int(r[2])}
for r in cur.fetchall()
}
def delete_wf_state(ticker: str) -> None:
"""WF 상태 삭제 (WF 해제 시)."""
with _conn() as conn:
conn.cursor().execute(
"DELETE FROM wf_state WHERE ticker = :ticker", {"ticker": ticker}
)
def load_positions() -> list[dict]:
"""저장된 전체 포지션 로드."""
sql = """
SELECT ticker, buy_price, peak_price, amount, invested_krw,
TO_CHAR(entry_time, 'YYYY-MM-DD"T"HH24:MI:SS.FF6') AS entry_time,
trade_id
FROM positions
"""
with _conn() as conn:
cursor = conn.cursor()
cursor.execute(sql)
rows = cursor.fetchall()
return [
{
"ticker": r[0],
"buy_price": float(r[1]),
"peak_price": float(r[2]),
"amount": float(r[3]),
"invested_krw": int(r[4]),
"entry_time": r[5],
"trade_id": r[6] or "",
}
for r in rows
]

171
archive/core/strategy.py Normal file
View File

@@ -0,0 +1,171 @@
"""Volume Lead 전략: 10분봉 거래량 급증 + 횡보 감지 후 +THRESH% 상승 시 진입.
흐름:
1. 직전 완성 10분봉 거래량 > 로컬 LV봉(280분) 평균 × VOL_THRESH AND
QN봉(120분) 이전 종가 대비 가격 변동 < PRICE_QUIET_PCT% (횡보 중 축적)
→ 신호가(signal_price) + 거래량비율(vol_ratio) 기록
* 더 강한 vol(> 기존 sig vol_ratio)이 오면 sig_p 갱신
2. signal_price 대비 +TREND_AFTER_VOL%(4.8%) 이상 상승 시 진입
3. 신호불사: 가격이 신호가 아래로 내려가도 신호 유지 (sig_p 고정, 만료까지 대기)
4. SIGNAL_TIMEOUT_MIN(480분=8h) 초과 시 신호 초기화
거래량 임계값 + 진입 차단 (F&G 기반 3구간):
- F&G ≤ FNG_FEAR_THRESHOLD(40): VOL_THRESH_FEAR(6.0x) ← 공포/극공포
- F&G 41 ~ FNG_MAX_ENTRY(50): VOL_THRESH_NORMAL(5.0x) ← 중립
- F&G > FNG_MAX_ENTRY(50): 진입 차단 ← 탐욕/극탐욕
캔들: minute10 데이터 직접 사용 (40분봉 리샘플링 없음)
"""
from __future__ import annotations
import logging
import os
import time
import pyupbit
from .fng import get_fng
from .market import get_current_price
from .notify import notify_signal, notify_watch
logger = logging.getLogger(__name__)
# 10분봉 직접 사용 파라미터
LOCAL_VOL_CANDLES = int(os.getenv("LOCAL_VOL_CANDLES", "28")) # 로컬 vol 평균 구간 (280분)
QUIET_CANDLES = int(os.getenv("QUIET_CANDLES", "12")) # 횡보 체크 구간 (120분)
PRICE_QUIET_PCT = float(os.getenv("PRICE_QUIET_PCT", "2.0")) # 횡보 기준 (%)
TREND_AFTER_VOL = float(os.getenv("TREND_AFTER_VOL", "4.8")) # 진입 임계값 (신호가 대비 %)
SIGNAL_TIMEOUT_MIN = int(os.getenv("SIGNAL_TIMEOUT_MIN", "480")) # 신호 유효 시간 (분=8h)
# F&G 기반 거래량 임계값 + 진입 차단
VOL_THRESH_NORMAL = float(os.getenv("VOL_THRESH_NORMAL", "5.0")) # 중립 구간 (F&G 41~FNG_MAX_ENTRY)
VOL_THRESH_FEAR = float(os.getenv("VOL_THRESH_FEAR", "6.0")) # 공포/극공포 (F&G ≤ FNG_FEAR_THRESHOLD)
FNG_FEAR_THRESHOLD = int(os.getenv("FNG_FEAR_THRESHOLD", "40")) # 공포 기준 (이하 → FEAR 임계값)
FNG_MAX_ENTRY = int(os.getenv("FNG_MAX_ENTRY", "50")) # 진입 허용 최대 F&G (초과 → 차단)
# 관찰 알림 (신호 임계값에 근접했지만 미달인 종목)
WATCH_VOL_THRESH = float(os.getenv("WATCH_VOL_THRESH", "4.0")) # 관찰 시작 임계값
WATCH_COOLDOWN_MIN = int(os.getenv("WATCH_COOLDOWN_MIN", "30")) # 같은 종목 재알림 최소 간격 (분)
WATCH_VOL_JUMP = float(os.getenv("WATCH_VOL_JUMP", "0.5")) # 쿨다운 무시 vol 상승폭
# 10분봉 조회 수
_FETCH_10M = LOCAL_VOL_CANDLES + QUIET_CANDLES + 5 # 45봉
# 축적 신호 상태: ticker → {"price": float, "time": float(unix), "vol_ratio": float}
_accum_signals: dict[str, dict] = {}
# 관찰 알림 상태: ticker → {"time": float, "vol_ratio": float}
_watch_notified: dict[str, dict] = {}
def get_active_signals() -> dict[str, dict]:
"""현재 활성화된 신호 딕셔너리 반환 (fast-poll 루프용).
Returns:
{ticker: {"price": float, "time": float, "vol_ratio": float}}
"""
return dict(_accum_signals)
def should_buy(ticker: str) -> bool:
"""Volume Lead 전략 (10분봉 직접 감지).
1단계: F&G 값으로 vol 임계값 동적 설정 (≤40→6x, >40→5x)
2단계: 10분봉 거래량 급증 + QN봉 횡보 → 신호가 기록 (더 강한 vol이면 갱신)
3단계: 신호가 대비 +TREND_AFTER_VOL% 상승 확인 시 진입
신호불사: 가격이 신호가 아래로 내려가도 신호 유지 (sig_p 고정)
"""
fng = get_fng()
# F&G 탐욕/극탐욕 구간 진입 차단
if fng > FNG_MAX_ENTRY:
logger.debug(f"[F&G차단] {ticker} F&G={fng} > {FNG_MAX_ENTRY} (탐욕) → 진입 금지")
return False
# F&G 구간별 vol 임계값
vth = VOL_THRESH_FEAR if fng <= FNG_FEAR_THRESHOLD else VOL_THRESH_NORMAL
current = get_current_price(ticker)
if not current:
return False
now = time.time()
# ── 신호 만료 체크 ────────────────────────────────────
sig = _accum_signals.get(ticker)
if sig is not None:
age_min = (now - sig["time"]) / 60
if age_min > SIGNAL_TIMEOUT_MIN:
del _accum_signals[ticker]
sig = None
logger.debug(f"[축적타임아웃] {ticker} {age_min:.0f}분 경과 → 신호 초기화")
# ── 10분봉 데이터 조회 ────────────────────────────────
try:
df10 = pyupbit.get_ohlcv(ticker, interval="minute10", count=_FETCH_10M)
except Exception:
return False
if df10 is None or len(df10) < LOCAL_VOL_CANDLES + QUIET_CANDLES:
return False
# ── 거래량 비율 계산 (직전 완성봉 기준) ───────────────
vol_prev = float(df10["volume"].iloc[-2])
vol_avg = float(df10["volume"].iloc[-(LOCAL_VOL_CANDLES + 2):-2].mean())
vol_r = vol_prev / vol_avg if vol_avg > 0 else 0.0
# ── 횡보 체크 (QN봉 이전 종가 기준) ──────────────────
close_qn = float(df10["close"].iloc[-(QUIET_CANDLES + 1)])
chg = abs(current - close_qn) / close_qn * 100 if close_qn > 0 else 999.0
# ── 관찰 알림: WATCH_VOL_THRESH ≤ vol_r < vth + 횡보 ──
if WATCH_VOL_THRESH <= vol_r < vth and chg < PRICE_QUIET_PCT:
prev = _watch_notified.get(ticker)
age_min = (now - prev["time"]) / 60 if prev else 999.0
vol_jump = vol_r - prev["vol_ratio"] if prev else vol_r
if prev is None or age_min >= WATCH_COOLDOWN_MIN or vol_jump >= WATCH_VOL_JUMP:
_watch_notified[ticker] = {"time": now, "vol_ratio": vol_r}
logger.info(
f"[관찰] {ticker} vol={vol_r:.2f}x (신호기준={vth:.1f}x) + 횡보({chg:.1f}%) | F&G={fng}"
)
notify_watch(ticker, current, vol_r, vth, chg, fng=fng)
elif vol_r < WATCH_VOL_THRESH:
_watch_notified.pop(ticker, None)
# ── vol 스파이크 + 횡보 → 신호 설정/갱신 ────────────
if vol_r >= vth and chg < PRICE_QUIET_PCT:
if sig is None or vol_r > sig.get("vol_ratio", 0.0):
_accum_signals[ticker] = {"price": current, "time": now, "vol_ratio": vol_r}
sig = _accum_signals[ticker]
logger.info(
f"[축적감지] {ticker} 10m vol={vol_r:.2f}x ≥ {vth:.1f}x + 횡보({chg:.1f}%) "
f"→ 신호가={current:,.2f}원 (F&G={fng})"
)
notify_signal(ticker, current, vol_r, fng=fng)
if sig is None:
logger.debug(
f"[축적✗] {ticker} vol={vol_r:.2f}x (기준={vth:.1f}x) / 횡보={chg:.1f}%"
)
return False
# ── 진입 확인: 신호가 대비 +TREND_AFTER_VOL% 이상 ──
signal_price = sig["price"]
vol_ratio = sig["vol_ratio"]
move_pct = (current - signal_price) / signal_price * 100
age_min = (now - sig["time"]) / 60
if move_pct >= TREND_AFTER_VOL:
del _accum_signals[ticker]
logger.info(
f"[축적진입] {ticker} 신호가={signal_price:,.2f}원 → 현재={current:,.2f}"
f"(+{move_pct:.1f}% ≥ {TREND_AFTER_VOL}% | 거래량={vol_ratio:.2f}x | F&G={fng})"
)
return True
# 신호불사: 가격 하락해도 신호 유지 (sig_p 고정, 만료까지 대기)
logger.debug(
f"[축적대기] {ticker} 신호가={signal_price:,.2f} 현재={current:,.2f} "
f"({move_pct:+.1f}% / 목표={TREND_AFTER_VOL}% | "
f"거래량={vol_ratio:.2f}x | 경과={age_min:.0f}분)"
)
return False

666
archive/core/trader.py Normal file
View File

@@ -0,0 +1,666 @@
"""매수/매도 실행 및 포지션 관리."""
from __future__ import annotations
import logging
import os
import threading
import time
import uuid
from datetime import datetime
from typing import Optional
import pyupbit
from dotenv import load_dotenv
from .notify import notify_buy, notify_sell, notify_error
from .price_db import (
delete_position, load_positions, upsert_position,
ensure_trade_results_table, record_trade, load_recent_wins,
ensure_sell_prices_table, upsert_sell_price, load_sell_prices,
get_cumulative_krw_profit,
ensure_wf_state_table, upsert_wf_state, load_wf_states, delete_wf_state,
)
load_dotenv()
logger = logging.getLogger(__name__)
SIMULATION_MODE = os.getenv("SIMULATION_MODE", "").lower() in ("true", "1", "yes")
if SIMULATION_MODE:
logging.getLogger(__name__).warning(
"*** SIMULATION MODE ACTIVE — 실제 주문이 실행되지 않습니다 ***"
)
INITIAL_BUDGET = int(os.getenv("MAX_BUDGET", "10000000")) # 초기 원금 (고정)
MAX_POSITIONS = int(os.getenv("MAX_POSITIONS", "3")) # 최대 동시 보유 종목 수
# 복리 적용 예산 (매도 후 재계산) — 수익 시 복리 증가, 손실 시 차감 (하한 30%)
MIN_BUDGET = INITIAL_BUDGET * 3 // 10 # 최소 예산: 초기값의 30%
MAX_BUDGET = INITIAL_BUDGET
PER_POSITION = INITIAL_BUDGET // MAX_POSITIONS
def _recalc_compound_budget() -> None:
"""누적 수익/손실을 반영해 MAX_BUDGET / PER_POSITION 재계산.
수익 시 복리로 증가, 손실 시 차감 (최소 초기 예산의 30% 보장).
매도 완료 후 호출.
"""
global MAX_BUDGET, PER_POSITION
try:
cum_profit = get_cumulative_krw_profit()
effective = max(INITIAL_BUDGET + int(cum_profit), MIN_BUDGET)
MAX_BUDGET = effective
PER_POSITION = effective // MAX_POSITIONS
logger.info(
f"[복리] 누적수익={cum_profit:+,.0f}원 | "
f"운용예산={MAX_BUDGET:,}원 | 포지션당={PER_POSITION:,}"
)
except Exception as e:
logger.warning(f"[복리] 예산 재계산 실패 (이전 값 유지): {e}")
# Walk-forward 필터 설정
WF_WINDOW = int(float(os.getenv("WF_WINDOW", "5"))) # 이력 윈도우 크기
WF_MIN_WIN_RATE = float(os.getenv("WF_MIN_WIN_RATE", "0.40")) # 최소 승률 임계값
WF_SHADOW_WINS = int(os.getenv("WF_SHADOW_WINS", "2")) # shadow N연승 → WF 해제
WF_VOL_BYPASS_THRESH = float(os.getenv("WF_VOL_BYPASS_THRESH", "10.0")) # 이 이상 vol이면 WF 무시
_lock = threading.Lock()
_positions: dict = {}
# 구조: { ticker: { buy_price, peak_price, amount, invested_krw, entry_time } }
_last_sell_prices: dict[str, float] = {}
# 직전 매도가 기록 — 재매수 시 이 가격 이상일 때만 진입 허용
_trade_history: dict[str, list[bool]] = {}
# walk-forward 이력: { ticker: [True/False, ...] } (True=수익)
_shadow_lock = threading.Lock()
_shadow_positions: dict[str, dict] = {}
# WF차단 종목 가상 포지션: { ticker: { buy_price, peak_price, entry_time } }
_shadow_cons_wins: dict[str, int] = {}
# shadow 연속 승 횟수: { ticker: int }
_upbit: Optional[pyupbit.Upbit] = None
def _get_upbit() -> pyupbit.Upbit:
global _upbit
if _upbit is None:
_upbit = pyupbit.Upbit(os.getenv("ACCESS_KEY"), os.getenv("SECRET_KEY"))
return _upbit
def _get_history(ticker: str) -> list[bool]:
"""in-memory 이력 반환. 없으면 DB에서 초기 로드."""
if ticker not in _trade_history:
try:
_trade_history[ticker] = load_recent_wins(ticker, WF_WINDOW)
except Exception:
_trade_history[ticker] = []
return _trade_history[ticker]
def _update_history(
ticker: str, is_win: bool, pnl_pct: float,
fee_krw: float = 0.0, krw_profit: float = 0.0,
trade_id: str = "", buy_price: float = 0.0,
sell_price: float = 0.0, invested_krw: int = 0,
sell_reason: str = "",
) -> None:
"""매도 후 in-memory 이력 갱신 + DB 기록."""
hist = _trade_history.setdefault(ticker, [])
hist.append(is_win)
# 윈도우 초과분 제거 (메모리 절약)
if len(hist) > WF_WINDOW * 2:
_trade_history[ticker] = hist[-WF_WINDOW:]
try:
record_trade(
ticker, is_win, pnl_pct, fee_krw, krw_profit,
trade_id, buy_price, sell_price, invested_krw, sell_reason,
)
except Exception as e:
logger.error(f"거래 이력 저장 실패 {ticker}: {e}")
# ── Shadow 재활 ────────────────────────────────────────────────────────────────
def _shadow_enter(ticker: str) -> None:
"""WF 차단 종목에 shadow(가상) 포지션 진입.
buy() 내부(_lock 보유 중)에서 호출됨.
API 호출 후 _shadow_lock으로만 shadow 상태 보호 (deadlock 방지).
"""
# 이미 shadow 중이면 스킵
if ticker in _shadow_positions:
return
price = pyupbit.get_current_price(ticker)
if not price:
return
with _shadow_lock:
if ticker in _shadow_positions: # double-check
return
_shadow_positions[ticker] = {
"buy_price": price,
"peak_price": price,
"entry_time": datetime.now(),
}
cons = _shadow_cons_wins.get(ticker, 0)
try:
upsert_wf_state(ticker, is_blocked=True, shadow_cons_wins=cons)
except Exception as e:
logger.error(f"WF 상태 DB 저장 실패 {ticker}: {e}")
logger.info(
f"[Shadow진입] {ticker} @ {price:,.0f}"
f"(가상 — WF 재활 {cons}/{WF_SHADOW_WINS}연승 필요)"
)
def get_shadow_positions() -> dict:
"""Shadow 포지션 스냅샷 반환 (monitor 에서 조회용)."""
with _shadow_lock:
return {k: dict(v) for k, v in _shadow_positions.items()}
def update_shadow_peak(ticker: str, price: float) -> None:
"""Shadow 포지션 최고가 갱신."""
with _shadow_lock:
if ticker in _shadow_positions:
if price > _shadow_positions[ticker]["peak_price"]:
_shadow_positions[ticker]["peak_price"] = price
def close_shadow(ticker: str, sell_price: float, pnl_pct: float, reason: str) -> None:
"""Shadow 포지션 청산 및 WF 재활 진행.
연속승 갱신 → WF_SHADOW_WINS 달성 시 WF 이력 초기화 + Telegram 알림.
"""
with _shadow_lock:
spos = _shadow_positions.pop(ticker, None)
if spos is None:
return
is_win = pnl_pct > 0
cons = _shadow_cons_wins.get(ticker, 0)
cons = cons + 1 if is_win else 0
_shadow_cons_wins[ticker] = cons
do_wf_reset = cons >= WF_SHADOW_WINS
if do_wf_reset:
_shadow_cons_wins.pop(ticker, None)
# shadow 상태 DB 갱신 (_shadow_lock 해제 후)
try:
if do_wf_reset:
delete_wf_state(ticker)
else:
upsert_wf_state(ticker, is_blocked=True, shadow_cons_wins=cons)
except Exception as e:
logger.error(f"WF 상태 DB 갱신 실패 {ticker}: {e}")
mark = "" if is_win else ""
logger.info(
f"[Shadow청산] {ticker} {spos['buy_price']:,.0f}{sell_price:,.0f}"
f"| {mark} {pnl_pct:+.1f}% | {reason} | 연속승={cons}/{WF_SHADOW_WINS}"
)
if do_wf_reset:
with _lock: # _shadow_lock은 이미 해제된 상태 (deadlock 없음)
_trade_history.pop(ticker, None)
logger.warning(
f"[WF해제] {ticker} Shadow {WF_SHADOW_WINS}연승 달성 → "
f"WF 이력 초기화, 실거래 재개"
)
notify_error(
f"🎉 [{ticker}] WF 재활 완료!\n"
f"Shadow {WF_SHADOW_WINS}연승 달성 → 실거래 재개"
)
def _db_upsert(ticker: str, pos: dict) -> None:
"""포지션을 Oracle DB에 저장 (실패해도 거래는 계속)."""
try:
upsert_position(
ticker=ticker,
buy_price=pos["buy_price"],
peak_price=pos["peak_price"],
amount=pos["amount"],
invested_krw=pos["invested_krw"],
entry_time=pos["entry_time"].isoformat(),
trade_id=pos.get("trade_id", ""),
)
except Exception as e:
logger.error(f"포지션 DB 저장 실패 {ticker}: {e}")
def get_positions() -> dict:
return _positions
def get_budget_info() -> dict:
"""현재 복리 예산 정보 반환 (main.py 등 외부에서 동적 조회용)."""
return {
"max_budget": MAX_BUDGET,
"per_position": PER_POSITION,
"initial": INITIAL_BUDGET,
}
def restore_positions() -> None:
"""시작 시 Oracle DB + Upbit 잔고를 교차 확인하여 포지션 복원.
trade_results 테이블도 이 시점에 생성 (없으면).
DB에 저장된 실제 매수가를 복원하고, Upbit 잔고에 없으면 DB에서도 삭제한다.
"""
# trade_results / sell_prices / wf_state 테이블 초기화
try:
ensure_trade_results_table()
except Exception as e:
logger.warning(f"trade_results 테이블 생성 실패 (무시): {e}")
# 시작 시 복리 예산 복원 (이전 세션 수익 반영)
_recalc_compound_budget()
# WF 상태 복원 (shadow 연속승 횟수 유지)
try:
ensure_wf_state_table()
wf_states = load_wf_states()
for ticker, state in wf_states.items():
if state["is_blocked"]:
_shadow_cons_wins[ticker] = state["shadow_cons_wins"]
if wf_states:
logger.info(
f"[복원] WF 차단 상태 {len(wf_states)}건 복원: "
+ ", ".join(f"{t}(shadow={s['shadow_cons_wins']})" for t, s in wf_states.items())
)
except Exception as e:
logger.warning(f"WF 상태 복원 실패 (무시): {e}")
try:
ensure_sell_prices_table()
except Exception as e:
logger.warning(f"sell_prices 테이블 생성 실패 (무시): {e}")
# 직전 매도가 복원 (재매수 차단 기준 유지)
try:
loaded = load_sell_prices()
_last_sell_prices.update(loaded)
if loaded:
logger.info(f"[복원] 직전 매도가 {len(loaded)}건 복원: {list(loaded.keys())}")
except Exception as e:
logger.warning(f"직전 매도가 복원 실패 (무시): {e}")
# DB에서 저장된 포지션 로드
try:
saved = {row["ticker"]: row for row in load_positions()}
except Exception as e:
logger.error(f"DB 포지션 로드 실패: {e}")
saved = {}
if SIMULATION_MODE:
# --- 시뮬레이션: Upbit 잔고 조회 없이 DB 포지션만 복원 ---
logger.info("[SIMULATION] 시뮬레이션 모드 — Upbit 잔고 조회 생략, DB 포지션만 복원")
for ticker, s in saved.items():
current = pyupbit.get_current_price(ticker)
if not current:
continue
peak = max(s["peak_price"], current)
entry_time = datetime.fromisoformat(s["entry_time"]) if isinstance(s["entry_time"], str) else s["entry_time"]
with _lock:
_positions[ticker] = {
"buy_price": s["buy_price"],
"peak_price": peak,
"amount": s.get("amount", 0),
"invested_krw": s["invested_krw"],
"entry_time": entry_time,
"trade_id": s.get("trade_id", ""),
}
logger.info(
f"[SIMULATION][복원] {ticker} 매수가={s['buy_price']:,.0f}원 | "
f"현재가={current:,.0f}원 (DB 복원)"
)
return
upbit = _get_upbit()
balances = upbit.get_balances()
upbit_tickers = set()
for b in balances:
currency = b["currency"]
if currency == "KRW":
continue
amount = float(b["balance"]) + float(b["locked"])
if amount <= 0:
continue
ticker = f"KRW-{currency}"
current = pyupbit.get_current_price(ticker)
if not current:
continue
invested_krw = int(amount * current)
if invested_krw < 1_000: # 소액 잔고 무시
continue
upbit_tickers.add(ticker)
if ticker in saved:
# DB에 저장된 실제 매수가 복원
s = saved[ticker]
peak = max(s["peak_price"], current) # 재시작 중 올랐을 수 있으므로 높은 쪽
entry_time = datetime.fromisoformat(s["entry_time"]) if isinstance(s["entry_time"], str) else s["entry_time"]
with _lock:
_positions[ticker] = {
"buy_price": s["buy_price"],
"peak_price": peak,
"amount": amount,
"invested_krw": s["invested_krw"],
"entry_time": entry_time,
"trade_id": s.get("trade_id", ""),
}
logger.info(
f"[복원] {ticker} 매수가={s['buy_price']:,.0f}원 | 현재가={current:,.0f}"
f"| 수량={amount} (DB 복원)"
)
else:
# DB에 없음 → 현재가로 초기화 후 DB에 저장
entry_time = datetime.now()
with _lock:
_positions[ticker] = {
"buy_price": current,
"peak_price": current,
"amount": amount,
"invested_krw": min(invested_krw, PER_POSITION),
"entry_time": entry_time,
}
_db_upsert(ticker, _positions[ticker])
logger.warning(
f"[복원] {ticker} 현재가={current:,.0f}원 | 수량={amount} "
f"(DB 기록 없음 → 현재가로 초기화)"
)
# Upbit 잔고에 없는데 DB에 남아있는 항목 정리
for ticker in saved:
if ticker not in upbit_tickers:
try:
delete_position(ticker)
logger.info(f"[정리] {ticker} Upbit 잔고 없음 → DB 포지션 삭제")
except Exception as e:
logger.error(f"DB 포지션 삭제 실패 {ticker}: {e}")
def buy(ticker: str, vol_ratio: float = 0.0) -> bool:
"""시장가 매수. 예산·포지션 수 확인 후 진입.
Args:
vol_ratio: 진입 시점의 거래량 배율. WF_VOL_BYPASS_THRESH 이상이면 WF 필터 무시.
"""
with _lock:
if ticker in _positions:
logger.debug(f"{ticker} 이미 보유 중")
return False
# WF 이력 항상 DB에서 직접 로드 (재시작과 무관하게 최신 이력 반영)
try:
hist = load_recent_wins(ticker, WF_WINDOW)
_trade_history[ticker] = hist # 메모리 캐시 동기화
except Exception as e:
logger.warning(f"WF 이력 DB 로드 실패 {ticker}: {e}")
hist = _trade_history.get(ticker, [])
# 직전 매도가 +1% 이상일 때만 재진입 (손절 직후 역방향 재매수 방지)
# 단, 직전 거래가 수익(승)이었으면 이 필터 스킵 — 다시 상승 시 재진입 허용
if ticker in _last_sell_prices:
last_was_win = bool(hist[-1]) if hist else False
if not last_was_win:
current_check = pyupbit.get_current_price(ticker)
last_sell = _last_sell_prices[ticker]
threshold = last_sell * 1.01
if current_check and current_check < threshold:
logger.info(
f"[재매수 차단] {ticker} 현재={current_check:,.2f} < "
f"직전매도+1%={threshold:,.2f} → 상승 흐름 미확인"
)
return False
# Walk-forward 필터: 직전 WF_WINDOW건 승률이 낮으면 진입 차단 + shadow 진입
# vol이 WF_VOL_BYPASS_THRESH 이상이면 WF 무시 (강한 신호 우선)
if WF_MIN_WIN_RATE > 0:
if WF_VOL_BYPASS_THRESH > 0 and vol_ratio >= WF_VOL_BYPASS_THRESH:
logger.info(
f"[WF바이패스] {ticker} vol={vol_ratio:.1f}x ≥ {WF_VOL_BYPASS_THRESH}x"
f" → WF 필터 무시 (강한 vol 신호)"
)
elif len(hist) >= WF_WINDOW:
recent_wr = sum(hist[-WF_WINDOW:]) / WF_WINDOW
if recent_wr < WF_MIN_WIN_RATE:
logger.info(
f"[WF차단] {ticker} 직전{WF_WINDOW}건 승률={recent_wr*100:.0f}%"
f" < {WF_MIN_WIN_RATE*100:.0f}% → 진입 차단 (shadow 재활 시작)"
)
_shadow_enter(ticker) # 가상 포지션으로 WF 재활 추적
return False
if len(_positions) >= MAX_POSITIONS:
logger.info(f"최대 포지션 도달({MAX_POSITIONS}), {ticker} 패스")
return False
invested = sum(p["invested_krw"] for p in _positions.values())
available = MAX_BUDGET - invested
order_krw = min(available, PER_POSITION)
if order_krw < 10_000:
logger.info(f"잔여 예산 부족({order_krw:,}원), {ticker} 패스")
return False
try:
if SIMULATION_MODE:
# --- 시뮬레이션 매수 ---
sim_price = pyupbit.get_current_price(ticker)
if not sim_price:
logger.error(f"[SIMULATION] 현재가 조회 실패: {ticker}")
return False
amount = order_krw / sim_price
actual_price = sim_price
logger.info(
f"[SIMULATION][매수] {ticker} @ {actual_price:,.0f}원 | "
f"수량={amount:.8f} | 투자금={order_krw:,}원 (모의 주문)"
)
else:
# --- 실제 매수 ---
upbit = _get_upbit()
result = upbit.buy_market_order(ticker, order_krw)
if not result or "error" in str(result):
logger.error(f"매수 실패: {result}")
return False
time.sleep(0.5) # 체결 대기
currency = ticker.split("-")[1]
amount = float(upbit.get_balance(currency) or 0)
# 실제 체결가 = 투자금 / 수량 (시장가 주문 슬리피지 반영)
actual_price = order_krw / amount if amount > 0 else pyupbit.get_current_price(ticker)
entry_time = datetime.now()
trade_id = str(uuid.uuid4())
_positions[ticker] = {
"buy_price": actual_price,
"peak_price": actual_price,
"amount": amount,
"invested_krw": order_krw,
"entry_time": entry_time,
"trade_id": trade_id,
}
_db_upsert(ticker, _positions[ticker])
prefix = "[SIMULATION]" if SIMULATION_MODE else ""
logger.info(
f"{prefix}[매수] {ticker} @ {actual_price:,.0f}원 (실체결가) | "
f"수량={amount} | 투자금={order_krw:,}원 | trade_id={trade_id[:8]}"
)
from .fng import get_fng
notify_buy(ticker, actual_price, amount, order_krw,
max_budget=MAX_BUDGET, per_position=PER_POSITION,
fng=get_fng())
return True
except Exception as e:
logger.error(f"매수 예외 {ticker}: {e}")
notify_error(f"매수 실패 {ticker}: {e}")
return False
def _get_avg_fill_price(
upbit: pyupbit.Upbit,
order_uuid: str,
ticker: str,
fallback: float,
) -> tuple[float, float | None]:
"""주문 UUID로 실제 체결 내역을 조회해 가중평균 체결가와 실수수료를 반환.
분할 체결(여러 fills)이면 합산 평균가 계산.
조회 실패 시 (fallback_price, None) 반환.
"""
if not order_uuid:
return fallback, None
try:
import hashlib
import jwt as _jwt
import requests as _req
query_str = f"uuid={order_uuid}"
payload = {
"access_key": upbit.access_key,
"nonce": str(uuid.uuid4()),
"query_hash": hashlib.sha512(query_str.encode()).hexdigest(),
"query_hash_alg": "SHA512",
}
token = _jwt.encode(payload, upbit.secret_key, algorithm="HS256")
resp = _req.get(
"https://api.upbit.com/v1/order",
params={"uuid": order_uuid},
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
data = resp.json()
trades = data.get("trades", [])
if not trades:
return fallback, None
total_vol = sum(float(t["volume"]) for t in trades)
total_krw = sum(float(t["price"]) * float(t["volume"]) for t in trades)
avg_price = total_krw / total_vol if total_vol > 0 else fallback
paid_fee = float(data.get("paid_fee", 0))
if len(trades) > 1:
logger.info(
f"[분할체결] {ticker} {len(trades)}건 → "
f"평균={avg_price:,.4f}원 (수수료={paid_fee:,.0f}원)"
)
return avg_price, paid_fee
except Exception as e:
logger.debug(f"[체결조회 실패] {ticker} uuid={order_uuid[:8]}: {e}")
return fallback, None
def sell(ticker: str, reason: str = "") -> bool:
"""시장가 전량 매도."""
with _lock:
if ticker not in _positions:
return False
pos = _positions[ticker]
try:
if SIMULATION_MODE:
# --- 시뮬레이션 매도 ---
actual_amount = pos["amount"]
actual_sell_price = pyupbit.get_current_price(ticker) or pos["buy_price"]
sell_value = actual_sell_price * actual_amount
fee = pos["invested_krw"] * 0.0005 + sell_value * 0.0005
krw_profit = sell_value - pos["invested_krw"] - fee
pnl = (actual_sell_price - pos["buy_price"]) / pos["buy_price"] * 100
logger.info(
f"[SIMULATION][매도] {ticker} @ {actual_sell_price:,.4f}원 | "
f"수익률={pnl:+.1f}% | 순익={krw_profit:+,.0f}원 (모의 주문) | 사유={reason}"
)
else:
# --- 실제 매도 ---
upbit = _get_upbit()
currency = ticker.split("-")[1]
# 실제 잔고 확인 (재시작 후 이미 매도된 경우 대비)
actual_amount = float(upbit.get_balance(currency) or 0)
if actual_amount < 0.00001:
logger.warning(f"[매도] {ticker} 실제 잔고 없음 → 포지션 정리 (이미 매도됨)")
del _positions[ticker]
return True
result = upbit.sell_market_order(ticker, actual_amount)
if not result or "error" in str(result):
logger.error(f"매도 실패: {result}")
# 실패 후에도 잔고 재확인 → 0이면 실제로는 매도됨
actual_amount2 = float(upbit.get_balance(currency) or 0)
if actual_amount2 < 0.00001:
logger.warning(f"[매도] {ticker} 잔고 소진 확인 → 포지션 정리")
del _positions[ticker]
return True
return False
time.sleep(0.5) # 체결 완료 대기
# 실제 체결 내역으로 가중평균 매도가 계산 (분할 체결 대응)
order_uuid = result.get("uuid", "") if isinstance(result, dict) else ""
fallback_price = pyupbit.get_current_price(ticker) or pos["buy_price"]
actual_sell_price, actual_fee_from_order = _get_avg_fill_price(
upbit, order_uuid, ticker, fallback_price
)
pnl = (actual_sell_price - pos["buy_price"]) / pos["buy_price"] * 100
sell_value = actual_sell_price * actual_amount
# 수수료: 주문 조회 성공 시 실제값, 아니면 추정값 (0.05% 양방향)
fee = actual_fee_from_order if actual_fee_from_order is not None \
else (pos["invested_krw"] * 0.0005 + sell_value * 0.0005)
krw_profit = sell_value - pos["invested_krw"] - fee
prefix = "[SIMULATION]" if SIMULATION_MODE else ""
logger.info(
f"{prefix}[매도] {ticker} @ {actual_sell_price:,.4f}원 | "
f"수익률={pnl:+.1f}% | 순익={krw_profit:+,.0f}원 (수수료 {fee:,.0f}원) | 사유={reason}"
)
try:
cum = get_cumulative_krw_profit() + krw_profit
except Exception:
cum = 0.0
notify_sell(ticker, actual_sell_price, pnl, reason,
krw_profit=krw_profit, fee_krw=fee, cum_profit=cum)
_last_sell_prices[ticker] = actual_sell_price
try:
upsert_sell_price(ticker, actual_sell_price)
except Exception as e:
logger.error(f"직전 매도가 DB 저장 실패 {ticker}: {e}")
_update_history(
ticker, pnl > 0, pnl, fee, krw_profit,
trade_id=pos.get("trade_id", ""),
buy_price=pos["buy_price"],
sell_price=actual_sell_price,
invested_krw=pos["invested_krw"],
sell_reason=reason,
)
del _positions[ticker]
try:
delete_position(ticker)
except Exception as e:
logger.error(f"포지션 DB 삭제 실패 {ticker}: {e}")
# 복리 예산 재계산: 수익 발생분만 다음 투자에 반영
_recalc_compound_budget()
return True
except Exception as e:
logger.error(f"매도 예외 {ticker}: {e}")
notify_error(f"매도 실패 {ticker}: {e}")
return False
def update_peak(ticker: str, current_price: float) -> None:
"""최고가 갱신 (트레일링 스탑 기준선 상향)."""
with _lock:
if ticker in _positions:
if current_price > _positions[ticker]["peak_price"]:
_positions[ticker]["peak_price"] = current_price
_db_upsert(ticker, _positions[ticker])

100
archive/daemon/runner.py Normal file
View File

@@ -0,0 +1,100 @@
"""매수 기회 스캔 루프 - 60초마다 전체 시장 스캔 + 신호 종목 빠른 폴링."""
import logging
import os
import threading
import time
from core import trader
from core.fng import get_fng
from core.market import get_top_tickers
from core.strategy import FNG_MAX_ENTRY, get_active_signals, should_buy
logger = logging.getLogger(__name__)
SCAN_INTERVAL = 60 # 전체 시장 스캔 주기 (초)
SIGNAL_POLL_INTERVAL = int(os.getenv("SIGNAL_POLL_INTERVAL", "15")) # 신호 종목 빠른 감시 주기 (초)
def _fast_poll_loop() -> None:
"""활성 신호 종목을 SIGNAL_POLL_INTERVAL 초마다 빠르게 체크.
신호 감지 후 전체 스캔 60초를 기다리지 않고, 신호 종목만 빠르게 감시하여
목표 임계값 도달 시 즉시 매수한다.
"""
logger.info(f"신호 감시 시작 (주기={SIGNAL_POLL_INTERVAL}초)")
while True:
try:
signals = get_active_signals()
if signals:
positions = trader.get_positions()
for ticker in list(signals):
if ticker in positions:
continue
if len(positions) >= trader.MAX_POSITIONS:
break
try:
if should_buy(ticker):
vol_r = signals.get(ticker, {}).get("vol_ratio", 0.0)
logger.info(f"[빠른감시] 매수 신호: {ticker} (vol={vol_r:.1f}x)")
trader.buy(ticker, vol_ratio=vol_r)
time.sleep(0.1)
except Exception as e:
logger.error(f"[빠른감시] 오류 {ticker}: {e}")
except Exception as e:
logger.error(f"신호 감시 루프 오류: {e}")
time.sleep(SIGNAL_POLL_INTERVAL)
def run_scanner() -> None:
"""메인 스캔 루프."""
# 신호 종목 빠른 감시 스레드 시작
t = threading.Thread(target=_fast_poll_loop, daemon=True, name="signal-fast-poll")
t.start()
logger.info(f"스캐너 시작 (스캔={SCAN_INTERVAL}초 | 신호감시={SIGNAL_POLL_INTERVAL}초)")
while True:
try:
# 포지션 꽉 찼으면 스캔 스킵
if len(trader.get_positions()) >= trader.MAX_POSITIONS:
logger.info("포지션 최대치 도달, 스캔 스킵")
time.sleep(SCAN_INTERVAL)
continue
# F&G 탐욕/극탐욕 구간 → 전체 스캔 스킵 (strategy.py와 동일 기준)
fv = get_fng()
fng_label = (
"극탐욕" if fv >= 76 else "탐욕" if fv >= 56 else
"중립" if fv >= 46 else "약공포" if fv >= 41 else
"공포" if fv >= 26 else "극공포"
)
if fv > FNG_MAX_ENTRY:
logger.info(
f"[F&G차단] F&G={fv} ({fng_label}) > {FNG_MAX_ENTRY} — 탐욕 구간 스캔 스킵"
)
time.sleep(SCAN_INTERVAL)
continue
tickers = get_top_tickers()
logger.info(f"스캔 시작: {len(tickers)}개 종목 | F&G={fv}({fng_label})")
for ticker in tickers:
# 이미 보유 중인 종목 제외
if ticker in trader.get_positions():
continue
try:
if should_buy(ticker):
vol_r = get_active_signals().get(ticker, {}).get("vol_ratio", 0.0)
logger.info(f"매수 신호: {ticker} (vol={vol_r:.1f}x)")
trader.buy(ticker, vol_ratio=vol_r)
time.sleep(0.15) # API rate limit 방지
except Exception as e:
logger.error(f"스캔 오류 {ticker}: {e}")
time.sleep(0.3)
except Exception as e:
logger.error(f"스캐너 루프 오류: {e}")
time.sleep(SCAN_INTERVAL)

View File

@@ -0,0 +1,169 @@
"""1분봉 장기 히스토리 fetch 데몬.
주요 5종목(BTC/ETH/XRP/SOL/DOGE)의 1분봉을 2년치까지 소급 수집.
백그라운드에서 조용히 실행 — API 딜레이 충분히 줘서 다른 작업 방해 안 함.
재시작 시 DB에 이미 있는 범위는 건너뜀.
실행:
.venv/bin/python3 daemons/fetch_1min_history.py [--tickers BTC ETH] [--days 730]
로그:
/tmp/fetch_1min_history.log
"""
import sys, os, time, argparse, logging
from datetime import datetime, timedelta
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from dotenv import load_dotenv
load_dotenv(os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), '.env'))
import pyupbit
import oracledb
# ── 설정 ──────────────────────────────────────────────────────────────────────
DEFAULT_TICKERS = ['KRW-BTC', 'KRW-ETH', 'KRW-XRP', 'KRW-SOL', 'KRW-DOGE']
BATCH = 200 # API 1회 요청 봉수 (Upbit 최대 200)
DELAY = 0.4 # API 호출 간격 (초) — 넉넉히 줘서 rate limit 회피
RETRY_WAIT = 5.0 # 오류 시 대기 (초)
# ── 로깅 설정 ─────────────────────────────────────────────────────────────────
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s %(levelname)s %(message)s',
handlers=[
logging.FileHandler('/tmp/fetch_1min_history.log'),
logging.StreamHandler(sys.stdout),
]
)
log = logging.getLogger(__name__)
def _get_conn():
kwargs = dict(user=os.environ["ORACLE_USER"],
password=os.environ["ORACLE_PASSWORD"],
dsn=os.environ["ORACLE_DSN"])
wallet = os.environ.get("ORACLE_WALLET")
if wallet:
kwargs["config_dir"] = wallet
return oracledb.connect(**kwargs)
def get_oldest_ts(conn, ticker: str):
"""DB에 있는 해당 ticker 1분봉의 가장 오래된 ts 반환. 없으면 None."""
cur = conn.cursor()
cur.execute(
"SELECT MIN(ts) FROM backtest_ohlcv "
"WHERE ticker=:t AND interval_cd='minute1'",
{"t": ticker}
)
row = cur.fetchone()
return row[0] if row and row[0] else None
def insert_batch(conn, ticker: str, rows: list) -> int:
"""rows: [(ts, open, high, low, close, volume), ...] — bulk insert, 중복 무시."""
if not rows:
return 0
cur = conn.cursor()
cur.executemany(
"INSERT INTO backtest_ohlcv "
"(ticker,interval_cd,ts,open_p,high_p,low_p,close_p,volume_p) "
"VALUES (:1,'minute1',:2,:3,:4,:5,:6,:7)",
[(ticker, ts, o, h, l, c, v) for ts, o, h, l, c, v in rows],
batcherrors=True,
)
errors = cur.getbatcherrors()
conn.commit()
return len(rows) - len(errors)
def fetch_ticker(conn, ticker: str, cutoff: datetime) -> int:
"""ticker의 cutoff까지 1분봉 소급 fetch.
DB에 이미 있는 범위는 batcherrors로 자동 스킵.
"""
oldest_in_db = get_oldest_ts(conn, ticker)
if oldest_in_db and oldest_in_db <= cutoff:
log.info(f"{ticker}: DB에 이미 {oldest_in_db.date()} 까지 있음 → 스킵")
return 0
# datetime.now()에서 시작해 cutoff까지 역방향 fetch
# 중복은 DB unique constraint + batcherrors가 처리
to_dt = datetime.now()
total = 0
batch_n = 0
if oldest_in_db:
log.info(f"{ticker}: DB 최솟값={oldest_in_db.date()}, {cutoff.date()} 까지 소급 시작")
else:
log.info(f"{ticker}: DB에 데이터 없음, {cutoff.date()} 까지 전체 fetch 시작")
while to_dt > cutoff:
# pyupbit의 to 파라미터는 UTC로 해석됨 — KST에서 9시간 빼서 전달
to_utc = to_dt - timedelta(hours=9)
to_str = to_utc.strftime('%Y-%m-%d %H:%M:%S')
try:
df = pyupbit.get_ohlcv(ticker, interval='minute1', count=BATCH, to=to_str)
time.sleep(DELAY)
except Exception as e:
log.warning(f"{ticker} API 오류: {e}{RETRY_WAIT}s 후 재시도")
time.sleep(RETRY_WAIT)
continue
if df is None or len(df) == 0:
log.info(f"{ticker}: API 데이터 소진 ({to_str})")
break
rows = [
(ts.to_pydatetime(), float(r['open']), float(r['high']),
float(r['low']), float(r['close']), float(r['volume']))
for ts, r in df.iterrows()
]
n = insert_batch(conn, ticker, rows)
total += n
batch_n += 1
oldest = df.index[0].to_pydatetime()
if batch_n % 50 == 0:
log.info(f" {ticker} 배치{batch_n:04d}: {oldest.date()} | 신규 누적 {total:,}")
to_dt = oldest - timedelta(minutes=1)
if oldest <= cutoff:
break
log.info(f"{ticker}: 완료 — 신규 {total:,}행 (배치 {batch_n}회)")
return total
def main():
parser = argparse.ArgumentParser()
parser.add_argument('--tickers', nargs='+', default=DEFAULT_TICKERS,
help='수집 티커 (예: KRW-BTC KRW-ETH)')
parser.add_argument('--days', type=int, default=730,
help='소급 일수 (기본 730일 = 2년)')
args = parser.parse_args()
cutoff = datetime.now() - timedelta(days=args.days)
log.info(f"=== 1분봉 히스토리 데몬 시작 ===")
log.info(f"대상: {args.tickers}")
log.info(f"목표: {cutoff.date()} ({args.days}일) 까지 소급")
conn = _get_conn()
grand_total = 0
t_start = time.time()
for ticker in args.tickers:
t0 = time.time()
try:
n = fetch_ticker(conn, ticker, cutoff)
grand_total += n
elapsed = time.time() - t0
log.info(f"{ticker}: {n:,}행 저장 ({elapsed/60:.1f}분)")
except Exception as e:
log.error(f"{ticker}: 오류 — {e}")
conn.close()
total_min = (time.time() - t_start) / 60
log.info(f"=== 완료: 총 {grand_total:,}행 / {total_min:.0f}분 ===")
if __name__ == '__main__':
main()

View File

@@ -0,0 +1,363 @@
"""실시간 1분봉 볼륨 가속 트레이더.
4봉 연속 가격+볼륨 가속 시그널(VOL≥8x) 감지 후 실제 매수/매도 + Telegram 알림.
실행:
.venv/bin/python3 daemons/live_trader.py
로그:
/tmp/live_trader.log
"""
import sys, os, time, logging, requests
from datetime import datetime
from typing import Optional
import pandas as pd
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from dotenv import load_dotenv
load_dotenv(os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), '.env'))
import pyupbit
# ── 전략 파라미터 ──────────────────────────────────────────────────────────────
TICKERS = [
'KRW-XRP', 'KRW-BTC', 'KRW-ETH', 'KRW-SOL', 'KRW-DOGE',
'KRW-ADA', 'KRW-SUI', 'KRW-NEAR', 'KRW-KAVA', 'KRW-SXP',
'KRW-AKT', 'KRW-SONIC', 'KRW-IP', 'KRW-ORBS', 'KRW-VIRTUAL',
'KRW-BARD', 'KRW-XPL', 'KRW-KITE', 'KRW-ENSO', 'KRW-0G',
]
VOL_LOOKBACK = 61
ATR_LOOKBACK = 28
FETCH_BARS = 100
VOL_MIN = 8.0
ATR_MULT = 1.0
ATR_MIN_R = 0.030 # 3.0% (ATR 계산용, ⑤ trail에서는 미사용)
ATR_MAX_R = 0.050 # 5.0%
# ── Cascade 청산 파라미터 ──────────────────────────────────────────────────────
# (시작분, 종료분, limit 수익률)
CASCADE_STAGES = [
(0, 2, 0.020), # ① 0~ 2분: 현재가 >= 진입가×1.020 → 청산
(2, 5, 0.010), # ② 2~ 5분: 현재가 >= 진입가×1.010 → 청산
(5, 35, 0.005), # ③ 5~35분: 현재가 >= 진입가×1.005 → 청산
(35, 155, 0.001), # ④ 35~155분: 현재가 >= 진입가×1.001 → 청산 (본전)
]
TRAIL_STOP_R = 0.004 # ⑤ 155분~: Trail Stop 0.4%
MAX_POS = int(os.environ.get('MAX_POSITIONS', 3))
PER_POS = int(os.environ.get('MAX_BUDGET', 15_000_000)) // MAX_POS
FEE = 0.0005
POLL_SEC = 65
API_DELAY = 0.12
TIMEOUT_BARS = 240 # 4시간: ⑤ Trail 구간에서 본전 이하 시 청산
SIM_MODE = os.environ.get('SIMULATION_MODE', 'true').lower() == 'true'
# ── Upbit 클라이언트 ───────────────────────────────────────────────────────────
upbit = pyupbit.Upbit(os.environ['ACCESS_KEY'], os.environ['SECRET_KEY'])
# ── Telegram ──────────────────────────────────────────────────────────────────
TG_TOKEN = os.environ.get('TELEGRAM_TRADE_TOKEN', '')
TG_CHAT_ID = os.environ.get('TELEGRAM_CHAT_ID', '')
def tg(msg: str) -> None:
if not TG_TOKEN or not TG_CHAT_ID:
return
try:
requests.post(
f'https://api.telegram.org/bot{TG_TOKEN}/sendMessage',
json={'chat_id': TG_CHAT_ID, 'text': msg, 'parse_mode': 'HTML'},
timeout=5,
)
except Exception as e:
log.warning(f'Telegram 전송 실패: {e}')
# ── 로깅 ──────────────────────────────────────────────────────────────────────
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s %(levelname)s %(message)s',
handlers=[
logging.FileHandler('/tmp/live_trader.log'),
logging.StreamHandler(sys.stdout),
]
)
log = logging.getLogger(__name__)
# ── 지표 계산 ─────────────────────────────────────────────────────────────────
def compute_indicators(df: pd.DataFrame) -> pd.DataFrame:
vol_ma = df['volume'].rolling(VOL_LOOKBACK, min_periods=30).mean().shift(2)
df = df.copy()
df['vr'] = df['volume'] / vol_ma
prev_close = df['close'].shift(1)
tr = pd.concat([
df['high'] - df['low'],
(df['high'] - prev_close).abs(),
(df['low'] - prev_close).abs(),
], axis=1).max(axis=1)
df['atr_raw'] = tr.rolling(ATR_LOOKBACK, min_periods=10).mean() / prev_close
return df
def check_signal(df: pd.DataFrame) -> Optional[dict]:
"""마지막 3봉(완성봉) 가격+볼륨 가속 조건 체크."""
if len(df) < VOL_LOOKBACK + 10:
return None
b = df.iloc[-4:-1] # 완성된 마지막 3봉
if len(b) < 3:
return None
c = b['close'].values
o = b['open'].values
vr = b['vr'].values
if not all(c[i] > o[i] for i in range(3)): return None # 양봉
if not (c[2] > c[1] > c[0]): return None # 가격 가속
if not (vr[2] > vr[1] > vr[0]): return None # 볼륨 가속
if vr[2] < VOL_MIN: return None # VOL 임계값
atr_raw = float(b['atr_raw'].iloc[-1]) if not pd.isna(b['atr_raw'].iloc[-1]) else 0.0
return {
'sig_ts': b.index[-1],
'sig_price': float(c[2]),
'vr': list(vr),
'prices': list(c),
'atr_raw': atr_raw,
}
# ── 주문 ──────────────────────────────────────────────────────────────────────
def do_buy(ticker: str, krw_amount: int) -> Optional[float]:
"""시장가 매수. 실제 체결 수량 반환. 실패 시 None."""
if SIM_MODE:
current = pyupbit.get_current_price(ticker)
qty = krw_amount * (1 - FEE) / current
log.info(f"[SIM 매수] {ticker} {krw_amount:,}원 → {qty:.6f}개 @ {current:,.0f}")
return qty
try:
krw_bal = upbit.get_balance("KRW")
if krw_bal is None or krw_bal < krw_amount:
log.warning(f"KRW 잔고 부족: {krw_bal:,.0f}원 < {krw_amount:,}")
return None
order = upbit.buy_market_order(ticker, krw_amount)
if not order or 'error' in str(order):
log.error(f"매수 주문 실패: {order}")
return None
# 체결 대기 후 실제 보유량 조회
time.sleep(1.5)
coin = ticker.split('-')[1]
qty = upbit.get_balance(coin)
log.info(f"[매수 완료] {ticker} {krw_amount:,}원 → {qty:.6f}개 uuid={order.get('uuid','')[:8]}")
return qty if qty and qty > 0 else None
except Exception as e:
log.error(f"매수 오류 {ticker}: {e}")
return None
def do_sell(ticker: str, qty: float) -> Optional[float]:
"""시장가 매도. 체결가(추정) 반환. 실패 시 None."""
if SIM_MODE:
current = pyupbit.get_current_price(ticker)
log.info(f"[SIM 매도] {ticker} {qty:.6f}개 @ {current:,.0f}")
return current
try:
order = upbit.sell_market_order(ticker, qty)
if not order or 'error' in str(order):
log.error(f"매도 주문 실패: {order}")
return None
time.sleep(1.5)
current = pyupbit.get_current_price(ticker)
log.info(f"[매도 완료] {ticker} {qty:.6f}개 uuid={order.get('uuid','')[:8]}")
return current
except Exception as e:
log.error(f"매도 오류 {ticker}: {e}")
return None
# ── 포지션 관리 ───────────────────────────────────────────────────────────────
positions: dict = {}
def enter_position(ticker: str, sig: dict, entry_price: float) -> None:
ar = sig['atr_raw']
atr_stop = max(ATR_MIN_R, min(ATR_MAX_R, ar * ATR_MULT)) if ar > 0 else ATR_MAX_R
qty = do_buy(ticker, PER_POS)
if qty is None:
log.warning(f"[진입 실패] {ticker} — 매수 주문 오류")
return
positions[ticker] = {
'entry_price': entry_price,
'entry_ts': datetime.now(),
'running_peak': entry_price,
'qty': qty,
'vr': sig['vr'],
'prices': sig['prices'],
}
log.info(f"[진입] {ticker} {entry_price:,.0f}원 vol {sig['vr'][2]:.1f}x cascade①2%②1%③0.5%④0.1%⑤trail0.4%")
tg(
f"🟢 <b>매수 완료</b> {ticker}\n"
f"체결가: {entry_price:,.0f}원 수량: {qty:.6f}\n"
f"전략: ①2% ②1% ③0.5% ④0.1%(본전) ⑤Trail0.4%\n"
f"{'[시뮬]' if SIM_MODE else '[실거래]'}"
)
def _do_exit(ticker: str, current_price: float, reason: str) -> bool:
"""공통 청산 처리. reason: 'trail' | 'timeout'"""
pos = positions[ticker]
exit_price = do_sell(ticker, pos['qty'])
if exit_price is None:
exit_price = current_price
pnl = (exit_price - pos['entry_price']) / pos['entry_price'] * 100
krw = PER_POS * (pnl / 100) - PER_POS * FEE * 2
held = int((datetime.now() - pos['entry_ts']).total_seconds() / 60)
icon = "" if pnl > 0 else "🔴"
reason_tag = {
'①2%': '① +2.0% 익절',
'②1%': '② +1.0% 익절',
'③0.5%': '③ +0.5% 익절',
'④0.1%': '④ +0.1% 본전',
'⑤trail': '⑤ 트레일스탑',
'timeout': '⑤ 타임아웃',
}.get(reason, reason)
msg = (
f"{icon} <b>청산</b> {ticker} [{reason_tag}]\n"
f"진입: {pos['entry_price']:,.0f}\n"
f"고점: {pos['running_peak']:,.0f}원 ({(pos['running_peak']/pos['entry_price']-1)*100:+.2f}%)\n"
f"청산: {exit_price:,.0f}\n"
f"PNL: <b>{pnl:+.2f}%</b> ({krw:+,.0f}원) {held}분 보유\n"
f"{'[시뮬]' if SIM_MODE else '[실거래]'}"
)
log.info(
f"[청산/{reason}] {ticker} {exit_price:,.0f}"
f"PNL {pnl:+.2f}% {krw:+,.0f}{held}분 보유"
)
tg(msg)
del positions[ticker]
return True
def update_position(ticker: str, current_price: float) -> bool:
"""Cascade 청산 체크. 청산 시 True 반환.
① 0~ 2분: +2.0% limit
② 2~ 5분: +1.0% limit
③ 5~35분: +0.5% limit
④ 35~155분: +0.1% limit (본전)
⑤ 155분~: Trail Stop 0.4%
"""
pos = positions[ticker]
ep = pos['entry_price']
held = int((datetime.now() - pos['entry_ts']).total_seconds() / 60)
# 항상 고점 갱신 (⑤ trail 진입 시 정확한 고점 기준)
pos['running_peak'] = max(pos['running_peak'], current_price)
# ①②③④: cascade limit 단계
stage_labels = {0: '①2%', 2: '②1%', 5: '③0.5%', 35: '④0.1%'}
for start, end, lr in CASCADE_STAGES:
if start <= held < end:
if current_price >= ep * (1 + lr):
return _do_exit(ticker, current_price, stage_labels[start])
return False
# ⑤: Trail Stop 0.4%
drop = (pos['running_peak'] - current_price) / pos['running_peak']
if drop >= TRAIL_STOP_R:
return _do_exit(ticker, current_price, '⑤trail')
# 타임아웃: 4시간 경과 + 본전 이하
if held >= TIMEOUT_BARS and current_price <= ep:
return _do_exit(ticker, current_price, 'timeout')
return False
# ── 메인 루프 ─────────────────────────────────────────────────────────────────
def run_once() -> None:
for ticker in TICKERS:
try:
df = pyupbit.get_ohlcv(ticker, interval='minute1', count=FETCH_BARS)
time.sleep(API_DELAY)
except Exception as e:
log.warning(f"{ticker} API 오류: {e}")
continue
if df is None or len(df) < 30:
continue
df = compute_indicators(df)
current_price = float(df['close'].iloc[-1])
# 열린 포지션: trail stop 체크
if ticker in positions:
update_position(ticker, current_price)
continue
# 신규 진입: 시그널 체크 (슬롯 여부와 관계없이 항상 탐지)
sig = check_signal(df)
if sig:
vr = sig['vr']
pr = sig['prices']
slot_tag = f"→ 매수 진행" if len(positions) < MAX_POS else f"⚠️ 슬롯 {len(positions)}/{MAX_POS} 꽉 참"
# 시그널 감지 즉시 알림
tg(
f"🔔 <b>시그널</b> {ticker}\n"
f"가격: {pr[0]:,.0f}{pr[1]:,.0f}{pr[2]:,.0f}\n"
f"볼륨: {vr[0]:.1f}x→{vr[1]:.1f}x→{vr[2]:.1f}x\n"
f"현재가: {current_price:,.0f}원 ATR: {sig['atr_raw']*100:.2f}%\n"
f"{slot_tag}"
)
log.info(f"[시그널] {ticker} {current_price:,.0f}원 vol {vr[2]:.1f}x {slot_tag}")
if len(positions) < MAX_POS:
enter_position(ticker, sig, current_price)
pos_str = ', '.join(
f"{t}({p['entry_price']:,.0f}{p['running_peak']:,.0f}, {((p['running_peak']/p['entry_price'])-1)*100:+.1f}%)"
for t, p in positions.items()
) or "없음"
log.info(f"[상태] 포지션 {len(positions)}/{MAX_POS}: {pos_str}")
def main():
mode = "🔴 실거래" if not SIM_MODE else "🟡 시뮬레이션"
log.info(f"=== 실시간 트레이더 시작 ({mode}) ===")
log.info(f"전략: 3봉 vol가속 VOL≥{VOL_MIN}x, cascade①2%②1%③0.5%④0.1%⑤Trail{TRAIL_STOP_R*100:.1f}%")
log.info(f"종목: {len(TICKERS)}개 포지션당 {PER_POS:,}원 최대 {MAX_POS}")
tg(
f"🚀 <b>트레이더 시작</b> ({mode})\n"
f"3봉 VOL≥{VOL_MIN}x cascade①2%②1%③0.5%④0.1%⑤Trail{TRAIL_STOP_R*100:.1f}%\n"
f"종목 {len(TICKERS)}개 포지션당 {PER_POS:,}원 최대 {MAX_POS}"
)
while True:
t0 = time.time()
try:
run_once()
except Exception as e:
log.error(f"루프 오류: {e}")
elapsed = time.time() - t0
sleep = max(1.0, POLL_SEC - elapsed)
log.info(f"[대기] {sleep:.0f}초 후 다음 체크")
time.sleep(sleep)
if __name__ == '__main__':
main()

View File

@@ -13,30 +13,39 @@ logging.basicConfig(
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
handlers=[
logging.StreamHandler(),
logging.FileHandler("trading.log", encoding="utf-8"),
logging.FileHandler("logs/trading.log", encoding="utf-8"),
],
)
from core.monitor import run_monitor
from core.notify import notify_error, notify_status
from core.price_collector import run_collector
from core.trader import get_positions, restore_positions
from core.price_collector import backfill_prices, run_collector
from core.price_db import get_cumulative_krw_profit
from core.trader import get_positions, get_budget_info, restore_positions
from daemon.runner import run_scanner
STATUS_INTERVAL = 3600 # 1시간마다 요약 전송
def run_status_reporter(interval: int = STATUS_INTERVAL) -> None:
"""주기적으로 포지션 현황을 Telegram으로 전송."""
def run_status_reporter() -> None:
"""매 정각마다 1시간 이상 보유 포지션 현황 전송."""
import datetime as _dt
logger = logging.getLogger("status")
logger.info(f"상태 리포터 시작 (주기={interval//60})")
time.sleep(interval) # 첫 전송은 1시간 후
logger.info("상태 리포터 시작 (매 정각 트리거)")
while True:
now = _dt.datetime.now()
# 다음 정각까지 대기
secs_to_next_hour = (60 - now.minute) * 60 - now.second
time.sleep(secs_to_next_hour)
try:
notify_status(dict(get_positions()))
budget = get_budget_info()
cum = get_cumulative_krw_profit()
notify_status(
dict(get_positions()),
max_budget=budget["max_budget"],
per_position=budget["per_position"],
cum_profit=cum,
)
except Exception as e:
logger.error(f"상태 리포트 오류: {e}")
time.sleep(interval)
def main() -> None:
@@ -45,19 +54,23 @@ def main() -> None:
# 재시작 시 기존 잔고 복원 (이중 매수 방지)
restore_positions()
# 과거 가격 백필 (추세 판단용 DB 데이터가 없는 경우 채움)
logger.info("과거 가격 백필 시작 (48시간)...")
backfill_prices(hours=48)
# 트레일링 스탑 감시 스레드 (10초 주기)
monitor_thread = threading.Thread(
target=run_monitor, args=(10,), daemon=True, name="monitor"
)
monitor_thread.start()
# 1시간 주기 상태 리포트 스레드
# 매 정각 상태 리포트 스레드 (1시간 이상 보유 포지션만)
status_thread = threading.Thread(
target=run_status_reporter, daemon=True, name="status"
)
status_thread.start()
# 10분 주기 가격 수집 스레드 (추세 판단용 DB 저장)
# 가격 수집 스레드 (10분 주기 → Oracle DB price_history 저장, 추세 판단용)
collector_thread = threading.Thread(
target=run_collector, daemon=True, name="collector"
)

214
archive/tests/atr_sweep.py Normal file
View File

@@ -0,0 +1,214 @@
"""ATR_MAX_STOP 파라미터 스윕 시뮬레이션.
실제 봇과 동일하게 ATR을 계산하되, ATR_MAX_STOP 상한만 바꿔가며 성과를 비교한다.
- ATR_MIN_STOP = 1.0% (고정)
- ATR_MULT = 1.5 (고정)
- ATR_CANDLES = 5 (고정)
- ATR_MAX_STOP : [1.5%, 2.0%, 2.5%, 3.0%, 3.5%, 4.0%] 스윕
데이터: Oracle ADB ohlcv_hourly (top30_tickers.pkl 상위 20종목)
"""
import pickle
import sys
from pathlib import Path
from dotenv import load_dotenv
load_dotenv(dotenv_path=Path(__file__).parent / ".env")
sys.path.insert(0, str(Path(__file__).parent))
from ohlcv_db import load_from_db
import pandas as pd
# ── 고정 파라미터 ─────────────────────────────────────────
TIME_STOP_HOURS = 8
TIME_STOP_MIN_PCT = 3.0
FEE = 0.0005
LOCAL_VOL_HOURS = 5
VOL_MULT = 2.0
PRICE_QUIET_PCT = 2.0
SIGNAL_TIMEOUT_H = 8
THRESH = 4.8
FROM_DATE = "2025-03-02"
# ATR 고정값
ATR_CANDLES = 5
ATR_MULT = 1.5
ATR_MIN = 0.010 # 1.0%
# 스윕 대상: ATR_MAX_STOP
ATR_MAX_CANDIDATES = [0.015, 0.020, 0.025, 0.030, 0.035, 0.040]
TOP30_FILE = Path("top30_tickers.pkl")
# ── 매수 시점 ATR 계산 ────────────────────────────────────
def calc_atr_stop(df: pd.DataFrame, buy_idx: int, atr_max: float) -> float:
"""매수 직전 ATR_CANDLES개 봉으로 스탑 비율 계산.
실제 봇(monitor.py)의 _get_adaptive_stop() 로직과 동일.
계산 실패 시 ATR_MIN 반환.
"""
start = max(0, buy_idx - ATR_CANDLES - 1)
sub = df.iloc[start:buy_idx]
if len(sub) < ATR_CANDLES:
return ATR_MIN
try:
ranges = (sub["high"] - sub["low"]) / sub["low"]
avg_range = ranges.iloc[-ATR_CANDLES:].mean()
return float(max(ATR_MIN, min(atr_max, avg_range * ATR_MULT)))
except Exception:
return ATR_MIN
# ── 포지션 시뮬 ───────────────────────────────────────────
def simulate_pos(df: pd.DataFrame, buy_idx: int, buy_price: float, stop_pct: float):
"""매수 후 청산 시뮬레이션 (고정 stop_pct 사용)."""
buy_dt = df.index[buy_idx]
peak = buy_price
for i in range(buy_idx + 1, len(df)):
row = df.iloc[i]
ts = df.index[i]
if row["high"] > peak:
peak = row["high"]
stop_price = peak * (1 - stop_pct)
elapsed_h = (ts - buy_dt).total_seconds() / 3600
# 트레일링 스탑
if row["low"] <= stop_price:
sell_price = stop_price
pnl = (sell_price * (1 - FEE) - buy_price * (1 + FEE)) / (buy_price * (1 + FEE)) * 100
return pnl > 0, sell_price, ts, f"트레일링({pnl:+.1f}%)", pnl
# 타임 스탑
pnl_now = (row["close"] - buy_price) / buy_price * 100
if elapsed_h >= TIME_STOP_HOURS and pnl_now < TIME_STOP_MIN_PCT:
pnl = (row["close"] * (1 - FEE) - buy_price * (1 + FEE)) / (buy_price * (1 + FEE)) * 100
return pnl > 0, row["close"], ts, "타임스탑", pnl
last = df.iloc[-1]["close"]
pnl = (last * (1 - FEE) - buy_price * (1 + FEE)) / (buy_price * (1 + FEE)) * 100
return pnl > 0, last, df.index[-1], "데이터종료", pnl
# ── vol-lead 전략 실행 (ATR_MAX 동적 주입) ────────────────
def run_vol_lead(df: pd.DataFrame, thresh: float, atr_max: float) -> list:
"""vol-lead 신호 → 진입 → ATR 기반 청산 시뮬.
진입 시점의 ATR을 계산해 stop_pct를 결정하고 청산 시뮬에 전달.
"""
trades = []
signal_i = None
signal_price = None
in_pos = False
buy_idx = buy_price = stop_pct = None
i = max(12, LOCAL_VOL_HOURS + 2)
while i < len(df):
if in_pos:
is_win, sp, sdt, reason, pnl = simulate_pos(df, buy_idx, buy_price, stop_pct)
next_i = next((j for j in range(i, len(df)) if df.index[j] > sdt), len(df))
trades.append((is_win, pnl, df.index[buy_idx], sdt, reason, stop_pct))
in_pos = False
signal_i = None
signal_price = None
i = next_i
continue
close = df.iloc[i]["close"]
close_2h = df.iloc[i - 2]["close"]
quiet = abs(close - close_2h) / close_2h * 100 < PRICE_QUIET_PCT
vol_recent = df.iloc[i - 1]["volume"]
vol_avg = df.iloc[i - LOCAL_VOL_HOURS - 1:i - 1]["volume"].mean()
vol_spike = vol_avg > 0 and vol_recent >= vol_avg * VOL_MULT
if quiet and vol_spike:
if signal_i is None:
signal_i = i
signal_price = close
else:
if signal_i is not None and close < signal_price:
signal_i = signal_price = None
if signal_i is not None and (i - signal_i) > SIGNAL_TIMEOUT_H:
signal_i = signal_price = None
if signal_i is not None:
move = (close - signal_price) / signal_price * 100
if move >= thresh:
in_pos = True
buy_idx = i
buy_price = close
stop_pct = calc_atr_stop(df, i, atr_max) # ← 진입 시점 ATR 계산
signal_i = signal_price = None
i += 1
return trades
# ── 최대 낙폭 계산 ────────────────────────────────────────
def calc_max_drawdown(trades: list) -> float:
if not trades:
return 0.0
cum = peak = max_dd = 0.0
for t in sorted(trades, key=lambda x: x[2]):
cum += t[1]
if cum > peak:
peak = cum
dd = peak - cum
if dd > max_dd:
max_dd = dd
return max_dd
# ── 메인 ─────────────────────────────────────────────────
def main() -> None:
top30: list = pickle.load(open(TOP30_FILE, "rb"))
print(f"DB 로드 중... ({len(top30)}종목)")
data = load_from_db(top30, from_date=FROM_DATE)
valid = [t for t in top30 if t in data and len(data[t]) >= 500]
use20 = valid[:20]
print(f"유효 종목: {len(use20)}\n")
print(f"{'='*72}")
print(f"ATR_MAX_STOP 스윕 | ATR×{ATR_MULT} (최소={ATR_MIN:.1%}) | vol-lead +{THRESH}% | {len(use20)}종목")
print(f"{'='*72}")
print(f"{'ATR_MAX':>8} | {'거래수':>6} | {'승률':>6} | {'누적PnL%':>10} | {'최대낙폭%':>10} | {'평균스탑%':>9}")
print(f"{''*72}")
for atr_max in ATR_MAX_CANDIDATES:
all_trades = []
for ticker in use20:
if ticker not in data:
continue
trades = run_vol_lead(data[ticker], THRESH, atr_max)
all_trades.extend(trades)
total = len(all_trades)
if total == 0:
print(f"{atr_max*100:>7.1f}% | {'0':>6} | {'N/A':>6} | {'N/A':>10} | {'N/A':>10} | {'N/A':>9}")
continue
wins = sum(1 for t in all_trades if t[0])
win_rate = wins / total * 100
cum_pnl = sum(t[1] for t in all_trades)
max_dd = calc_max_drawdown(all_trades)
avg_stop = sum(t[5] for t in all_trades) / total * 100 # 실제 평균 스탑%
print(f"{atr_max*100:>7.1f}% | {total:>6}건 | {win_rate:>5.1f}% | "
f"{cum_pnl:>+9.2f}% | {-max_dd:>+9.2f}% | {avg_stop:>8.2f}%")
print(f"{'='*72}")
print("\n※ 평균스탑% = 실제 거래에서 적용된 ATR 스탑의 평균 (ATR_MAX에 걸렸는지 확인)")
if __name__ == "__main__":
main()

1803
archive/tests/backtest.py Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,250 @@
"""백테스트 결과 Oracle DB 저장 모듈.
테이블:
backtest_runs - 실행 단위 (실행시각, 설명, 파라미터)
backtest_results - 조건별 집계 (run_id + label)
backtest_trade_log - 개별 거래 (run_id + label + 종목 + pnl + fng + ...)
"""
import os as _os, sys as _sys
_sys.path.insert(0, _os.path.dirname(_os.path.dirname(_os.path.abspath(__file__))))
from __future__ import annotations
import json
import os
from contextlib import contextmanager
from datetime import datetime
from pathlib import Path
from typing import Generator
import oracledb
from dotenv import load_dotenv
load_dotenv(dotenv_path=Path(__file__).parent / ".env")
_pool: oracledb.ConnectionPool | None = None
def _get_pool() -> oracledb.ConnectionPool:
global _pool
if _pool is None:
kwargs: dict = dict(
user=os.environ["ORACLE_USER"],
password=os.environ["ORACLE_PASSWORD"],
dsn=os.environ["ORACLE_DSN"],
min=1,
max=3,
increment=1,
)
wallet = os.environ.get("ORACLE_WALLET")
if wallet:
kwargs["config_dir"] = wallet
_pool = oracledb.create_pool(**kwargs)
return _pool
@contextmanager
def _conn() -> Generator[oracledb.Connection, None, None]:
pool = _get_pool()
conn = pool.acquire()
try:
yield conn
conn.commit()
except Exception:
conn.rollback()
raise
finally:
pool.release(conn)
# ── DDL ────────────────────────────────────────────────────────
_DDL_RUNS = """
CREATE TABLE backtest_runs (
run_id VARCHAR2(36) DEFAULT SYS_GUID() PRIMARY KEY,
run_name VARCHAR2(200) NOT NULL,
description VARCHAR2(1000),
params_json CLOB,
created_at TIMESTAMP DEFAULT SYSTIMESTAMP NOT NULL
)
"""
_DDL_RESULTS = """
CREATE TABLE backtest_results (
id NUMBER GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
run_id VARCHAR2(36) NOT NULL,
label VARCHAR2(100) NOT NULL,
n_trades NUMBER,
win_rate NUMBER(6,3),
avg_pnl NUMBER(10,4),
total_pnl NUMBER(12,4),
rr NUMBER(8,4),
avg_win NUMBER(10,4),
avg_loss NUMBER(10,4),
max_dd NUMBER(10,4),
fng_lo NUMBER,
fng_hi NUMBER,
created_at TIMESTAMP DEFAULT SYSTIMESTAMP NOT NULL,
CONSTRAINT fk_br_run FOREIGN KEY (run_id) REFERENCES backtest_runs(run_id)
)
"""
_DDL_TRADES = """
CREATE TABLE backtest_trade_log (
id NUMBER GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
run_id VARCHAR2(36) NOT NULL,
label VARCHAR2(100),
ticker VARCHAR2(20),
pnl NUMBER(10,4),
hold_h NUMBER,
fng_val NUMBER,
exit_type VARCHAR2(10),
created_at TIMESTAMP DEFAULT SYSTIMESTAMP NOT NULL,
CONSTRAINT fk_bt_run FOREIGN KEY (run_id) REFERENCES backtest_runs(run_id)
)
"""
def ensure_tables() -> None:
"""백테스트 테이블이 없으면 생성."""
with _conn() as conn:
cur = conn.cursor()
for tbl_name, ddl in [
("BACKTEST_RUNS", _DDL_RUNS),
("BACKTEST_RESULTS", _DDL_RESULTS),
("BACKTEST_TRADE_LOG", _DDL_TRADES),
]:
cur.execute(
"SELECT COUNT(*) FROM user_tables WHERE table_name=:1", [tbl_name]
)
if cur.fetchone()[0] == 0:
cur.execute(ddl)
print(f" {tbl_name} 테이블 생성 완료")
# ── 삽입 헬퍼 ──────────────────────────────────────────────────
def insert_run(run_name: str, description: str = "", params: dict | None = None) -> str:
"""새 백테스트 실행 레코드 삽입. run_id 반환."""
sql = """
INSERT INTO backtest_runs (run_name, description, params_json)
VALUES (:rname, :rdesc, :rparams)
RETURNING run_id INTO :out_id
"""
with _conn() as conn:
cur = conn.cursor()
out = cur.var(oracledb.STRING)
cur.execute(sql, {
"rname": run_name,
"rdesc": description,
"rparams": json.dumps(params or {}, ensure_ascii=False),
"out_id": out,
})
return out.getvalue()[0]
def insert_result(
run_id: str,
label: str,
stats: dict,
fng_lo: int | None = None,
fng_hi: int | None = None,
) -> None:
"""조건별 집계 결과 삽입."""
sql = """
INSERT INTO backtest_results
(run_id, label, n_trades, win_rate, avg_pnl, total_pnl,
rr, avg_win, avg_loss, max_dd, fng_lo, fng_hi)
VALUES
(:run_id, :label, :n, :wr, :avg_pnl, :total_pnl,
:rr, :avg_win, :avg_loss, :max_dd, :fng_lo, :fng_hi)
"""
with _conn() as conn:
conn.cursor().execute(sql, {
"run_id": run_id,
"label": label,
"n": stats.get("n", 0),
"wr": round(stats.get("wr", 0), 3),
"avg_pnl": round(stats.get("avg_pnl", 0), 4),
"total_pnl": round(stats.get("total_pnl", 0), 4),
"rr": round(stats.get("rr", 0), 4),
"avg_win": round(stats.get("avg_win", 0), 4),
"avg_loss": round(stats.get("avg_loss", 0), 4),
"max_dd": round(stats.get("max_dd", 0), 4),
"fng_lo": fng_lo,
"fng_hi": fng_hi,
})
def insert_trades_bulk(
run_id: str,
label: str,
ticker: str,
trades: list,
) -> None:
"""개별 거래 목록 일괄 삽입."""
if not trades:
return
sql = """
INSERT INTO backtest_trade_log
(run_id, label, ticker, pnl, hold_h, fng_val, exit_type)
VALUES (:run_id, :label, :ticker, :pnl, :hold_h, :fng_val, :exit_type)
"""
rows = []
for t in trades:
rows.append({
"run_id": run_id,
"label": label,
"ticker": ticker,
"pnl": round(float(getattr(t, "pnl", 0)), 4),
"hold_h": int(getattr(t, "h", 0)),
"fng_val": int(getattr(t, "fng", 0)),
"exit_type": str(getattr(t, "exit", "")),
})
with _conn() as conn:
conn.cursor().executemany(sql, rows)
# ── 조회 ───────────────────────────────────────────────────────
def list_runs(limit: int = 20) -> list[dict]:
"""최근 백테스트 실행 목록 반환."""
sql = """
SELECT run_id, run_name, description, created_at
FROM backtest_runs
ORDER BY created_at DESC
FETCH FIRST :n ROWS ONLY
"""
with _conn() as conn:
cur = conn.cursor()
cur.execute(sql, {"n": limit})
rows = cur.fetchall()
return [
{"run_id": r[0], "run_name": r[1], "description": r[2],
"created_at": r[3].strftime("%Y-%m-%d %H:%M")}
for r in rows
]
def get_results(run_id: str) -> list[dict]:
"""특정 run_id의 조건별 결과 반환."""
sql = """
SELECT label, n_trades, win_rate, avg_pnl, total_pnl,
rr, avg_win, avg_loss, max_dd, fng_lo, fng_hi
FROM backtest_results
WHERE run_id = :run_id
ORDER BY avg_pnl DESC
"""
with _conn() as conn:
cur = conn.cursor()
cur.execute(sql, {"run_id": run_id})
cols = ["label", "n_trades", "win_rate", "avg_pnl", "total_pnl",
"rr", "avg_win", "avg_loss", "max_dd", "fng_lo", "fng_hi"]
return [dict(zip(cols, r)) for r in cur.fetchall()]
if __name__ == "__main__":
print("백테스트 DB 테이블 확인/생성...")
ensure_tables()
print("완료. 최근 실행 목록:")
for r in list_runs(5):
print(f" {r['created_at']} {r['run_name']}")

View File

@@ -0,0 +1,360 @@
"""최근 N일 볼륨 가속 시그널 확인 + ATR trail stop 시뮬레이션.
4봉 연속 가격+볼륨 가속 시그널 발생 후 실제 trail stop 로직을 돌려
진입가·청산가·PNL을 표시.
최적 파라미터 (sweep_volaccel 기준):
N봉=4, VOL≥5.0x, ATR_MULT=1.5, ATR_MIN=1.5%, ATR_MAX=2.0%
"""
import sys, os
from datetime import datetime, timedelta
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from dotenv import load_dotenv
load_dotenv(os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), '.env'))
import oracledb
# ── 파라미터 ──────────────────────────────────────────────────────────────────
VOL_LOOKBACK = 61
ATR_LOOKBACK = 28
LOOKBACK_DAYS = 3 # 오늘 기준 며칠 전까지 시그널 탐색
VOL_MIN_LIST = [8.0]
VOL_MIN = min(VOL_MIN_LIST)
# ATR 비교 목록: (MULT, MIN%, MAX%) — 타이트→여유 순
ATR_SCENARIOS = [
(1.0, 0.010, 0.020), # 타이트: 1.0×ATR, 1.0~2.0%
(1.5, 0.015, 0.025), # 기존최적: 1.5×ATR, 1.5~2.5%
(1.0, 0.020, 0.030), # 고정 2.0~3.0%
(1.0, 0.030, 0.050), # 느슨: 3.0~5.0%
]
ATR_MULT = ATR_SCENARIOS[0][0]
ATR_MIN_R = ATR_SCENARIOS[0][1]
ATR_MAX_R = ATR_SCENARIOS[0][2]
MAX_TRAIL_BARS = 240
BUDGET = 15_000_000
MAX_POS = 3
PER_POS = BUDGET // MAX_POS
FEE = 0.0005
TICKERS = [
'KRW-XRP','KRW-BTC','KRW-ETH','KRW-SOL','KRW-DOGE',
'KRW-ADA','KRW-SUI','KRW-NEAR','KRW-KAVA','KRW-SXP',
'KRW-AKT','KRW-SONIC','KRW-IP','KRW-ORBS','KRW-VIRTUAL',
'KRW-BARD','KRW-XPL','KRW-KITE','KRW-ENSO','KRW-0G',
]
_TK = ",".join(f"'{t}'" for t in TICKERS)
# ── 시그널 SQL (4봉 가속 조건 전부 Oracle SQL에서 처리) ───────────────────────
SIGNAL_SQL = f"""
WITH
base AS (
SELECT ticker, ts, open_p, close_p, high_p, low_p, volume_p,
LAG(close_p, 1) OVER (PARTITION BY ticker ORDER BY ts) prev_close_1,
LAG(open_p, 1) OVER (PARTITION BY ticker ORDER BY ts) prev_open_1,
LAG(volume_p, 1) OVER (PARTITION BY ticker ORDER BY ts) prev_vol_1,
LAG(close_p, 2) OVER (PARTITION BY ticker ORDER BY ts) prev_close_2,
LAG(open_p, 2) OVER (PARTITION BY ticker ORDER BY ts) prev_open_2,
LAG(volume_p, 2) OVER (PARTITION BY ticker ORDER BY ts) prev_vol_2,
LAG(close_p, 3) OVER (PARTITION BY ticker ORDER BY ts) prev_close_3,
LAG(open_p, 3) OVER (PARTITION BY ticker ORDER BY ts) prev_open_3,
LAG(volume_p, 3) OVER (PARTITION BY ticker ORDER BY ts) prev_vol_3,
GREATEST(
high_p - low_p,
ABS(high_p - LAG(close_p,1) OVER (PARTITION BY ticker ORDER BY ts)),
ABS(low_p - LAG(close_p,1) OVER (PARTITION BY ticker ORDER BY ts))
) tr
FROM backtest_ohlcv
WHERE interval_cd = 'minute1'
AND ts >= TO_TIMESTAMP(:warmup_since, 'YYYY-MM-DD HH24:MI:SS')
AND ticker IN ({_TK})
),
indicators AS (
SELECT ticker, ts, open_p, close_p,
volume_p / NULLIF(
AVG(volume_p) OVER (PARTITION BY ticker ORDER BY ts
ROWS BETWEEN {VOL_LOOKBACK} PRECEDING AND 2 PRECEDING), 0
) vr0,
prev_vol_1 / NULLIF(
AVG(volume_p) OVER (PARTITION BY ticker ORDER BY ts
ROWS BETWEEN {VOL_LOOKBACK} PRECEDING AND 2 PRECEDING), 0
) vr1,
prev_vol_2 / NULLIF(
AVG(volume_p) OVER (PARTITION BY ticker ORDER BY ts
ROWS BETWEEN {VOL_LOOKBACK} PRECEDING AND 2 PRECEDING), 0
) vr2,
prev_vol_3 / NULLIF(
AVG(volume_p) OVER (PARTITION BY ticker ORDER BY ts
ROWS BETWEEN {VOL_LOOKBACK} PRECEDING AND 2 PRECEDING), 0
) vr3,
prev_close_1, prev_open_1,
prev_close_2, prev_open_2,
prev_close_3, prev_open_3,
AVG(tr) OVER (PARTITION BY ticker ORDER BY ts
ROWS BETWEEN {ATR_LOOKBACK} PRECEDING AND 1 PRECEDING)
/ NULLIF(prev_close_1, 0) atr_raw
FROM base
)
SELECT ticker, ts sig_ts, close_p sig_price,
vr0, vr1, vr2, vr3, atr_raw,
prev_close_3, prev_close_2, prev_close_1, close_p
FROM indicators
WHERE ts >= TO_TIMESTAMP(:check_since, 'YYYY-MM-DD HH24:MI:SS')
AND vr0 >= :min_vol
-- 4봉 연속: 양봉
AND close_p > open_p
AND prev_close_1 > prev_open_1
AND prev_close_2 > prev_open_2
AND prev_close_3 > prev_open_3
-- 4봉 연속: 가격 가속
AND close_p > prev_close_1
AND prev_close_1 > prev_close_2
AND prev_close_2 > prev_close_3
-- 4봉 연속: 볼륨 가속
AND vr0 > vr1
AND vr1 > vr2
AND vr2 > vr3
ORDER BY ticker, ts
"""
def _get_conn():
kwargs = dict(user=os.environ["ORACLE_USER"],
password=os.environ["ORACLE_PASSWORD"],
dsn=os.environ["ORACLE_DSN"])
wallet = os.environ.get("ORACLE_WALLET")
if wallet:
kwargs["config_dir"] = wallet
return oracledb.connect(**kwargs)
def simulate_trail(cur, ticker: str, entry_ts, entry_price: float,
atr_raw: float,
mult: float = None, min_r: float = None, max_r: float = None) -> dict:
"""entry_ts 봉부터 trail stop 시뮬. 상세 결과 dict 반환."""
m = mult if mult is not None else ATR_MULT
n = min_r if min_r is not None else ATR_MIN_R
x = max_r if max_r is not None else ATR_MAX_R
ar = atr_raw if (atr_raw and atr_raw == atr_raw) else 0.0
atr_stop = max(n, min(x, ar * m)) if ar > 0 else x
cur.execute(
"""SELECT ts, close_p FROM backtest_ohlcv
WHERE ticker=:t AND interval_cd='minute1'
AND ts >= :entry
ORDER BY ts FETCH FIRST :n ROWS ONLY""",
{"t": ticker, "entry": entry_ts, "n": MAX_TRAIL_BARS + 1}
)
bars = cur.fetchall()
if not bars:
return dict(status="데이터없음", exit_ts=entry_ts, exit_price=entry_price,
peak=entry_price, peak_pct=0.0, pnl=0.0, atr_stop=atr_stop,
held_bars=0)
running_peak = entry_price
for i, (ts, close_p) in enumerate(bars):
close_p = float(close_p)
running_peak = max(running_peak, close_p)
drop = (running_peak - close_p) / running_peak if running_peak > 0 else 0.0
if drop >= atr_stop:
pnl = (close_p - entry_price) / entry_price * 100
peak_pct = (running_peak - entry_price) / entry_price * 100
return dict(status="손절", exit_ts=ts, exit_price=close_p,
peak=running_peak, peak_pct=peak_pct,
pnl=pnl, atr_stop=atr_stop, held_bars=i + 1)
last_ts, last_price = bars[-1][0], float(bars[-1][1])
pnl = (last_price - entry_price) / entry_price * 100
peak_pct = (running_peak - entry_price) / entry_price * 100
status = "타임아웃" if len(bars) >= MAX_TRAIL_BARS else "진행중"
return dict(status=status, exit_ts=last_ts, exit_price=last_price,
peak=running_peak, peak_pct=peak_pct,
pnl=pnl, atr_stop=atr_stop, held_bars=len(bars))
def apply_pos_limit(sim_results: list, vol_thr: float) -> tuple:
"""VOL 필터 + MAX_POS 동시 포지션 제한 적용. (taken, skipped) 반환."""
filtered = [r for r in sim_results if r['vr'][3] >= vol_thr]
open_positions = []
taken, skipped = [], []
for r in filtered:
open_positions = [ex for ex in open_positions if ex > r['entry_ts']]
if len(open_positions) < MAX_POS:
open_positions.append(r['exit_ts'])
taken.append(r)
else:
skipped.append(r)
return taken, skipped
def print_detail(vol_thr: float, taken: list, skipped: list):
div = "" * 120
print(f"\n{''*120}")
print(f" 4봉 VOL≥{vol_thr:.0f}x | ATR={ATR_MULT}×[{ATR_MIN_R*100:.1f}~{ATR_MAX_R*100:.1f}%] "
f"| 자본 {BUDGET//10000}만원 / 포지션 {PER_POS//10000}만원 / 동시 {MAX_POS}")
print(f"{''*120}\n")
total_krw, total_wins = 0.0, 0
for i, r in enumerate(taken, 1):
vr = r['vr']
entry_str = str(r['entry_ts'])[:16]
exit_str = str(r['exit_ts'])[:16]
krw = PER_POS * (r['pnl'] / 100) - PER_POS * FEE * 2
total_krw += krw
won = r['pnl'] > 0
if won:
total_wins += 1
pr = r['prices']
# 봉별 가격 변화율 (봉1→2, 2→3, 3→4)
pchg = [
(pr[1] - pr[0]) / pr[0] * 100 if pr[0] else 0,
(pr[2] - pr[1]) / pr[1] * 100 if pr[1] else 0,
(pr[3] - pr[2]) / pr[2] * 100 if pr[2] else 0,
]
sign = "" if won else ""
print(f" #{i:02d} {r['ticker']:12s} [{sign}]")
print(f" 가격흐름 {pr[0]:,.0f}{pr[1]:,.0f} ({pchg[0]:+.2f}%)"
f"{pr[2]:,.0f} ({pchg[1]:+.2f}%)"
f"{pr[3]:,.0f} ({pchg[2]:+.2f}%)")
print(f" 볼륨흐름 {vr[0]:.1f}x → {vr[1]:.1f}x → {vr[2]:.1f}x → {vr[3]:.1f}x "
f"(ATR손절 {r['atr_stop']*100:.1f}%)")
print(f" 진입 {entry_str} @ {r['entry_price']:>13,.0f}")
print(f" 고점 {r['peak']:>13,.0f}원 ({r['peak_pct']:>+.2f}%까지 상승)")
print(f" 청산 {exit_str} @ {r['exit_price']:>13,.0f}"
f" ({r['status']}, {r['held_bars']}봉 보유)")
print(f" 손익 {r['pnl']:>+.2f}% → {krw:>+,.0f}")
print()
if skipped:
print(f" ── 스킵 {len(skipped)}건 (포지션 한도 초과) ──")
for r in skipped:
vr = r['vr']
krw_if = PER_POS * (r['pnl'] / 100) - PER_POS * FEE * 2
print(f" SKIP {r['ticker']:12s} 진입 {str(r['entry_ts'])[:16]} "
f"vol {vr[3]:.1f}x → {r['pnl']:>+.2f}% ({krw_if:>+,.0f}원 기회손실)")
print()
n = len(taken)
win_rate = total_wins / n * 100 if n else 0
ret_rate = total_krw / BUDGET * 100
print(div)
print(f" ◆ 실거래 {n}건 | 승률 {win_rate:.0f}% ({total_wins}/{n}) | "
f"합산 {total_krw:>+,.0f}원 | 수익률 {ret_rate:>+.2f}%")
print(div)
def main():
now = datetime.now()
check_since = (now - timedelta(days=LOOKBACK_DAYS)).strftime('%Y-%m-%d 00:00:00')
warmup_since = (now - timedelta(days=LOOKBACK_DAYS + 1)).strftime('%Y-%m-%d 00:00:00')
print(f"=== 4봉 볼륨 가속 시뮬 — VOL {VOL_MIN_LIST} 비교 ===")
print(f"ATR={ATR_MULT}×[{ATR_MIN_R*100:.1f}~{ATR_MAX_R*100:.1f}%], "
f"자본 {BUDGET/10000:.0f}만원 / 포지션당 {PER_POS/10000:.0f}만원 / 동시 {MAX_POS}")
print(f"조회 기간: {check_since[:10]} ~ 현재\n")
conn = _get_conn()
cur = conn.cursor()
cur.arraysize = 10_000
# ── 1회 조회 (VOL_MIN = 가장 낮은 값) ────────────────────────────────────────
cur.execute(SIGNAL_SQL, {
"warmup_since": warmup_since,
"check_since": check_since,
"min_vol": VOL_MIN,
})
rows = cur.fetchall()
if not rows:
print(f"해당 기간 VOL>={VOL_MIN}x 4봉 가속 시그널 없음")
conn.close()
return
# 진입봉 조회
signals = []
for row in rows:
ticker, sig_ts, sig_price, vr0, vr1, vr2, vr3, atr_raw, \
p3, p2, p1, p0 = row
cur.execute(
"""SELECT close_p, ts FROM backtest_ohlcv
WHERE ticker=:t AND interval_cd='minute1'
AND ts > :sig AND ts <= :sig + INTERVAL '3' MINUTE
ORDER BY ts FETCH FIRST 1 ROWS ONLY""",
{"t": ticker, "sig": sig_ts}
)
entry_row = cur.fetchone()
if not entry_row:
continue
entry_price, entry_ts = float(entry_row[0]), entry_row[1]
signals.append({
'ticker': ticker,
'sig_ts': sig_ts,
'entry_ts': entry_ts,
'entry_price': entry_price,
'vr': [float(x) if x else 0.0 for x in [vr3, vr2, vr1, vr0]],
'prices': [float(x) if x else 0.0 for x in [p3, p2, p1, p0]],
'atr_raw': float(atr_raw) if atr_raw else 0.0,
})
if not signals:
print("진입봉을 찾을 수 없음")
conn.close()
return
# ── trail stop 시뮬 — 시나리오별 ─────────────────────────────────────────────
signals.sort(key=lambda x: x['entry_ts'])
vol_thr = VOL_MIN_LIST[0]
# 각 시나리오에 맞는 bars를 재사용하기 위해 기본(가장 느슨한) 시나리오로 bars 캐시
# (atr_stop 달라도 bars 조회는 동일하므로 1회만 fetch)
print(f"시그널 {len(signals)}건 trail stop 시뮬 중 ({len(ATR_SCENARIOS)}가지 ATR 비교)...", flush=True)
# 시나리오별 결과 수집
scenario_results = []
for mult, min_r, max_r in ATR_SCENARIOS:
sim = []
for s in signals:
trail = simulate_trail(cur, s['ticker'], s['entry_ts'], s['entry_price'],
s['atr_raw'], mult=mult, min_r=min_r, max_r=max_r)
sim.append({**s, **trail})
taken, skipped = apply_pos_limit(sim, vol_thr)
scenario_results.append((mult, min_r, max_r, taken, skipped))
# ── 시나리오 요약 비교표 ──────────────────────────────────────────────────────
print(f"\n{''*95}")
print(f" {'ATR 설정':22s} {'거래':>4s} {'승률':>5s} {'합산손익':>12s} {'수익률':>6s} {'평균보유(분)':>10s}")
print(f"{''*95}")
best_krw = max(sum(PER_POS*(r['pnl']/100) - PER_POS*FEE*2 for r in taken)
for _, _, _, taken, _ in scenario_results)
for mult, min_r, max_r, taken, skipped in scenario_results:
n = len(taken)
if n == 0:
continue
wins = sum(1 for r in taken if r['pnl'] > 0)
total = sum(PER_POS*(r['pnl']/100) - PER_POS*FEE*2 for r in taken)
avg_held = sum(r['held_bars'] for r in taken) / n
ret = total / BUDGET * 100
label = f"{mult:.1f}×ATR [{min_r*100:.1f}~{max_r*100:.1f}%]"
star = "" if abs(total - best_krw) < 1 else ""
print(f" {label:22s} {n:>4d}{wins/n*100:>4.0f}% {total:>+12,.0f}{ret:>+5.2f}%"
f" {avg_held:>6.0f}{star}")
print(f"{''*95}")
# ── 최적 시나리오 건별 상세 출력 ─────────────────────────────────────────────
best_idx = max(range(len(scenario_results)),
key=lambda i: sum(PER_POS*(r['pnl']/100) - PER_POS*FEE*2
for r in scenario_results[i][3]))
bm, bn, bx, best_taken, best_skipped = scenario_results[best_idx]
print(f"\n[최적 시나리오 건별 상세: {bm:.1f}×ATR {bn*100:.1f}~{bx*100:.1f}%]")
print_detail(vol_thr, best_taken, best_skipped)
conn.close()
if __name__ == '__main__':
main()

View File

@@ -0,0 +1,153 @@
"""1년치 데이터 수집 — 10분봉 OHLCV + F&G 히스토리.
생성 파일:
data/sim1y_cache.pkl — {"10m": {ticker: DataFrame}} (10분봉, 365일)
data/fng_1y.json — {"YYYY-MM-DD": int, ...} (Fear & Greed 1년치)
소요 시간: 약 10~15분 (20종목 × 263 API 호출)
"""
import os as _os, sys as _sys
_sys.path.insert(0, _os.path.dirname(_os.path.dirname(_os.path.abspath(__file__))))
import json
import pickle
import time
import urllib.request
from datetime import datetime, timedelta
from pathlib import Path
import pandas as pd
import pyupbit
from dotenv import load_dotenv
load_dotenv(dotenv_path=Path(__file__).parent.parent / ".env")
# ── 설정 ─────────────────────────────────────────────────
CACHE_FILE = Path(__file__).parent.parent / "data" / "sim1y_cache.pkl"
FNG_FILE = Path(__file__).parent.parent / "data" / "fng_1y.json"
TOP30_FILE = Path(__file__).parent.parent / "data" / "top30_tickers.pkl"
DAYS = 365
TOP_N = 20
# ── 10분봉 수집 ───────────────────────────────────────────
def fetch_10m(ticker: str, days: int) -> "pd.DataFrame | None":
target_start = datetime.now() - timedelta(days=days)
all_dfs, to, prev_oldest = [], None, None
while True:
kwargs = dict(ticker=ticker, interval="minute10", count=200)
if to:
kwargs["to"] = to.strftime("%Y-%m-%d %H:%M:%S")
try:
df = pyupbit.get_ohlcv(**kwargs)
except Exception:
time.sleep(0.5)
break
if df is None or df.empty:
break
all_dfs.append(df)
oldest = df.index[0]
if prev_oldest is not None and oldest >= prev_oldest:
break
prev_oldest = oldest
if oldest <= target_start:
break
to = oldest
time.sleep(0.12)
if not all_dfs:
return None
combined = pd.concat(all_dfs).sort_index()
combined = combined[~combined.index.duplicated(keep="last")]
return combined[combined.index >= target_start]
# ── F&G 1년치 수집 ────────────────────────────────────────
def fetch_fng(limit: int = 400) -> dict:
url = f"https://api.alternative.me/fng/?limit={limit}&format=json"
with urllib.request.urlopen(url, timeout=15) as r:
data = json.loads(r.read())
result = {}
for e in data["data"]:
dt = datetime.fromtimestamp(int(e["timestamp"]))
result[dt.strftime("%Y-%m-%d")] = int(e["value"])
return result
# ── 메인 ─────────────────────────────────────────────────
def main():
# ── 종목 목록 ─────────────────────────────────────────
try:
from core.market import get_top_tickers
tickers = get_top_tickers()[:TOP_N]
print(f"Top{TOP_N} 종목 API 조회: {tickers}\n")
# top30 파일 갱신
pickle.dump(tickers, open(TOP30_FILE, "wb"))
except Exception as e:
print(f" [경고] 종목 API 실패: {e}")
if TOP30_FILE.exists():
tickers = pickle.load(open(TOP30_FILE, "rb"))[:TOP_N]
print(f" 기존 top30 파일 사용: {tickers}\n")
else:
print(" [오류] 종목 목록 없음. 종료.")
return
# ── F&G 1년치 ─────────────────────────────────────────
print("F&G 1년치 수집...")
try:
fng_map = fetch_fng(limit=400)
sorted_dates = sorted(fng_map.keys())
print(f" 기간: {sorted_dates[0]} ~ {sorted_dates[-1]} ({len(fng_map)}일)")
# 분포
zones = {"극공포(≤25)": 0, "공포(26~40)": 0, "중립(41~55)": 0,
"탐욕(56~75)": 0, "극탐욕(76+)": 0}
for v in fng_map.values():
if v <= 25: zones["극공포(≤25)"] += 1
elif v <= 40: zones["공포(26~40)"] += 1
elif v <= 55: zones["중립(41~55)"] += 1
elif v <= 75: zones["탐욕(56~75)"] += 1
else: zones["극탐욕(76+)"] += 1
total = sum(zones.values())
for name, cnt in zones.items():
print(f" {name:12} {cnt:>3}일 ({cnt/total*100:.1f}%)")
json.dump(fng_map, open(FNG_FILE, "w"))
print(f" 저장: {FNG_FILE}\n")
except Exception as e:
print(f" [오류] F&G 수집 실패: {e}\n")
fng_map = {}
# ── 10분봉 1년치 ──────────────────────────────────────
print(f"10분봉 {DAYS}일치 수집 중 ({len(tickers)}종목)...")
print(f" 예상 소요: {len(tickers) * 265 * 0.12 / 60:.0f}~{len(tickers) * 265 * 0.15 / 60:.0f}\n")
data = {"10m": {}}
for i, ticker in enumerate(tickers, 1):
start_t = time.time()
df = fetch_10m(ticker, DAYS)
elapsed = time.time() - start_t
if df is not None and len(df) > 500:
data["10m"][ticker] = df
candles = len(df)
period = f"{df.index[0].strftime('%Y-%m-%d')}~{df.index[-1].strftime('%Y-%m-%d')}"
print(f" {i:>2}/{len(tickers)} {ticker:<15} {candles:>6}{period} ({elapsed:.0f}s)")
else:
print(f" {i:>2}/{len(tickers)} {ticker:<15} 데이터 부족 ({elapsed:.0f}s)")
time.sleep(0.15)
# ── 저장 ──────────────────────────────────────────────
print(f"\n수집 완료: {len(data['10m'])}종목")
if data["10m"]:
sample = next(iter(data["10m"].values()))
print(f"기간: {sample.index[0].strftime('%Y-%m-%d')} ~ {sample.index[-1].strftime('%Y-%m-%d')}")
print(f"봉 수: {len(sample)}개 (10분봉)")
# 파일 크기 추정
import sys
size_mb = sys.getsizeof(pickle.dumps(data)) / 1024 / 1024
print(f"예상 크기: {size_mb:.1f} MB")
pickle.dump(data, open(CACHE_FILE, "wb"))
print(f"\n캐시 저장: {CACHE_FILE}")
print("완료!")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,323 @@
"""3% 고정 익절 vs 현재 trail stop 전략 비교 시뮬레이션.
전략:
A. Trail Stop only : ATR 1.0×[3.0~5.0%]
B. TP 3% + Trail Stop 손절 : +3% 도달 시 익절, 못 도달하면 trail stop
C. TP 2% + Trail Stop 손절 : +2% 도달 시 익절
D. TP 5% + Trail Stop 손절 : +5% 도달 시 익절
"""
import sys, os
from datetime import datetime, timedelta
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from dotenv import load_dotenv
load_dotenv(os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), '.env'))
import oracledb
# ── 파라미터 ──────────────────────────────────────────────────────────────────
VOL_LOOKBACK = 61
ATR_LOOKBACK = 28
LOOKBACK_DAYS = 3
VOL_MIN = 8.0
ATR_MULT = 1.0
ATR_MIN_R = 0.030
ATR_MAX_R = 0.050
MAX_TRAIL_BARS = 240
BUDGET = 15_000_000
MAX_POS = 3
PER_POS = BUDGET // MAX_POS
FEE = 0.0005
TICKERS = [
'KRW-XRP','KRW-BTC','KRW-ETH','KRW-SOL','KRW-DOGE',
'KRW-ADA','KRW-SUI','KRW-NEAR','KRW-KAVA','KRW-SXP',
'KRW-AKT','KRW-SONIC','KRW-IP','KRW-ORBS','KRW-VIRTUAL',
'KRW-BARD','KRW-XPL','KRW-KITE','KRW-ENSO','KRW-0G',
]
_TK = ",".join(f"'{t}'" for t in TICKERS)
SIGNAL_SQL = f"""
WITH
base AS (
SELECT ticker, ts, open_p, close_p, high_p, low_p, volume_p,
LAG(close_p, 1) OVER (PARTITION BY ticker ORDER BY ts) prev_close_1,
LAG(open_p, 1) OVER (PARTITION BY ticker ORDER BY ts) prev_open_1,
LAG(volume_p, 1) OVER (PARTITION BY ticker ORDER BY ts) prev_vol_1,
LAG(close_p, 2) OVER (PARTITION BY ticker ORDER BY ts) prev_close_2,
LAG(open_p, 2) OVER (PARTITION BY ticker ORDER BY ts) prev_open_2,
LAG(volume_p, 2) OVER (PARTITION BY ticker ORDER BY ts) prev_vol_2,
LAG(close_p, 3) OVER (PARTITION BY ticker ORDER BY ts) prev_close_3,
LAG(open_p, 3) OVER (PARTITION BY ticker ORDER BY ts) prev_open_3,
LAG(volume_p, 3) OVER (PARTITION BY ticker ORDER BY ts) prev_vol_3,
GREATEST(
high_p - low_p,
ABS(high_p - LAG(close_p,1) OVER (PARTITION BY ticker ORDER BY ts)),
ABS(low_p - LAG(close_p,1) OVER (PARTITION BY ticker ORDER BY ts))
) tr
FROM backtest_ohlcv
WHERE interval_cd = 'minute1'
AND ts >= TO_TIMESTAMP(:warmup_since, 'YYYY-MM-DD HH24:MI:SS')
AND ticker IN ({_TK})
),
indicators AS (
SELECT ticker, ts, open_p, close_p,
volume_p / NULLIF(
AVG(volume_p) OVER (PARTITION BY ticker ORDER BY ts
ROWS BETWEEN {VOL_LOOKBACK} PRECEDING AND 2 PRECEDING), 0
) vr0,
prev_vol_1 / NULLIF(
AVG(volume_p) OVER (PARTITION BY ticker ORDER BY ts
ROWS BETWEEN {VOL_LOOKBACK} PRECEDING AND 2 PRECEDING), 0
) vr1,
prev_vol_2 / NULLIF(
AVG(volume_p) OVER (PARTITION BY ticker ORDER BY ts
ROWS BETWEEN {VOL_LOOKBACK} PRECEDING AND 2 PRECEDING), 0
) vr2,
prev_vol_3 / NULLIF(
AVG(volume_p) OVER (PARTITION BY ticker ORDER BY ts
ROWS BETWEEN {VOL_LOOKBACK} PRECEDING AND 2 PRECEDING), 0
) vr3,
prev_close_1, prev_open_1,
prev_close_2, prev_open_2,
prev_close_3, prev_open_3,
AVG(tr) OVER (PARTITION BY ticker ORDER BY ts
ROWS BETWEEN {ATR_LOOKBACK} PRECEDING AND 1 PRECEDING)
/ NULLIF(prev_close_1, 0) atr_raw
FROM base
)
SELECT ticker, ts sig_ts, close_p sig_price,
vr0, vr1, vr2, vr3, atr_raw,
prev_close_3, prev_close_2, prev_close_1, close_p
FROM indicators
WHERE ts >= TO_TIMESTAMP(:check_since, 'YYYY-MM-DD HH24:MI:SS')
AND vr0 >= :min_vol
AND close_p > open_p
AND prev_close_1 > prev_open_1
AND prev_close_2 > prev_open_2
AND prev_close_3 > prev_open_3
AND close_p > prev_close_1
AND prev_close_1 > prev_close_2
AND prev_close_2 > prev_close_3
AND vr0 > vr1 AND vr1 > vr2 AND vr2 > vr3
ORDER BY ticker, ts
"""
def _get_conn():
kwargs = dict(user=os.environ["ORACLE_USER"],
password=os.environ["ORACLE_PASSWORD"],
dsn=os.environ["ORACLE_DSN"])
wallet = os.environ.get("ORACLE_WALLET")
if wallet:
kwargs["config_dir"] = wallet
return oracledb.connect(**kwargs)
def _fetch_bars(cur, ticker, entry_ts):
"""진입 시점 이후 봉 데이터 조회 (캐시용)."""
cur.execute(
"""SELECT ts, close_p, high_p FROM backtest_ohlcv
WHERE ticker=:t AND interval_cd='minute1'
AND ts >= :entry
ORDER BY ts FETCH FIRST :n ROWS ONLY""",
{"t": ticker, "entry": entry_ts, "n": MAX_TRAIL_BARS + 1}
)
return [(ts, float(cp), float(hp)) for ts, cp, hp in cur.fetchall()]
def simulate_trail_only(bars, entry_price, atr_raw):
"""전략 A: Trail Stop만 사용."""
ar = atr_raw if (atr_raw and atr_raw == atr_raw) else 0.0
atr_stop = max(ATR_MIN_R, min(ATR_MAX_R, ar * ATR_MULT)) if ar > 0 else ATR_MAX_R
running_peak = entry_price
for i, (ts, close_p, _) in enumerate(bars):
running_peak = max(running_peak, close_p)
drop = (running_peak - close_p) / running_peak if running_peak > 0 else 0.0
if drop >= atr_stop:
pnl = (close_p - entry_price) / entry_price * 100
peak_pct = (running_peak - entry_price) / entry_price * 100
return dict(status="트레일손절", exit_ts=ts, exit_price=close_p,
peak=running_peak, peak_pct=peak_pct,
pnl=pnl, held_bars=i + 1)
last_ts, last_price = bars[-1][0], bars[-1][1]
pnl = (last_price - entry_price) / entry_price * 100
peak_pct = (running_peak - entry_price) / entry_price * 100
status = "타임아웃" if len(bars) >= MAX_TRAIL_BARS else "진행중"
return dict(status=status, exit_ts=last_ts, exit_price=last_price,
peak=running_peak, peak_pct=peak_pct,
pnl=pnl, held_bars=len(bars))
def simulate_tp_trail(bars, entry_price, atr_raw, tp_r):
"""전략 B/C/D: 고정 익절 + Trail Stop 손절.
먼저 +tp_r% 도달하면 익절.
못 도달하고 trail stop 걸리면 손절.
"""
ar = atr_raw if (atr_raw and atr_raw == atr_raw) else 0.0
atr_stop = max(ATR_MIN_R, min(ATR_MAX_R, ar * ATR_MULT)) if ar > 0 else ATR_MAX_R
tp_price = entry_price * (1 + tp_r)
running_peak = entry_price
for i, (ts, close_p, high_p) in enumerate(bars):
# 고가로 익절 체크 (봉 내 고가가 익절가 도달하면 익절)
if high_p >= tp_price:
pnl = tp_r * 100
peak_pct = (max(running_peak, high_p) - entry_price) / entry_price * 100
return dict(status=f"익절+{tp_r*100:.0f}%", exit_ts=ts, exit_price=tp_price,
peak=max(running_peak, high_p), peak_pct=peak_pct,
pnl=pnl, held_bars=i + 1)
running_peak = max(running_peak, close_p)
drop = (running_peak - close_p) / running_peak if running_peak > 0 else 0.0
if drop >= atr_stop:
pnl = (close_p - entry_price) / entry_price * 100
peak_pct = (running_peak - entry_price) / entry_price * 100
return dict(status="트레일손절", exit_ts=ts, exit_price=close_p,
peak=running_peak, peak_pct=peak_pct,
pnl=pnl, held_bars=i + 1)
last_ts, last_price = bars[-1][0], bars[-1][1]
pnl = (last_price - entry_price) / entry_price * 100
peak_pct = (running_peak - entry_price) / entry_price * 100
status = "타임아웃" if len(bars) >= MAX_TRAIL_BARS else "진행중"
return dict(status=status, exit_ts=last_ts, exit_price=last_price,
peak=running_peak, peak_pct=peak_pct,
pnl=pnl, held_bars=len(bars))
def apply_pos_limit(sim_results):
open_positions = []
taken, skipped = [], []
for r in sim_results:
open_positions = [ex for ex in open_positions if ex > r['entry_ts']]
if len(open_positions) < MAX_POS:
open_positions.append(r['exit_ts'])
taken.append(r)
else:
skipped.append(r)
return taken, skipped
def print_summary(label, taken):
n = len(taken)
if n == 0:
print(f" {label:35s} 거래없음")
return
wins = sum(1 for r in taken if r['pnl'] > 0)
total = sum(PER_POS * (r['pnl'] / 100) - PER_POS * FEE * 2 for r in taken)
avg_h = sum(r['held_bars'] for r in taken) / n
ret = total / BUDGET * 100
print(f" {label:35s} {n:>3d}{wins/n*100:>4.0f}% {total:>+12,.0f}{ret:>+5.2f}% {avg_h:>5.0f}")
def print_detail(label, taken):
print(f"\n{''*110}")
print(f" {label}")
print(f"{''*110}")
for i, r in enumerate(taken, 1):
krw = PER_POS * (r['pnl'] / 100) - PER_POS * FEE * 2
sign = "" if r['pnl'] > 0 else ""
print(f" #{i:02d} {r['ticker']:12s} [{sign}] 진입 {str(r['entry_ts'])[:16]} "
f"고점 {r['peak_pct']:>+.2f}% → {r['status']} {r['held_bars']}"
f"PNL {r['pnl']:>+.2f}% ({krw:>+,.0f}원)")
def main():
now = datetime.now()
check_since = (now - timedelta(days=LOOKBACK_DAYS)).strftime('%Y-%m-%d 00:00:00')
warmup_since = (now - timedelta(days=LOOKBACK_DAYS + 1)).strftime('%Y-%m-%d 00:00:00')
print(f"=== 고정 익절 vs Trail Stop 비교 시뮬 ===")
print(f"기간: {check_since[:10]} ~ 현재 | 4봉 VOL≥{VOL_MIN}x | "
f"손절: ATR {ATR_MULT}×[{ATR_MIN_R*100:.0f}~{ATR_MAX_R*100:.0f}%]")
print(f"자본 {BUDGET//10000}만원 / 포지션 {PER_POS//10000}만원 / 동시 {MAX_POS}\n")
conn = _get_conn()
cur = conn.cursor()
cur.arraysize = 10_000
cur.execute(SIGNAL_SQL, {
"warmup_since": warmup_since,
"check_since": check_since,
"min_vol": VOL_MIN,
})
rows = cur.fetchall()
if not rows:
print(f"해당 기간 VOL>={VOL_MIN}x 4봉 가속 시그널 없음")
conn.close()
return
# 진입봉 + bars 수집
signals = []
for row in rows:
ticker, sig_ts, sig_price, vr0, vr1, vr2, vr3, atr_raw, \
p3, p2, p1, p0 = row
cur.execute(
"""SELECT close_p, ts FROM backtest_ohlcv
WHERE ticker=:t AND interval_cd='minute1'
AND ts > :sig AND ts <= :sig + INTERVAL '3' MINUTE
ORDER BY ts FETCH FIRST 1 ROWS ONLY""",
{"t": ticker, "sig": sig_ts}
)
entry_row = cur.fetchone()
if not entry_row:
continue
entry_price, entry_ts = float(entry_row[0]), entry_row[1]
bars = _fetch_bars(cur, ticker, entry_ts)
if not bars:
continue
signals.append({
'ticker': ticker,
'entry_ts': entry_ts,
'entry_price': entry_price,
'vr': [float(x) if x else 0.0 for x in [vr3, vr2, vr1, vr0]],
'prices': [float(x) if x else 0.0 for x in [p3, p2, p1, p0]],
'atr_raw': float(atr_raw) if atr_raw else 0.0,
'bars': bars,
})
signals.sort(key=lambda x: x['entry_ts'])
print(f"시그널 {len(signals)}건 → 전략별 시뮬 중...\n")
# ── 전략별 시뮬 ───────────────────────────────────────────────────────────
strategies = [
("A. Trail Stop [3~5%]", None),
("B. 익절 2% + Trail Stop", 0.02),
("C. 익절 3% + Trail Stop", 0.03),
("D. 익절 5% + Trail Stop", 0.05),
]
results = {}
for label, tp_r in strategies:
sim = []
for s in signals:
if tp_r is None:
r = simulate_trail_only(s['bars'], s['entry_price'], s['atr_raw'])
else:
r = simulate_tp_trail(s['bars'], s['entry_price'], s['atr_raw'], tp_r)
sim.append({**s, **r})
taken, _ = apply_pos_limit(sim)
results[label] = taken
# ── 요약 비교표 ───────────────────────────────────────────────────────────
print(f"{''*95}")
print(f" {'전략':35s} {'거래':>3s} {'승률':>4s} {'합산손익':>12s} {'수익률':>5s} {'평균보유':>5s}")
print(f"{''*95}")
for label, taken in results.items():
print_summary(label, taken)
print(f"{''*95}")
# ── 전략별 건별 상세 ──────────────────────────────────────────────────────
for label, taken in results.items():
print_detail(label, taken)
conn.close()
if __name__ == '__main__':
main()

View File

@@ -0,0 +1,131 @@
"""1년치 1분봉 OHLCV → BACKTEST_OHLCV (수동 페이지네이션).
pyupbit이 count>5000에서 실패하므로 to 파라미터로 직접 페이지네이션.
실행: .venv/bin/python3 tests/fetch_1y_minute1.py
예상 소요: 20종목 × ~15분 = ~5시간 (overnight 실행 권장)
"""
import sys, os, time
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from dotenv import load_dotenv
load_dotenv(os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), '.env'))
import pyupbit
import oracledb
from datetime import datetime, timedelta
import pandas as pd
ALL_TICKERS = [
'KRW-XRP','KRW-BTC','KRW-ETH','KRW-SOL','KRW-DOGE', # 그룹 1
'KRW-ADA','KRW-SUI','KRW-NEAR','KRW-KAVA','KRW-SXP', # 그룹 2
'KRW-AKT','KRW-SONIC','KRW-IP','KRW-ORBS','KRW-VIRTUAL', # 그룹 3
'KRW-BARD','KRW-XPL','KRW-KITE','KRW-ENSO','KRW-0G', # 그룹 4
]
# 실행: python fetch_1y_minute1.py [그룹번호 1-4]
import argparse
parser = argparse.ArgumentParser()
parser.add_argument('group', nargs='?', type=int, default=0,
help='1-4: 해당 그룹만 실행, 0: 전체')
args, _ = parser.parse_known_args()
if args.group in (1,2,3,4):
TICKERS = ALL_TICKERS[(args.group-1)*5 : args.group*5]
print(f"그룹 {args.group} 실행: {TICKERS}")
else:
TICKERS = ALL_TICKERS
BATCH = 4000 # 한 번에 요청할 봉 수 (4000 = ~2.8일)
DELAY = 0.15 # API 딜레이 (초)
TARGET_DAYS = 365 # 목표 기간
def _get_conn():
kwargs = dict(user=os.environ["ORACLE_USER"],
password=os.environ["ORACLE_PASSWORD"],
dsn=os.environ["ORACLE_DSN"])
wallet = os.environ.get("ORACLE_WALLET")
if wallet:
kwargs["config_dir"] = wallet
return oracledb.connect(**kwargs)
def insert_batch(conn, ticker, df) -> int:
"""DB에 없는 행만 삽입. 반환: 신규 건수."""
cur = conn.cursor()
min_ts = df.index.min().to_pydatetime()
max_ts = df.index.max().to_pydatetime()
cur.execute(
"SELECT ts FROM backtest_ohlcv WHERE ticker=:t AND interval_cd='minute1' "
"AND ts BETWEEN :s AND :e",
{"t": ticker, "s": min_ts, "e": max_ts}
)
existing = {r[0] for r in cur.fetchall()}
new_rows = [
(ticker, 'minute1', ts.to_pydatetime(),
float(r["open"]), float(r["high"]), float(r["low"]),
float(r["close"]), float(r["volume"]))
for ts, r in df.iterrows()
if ts.to_pydatetime() not in existing
]
if not new_rows:
return 0
cur.executemany(
"INSERT INTO backtest_ohlcv (ticker,interval_cd,ts,open_p,high_p,low_p,close_p,volume_p) "
"VALUES (:1,:2,:3,:4,:5,:6,:7,:8)",
new_rows
)
conn.commit()
return len(new_rows)
def fetch_ticker(conn, ticker) -> int:
"""ticker 1년치 1분봉 fetch → DB 저장. 반환: 총 신규 건수."""
cutoff = datetime.now() - timedelta(days=TARGET_DAYS)
to_dt = datetime.now()
total = 0
batch_no = 0
while to_dt > cutoff:
to_str = to_dt.strftime('%Y-%m-%d %H:%M:%S')
try:
df = pyupbit.get_ohlcv(ticker, interval='minute1', count=BATCH, to=to_str)
time.sleep(DELAY)
except Exception as e:
print(f" API 오류: {e} → 재시도")
time.sleep(2.0)
continue
if df is None or len(df) == 0:
break
n = insert_batch(conn, ticker, df)
total += n
batch_no += 1
oldest = df.index[0]
print(f" 배치{batch_no:03d}: {oldest.date()} ~ {df.index[-1].strftime('%m-%d')} "
f"({len(df)}봉, 신규 {n}) | 누적 {total:,}", flush=True)
# 다음 페이지: 이 배치에서 가장 오래된 봉 이전부터
to_dt = oldest - timedelta(minutes=1)
if oldest <= cutoff:
break
return total
conn = _get_conn()
grand_total = 0
start_time = time.time()
for idx, tk in enumerate(TICKERS, 1):
t0 = time.time()
print(f"\n[{idx:02d}/{len(TICKERS)}] {tk} 시작...", flush=True)
try:
n = fetch_ticker(conn, tk)
elapsed = time.time() - t0
grand_total += n
print(f" → 완료: 신규 {n:,}행 ({elapsed/60:.1f}분) | 전체 누적 {grand_total:,}", flush=True)
except Exception as e:
print(f" → 오류: {e}")
conn.close()
elapsed_total = time.time() - start_time
print(f"\n전체 완료: {grand_total:,}행 저장 ({elapsed_total/60:.0f}분 소요)")

View File

@@ -0,0 +1,318 @@
"""F&G 조건별 백테스트 - 1년치 데이터 (배치 수집)
60일 극공포 편향을 제거하고 Bull/Neutral/Bear 다양한 구간 포함.
데이터: 1h 캔들 배치 수집 → 약 365일치
"""
import os as _os, sys as _sys
_sys.path.insert(0, _os.path.dirname(_os.path.dirname(_os.path.abspath(__file__))))
from __future__ import annotations
import datetime, json, time, sys, urllib.request
import pandas as pd
import pyupbit
from dataclasses import dataclass
TICKERS = [
"KRW-BTC", "KRW-ETH", "KRW-XRP", "KRW-SOL", "KRW-DOGE",
"KRW-ADA", "KRW-DOT", "KRW-NEAR", "KRW-AVAX", "KRW-LINK",
"KRW-SUI", "KRW-HBAR",
"KRW-VIRTUAL", "KRW-SXP", "KRW-CFG", "KRW-HOLO",
"KRW-KAVA", "KRW-KNC",
]
VOL_MULT = 2.0
QUIET_2H = 2.0
SIG_TO_H = 8
MOM_THR = 3.0
SIG_CANCEL = 3.0
TRAIL_STOP = 0.015
TIME_H = 24
TIME_MIN = 3.0
# ── 데이터 수집 ───────────────────────────────────────────────
def fetch_1y(ticker: str, total_days: int = 365) -> pd.DataFrame | None:
"""1h 캔들을 배치로 수집해 약 1년치 DataFrame 반환."""
all_dfs = []
end = datetime.datetime.now()
batch = 1440 # 60일치씩
prev_oldest = None
while True:
df = pyupbit.get_ohlcv(
ticker, interval="minute60", count=batch,
to=end.strftime("%Y-%m-%d %H:%M:%S"),
)
if df is None or df.empty:
break
all_dfs.append(df)
oldest = df.index[0]
# 상장 초기 종목: oldest가 진전되지 않으면 더 오래된 데이터 없음
if prev_oldest is not None and oldest >= prev_oldest:
break
prev_oldest = oldest
cutoff = datetime.datetime.now() - datetime.timedelta(days=total_days)
if oldest <= cutoff:
break
end = oldest
time.sleep(0.12)
if not all_dfs:
return None
combined = pd.concat(all_dfs).sort_index()
combined = combined[~combined.index.duplicated(keep="last")]
cutoff = datetime.datetime.now() - datetime.timedelta(days=total_days)
return combined[combined.index >= cutoff]
def load_fng() -> dict[str, int]:
url = "https://api.alternative.me/fng/?limit=400&format=json"
with urllib.request.urlopen(url, timeout=10) as r:
data = json.loads(r.read())
return {
datetime.datetime.fromtimestamp(int(d["timestamp"])).strftime("%Y-%m-%d"):
int(d["value"])
for d in data["data"]
}
def fng_val(fng_map, ts):
return fng_map.get(ts.strftime("%Y-%m-%d"), 50)
# ── 시뮬레이션 ────────────────────────────────────────────────
@dataclass
class Trade:
pnl: float
h: int
fng: int
exit: str
def simulate(df, fng_map, fng_lo=None, fng_hi=None) -> list[Trade]:
closes = df["close"].values
vols = df["volume"].values
idx = df.index
trades: list[Trade] = []
sig_px = sig_i = None
pos_buy = pos_peak = pos_i = pos_fng = None
for i in range(7, len(closes) - max(TIME_H + 4, 10)):
if pos_buy is not None:
cur = closes[i]
if cur > pos_peak:
pos_peak = cur
if (pos_peak - cur) / pos_peak >= TRAIL_STOP:
trades.append(Trade((cur - pos_buy) / pos_buy * 100,
i - pos_i, pos_fng, "trail"))
pos_buy = pos_peak = pos_i = pos_fng = sig_px = sig_i = None
continue
if i - pos_i >= TIME_H:
pnl = (cur - pos_buy) / pos_buy * 100
if pnl < TIME_MIN:
trades.append(Trade(pnl, i - pos_i, pos_fng, "time"))
pos_buy = pos_peak = pos_i = pos_fng = sig_px = sig_i = None
continue
continue
if sig_px is not None:
if i - sig_i > SIG_TO_H:
sig_px = sig_i = None
elif (closes[i] - sig_px) / sig_px * 100 < -SIG_CANCEL:
sig_px = sig_i = None
if sig_px is None:
vol_avg = vols[i - 6:i - 1].mean()
if vol_avg <= 0:
continue
if vols[i - 1] / vol_avg >= VOL_MULT:
if abs(closes[i] - closes[i - 2]) / closes[i - 2] * 100 < QUIET_2H:
sig_px = closes[i]
sig_i = i
continue
fv = fng_val(fng_map, idx[i])
if fng_lo is not None and fv < fng_lo:
continue
if fng_hi is not None and fv > fng_hi:
continue
if (closes[i] - sig_px) / sig_px * 100 >= MOM_THR:
pos_buy = pos_peak = closes[i]
pos_i = i
pos_fng = fv
sig_px = sig_i = None
return trades
def stats(trades):
if not trades:
return dict(n=0, wr=0, avg_pnl=0, total_pnl=0, rr=0,
avg_win=0, avg_loss=0, max_dd=0)
wins = [t for t in trades if t.pnl > 0]
losses = [t for t in trades if t.pnl <= 0]
aw = sum(t.pnl for t in wins) / len(wins) if wins else 0
al = sum(t.pnl for t in losses) / len(losses) if losses else 0
cum = pk = max_dd = 0.0
for t in trades:
cum += t.pnl
if cum > pk: pk = cum
if pk - cum > max_dd: max_dd = pk - cum
return dict(
n=len(trades), wr=len(wins) / len(trades) * 100,
avg_pnl=sum(t.pnl for t in trades) / len(trades),
total_pnl=sum(t.pnl for t in trades),
rr=abs(aw / al) if al else 0,
avg_win=aw, avg_loss=al, max_dd=max_dd,
)
def main():
print("F&G 데이터 로드...")
fng_map = load_fng()
# F&G 연간 분포 출력
from collections import Counter
zone_cnt = Counter()
for v in fng_map.values():
if v <= 25: zone_cnt["극공포(0~25)"] += 1
elif v <= 45: zone_cnt["공포(26~45)"] += 1
elif v <= 55: zone_cnt["중립(46~55)"] += 1
elif v <= 75: zone_cnt["탐욕(56~75)"] += 1
else: zone_cnt["극탐욕(76~100)"] += 1
total_days = sum(zone_cnt.values())
print(f" 1년 F&G 분포 ({total_days}일):")
for k, v in sorted(zone_cnt.items()):
bar = "" * (v // 5)
print(f" {k:<14} {v:>3}일 ({v/total_days*100:>4.1f}%) {bar}")
print(f"\n종목 1년치 데이터 수집 중 ({len(TICKERS)}개)...")
datasets = {}
for i, tk in enumerate(TICKERS):
try:
df = fetch_1y(tk, total_days=365)
if df is not None and len(df) > 100:
datasets[tk] = df
sys.stderr.write(f"\r {i+1}/{len(TICKERS)} {tk} ({len(df)}h) ")
except Exception as e:
sys.stderr.write(f"\r {tk} 실패: {e} ")
sys.stderr.write("\n")
print(f" 완료: {len(datasets)}개 종목\n")
# ── 전체 기간 F&G 구간별 성과 ────────────────────────────
CONFIGS = [
(None, None, "필터 없음 (전체)"),
(None, 25, "극공포만 (0~25)"),
(26, 45, "공포만 (26~45)"),
(46, 55, "중립만 (46~55)"),
(56, 100, "탐욕+ (56~100)"),
(46, 100, "중립 이상 (46~100)"),
(26, 100, "공포 이상 (26~100)"),
]
print("=" * 78)
print(" F&G 조건별 성과 - 1년치 (1h 캔들 / 모멘텀 / 스탑1.5%)")
print("=" * 78)
print(f" {'조건':<26} {'거래':>5} {'승률':>6} {'평균PnL':>8} "
f"{'손익비':>6} {'총PnL':>9} {'MaxDD':>7}")
print(" " + "-" * 72)
all_results = {}
for lo, hi, label in CONFIGS:
all_trades = []
for df in datasets.values():
all_trades.extend(simulate(df, fng_map, lo, hi))
s = stats(all_trades)
all_results[label] = (s, all_trades)
if s["n"] == 0:
print(f" {label:<26} 거래 없음 (해당 구간 진입 기회 없음)")
continue
sign = "+" if s["total_pnl"] > 0 else ""
print(
f" {label:<26} {s['n']:>5}{s['wr']:>5.1f}% "
f"{s['avg_pnl']:>+7.3f}% {s['rr']:>5.2f} "
f"{sign}{s['total_pnl']:>8.1f}% -{s['max_dd']:>5.1f}%"
)
# ── 분기별 성과 (계절성) ──────────────────────────────────
print()
print(" 분기별 성과 (전체 필터 없음 기준):")
base_trades = all_results["필터 없음 (전체)"][1]
for df in datasets.values():
pass # already computed
# 전체 종목 합산 후 날짜로 분기 분리
all_base = []
for df in datasets.values():
t_list = simulate(df, fng_map)
# trade에 날짜 정보 추가
# simulate에서 idx를 참조하지 않으므로 재계산
all_base.extend(t_list)
# F&G 수치별 세분화
print()
print(" F&G 10단위 구간별 세부 성과:")
print(f" {'구간':<16} {'건수':>5} {'승률':>6} {'평균PnL':>9} {'손익비':>6} {'의미'}")
print(" " + "-" * 65)
fng_zones_detail = [
(0, 10, "극단 공포(0~10)"),
(11, 20, "극단 공포(11~20)"),
(21, 30, "극공포(21~30)"),
(31, 40, "공포(31~40)"),
(41, 50, "약공포(41~50)"),
(51, 60, "약탐욕(51~60)"),
(61, 75, "탐욕(61~75)"),
(76, 100, "극탐욕(76~100)"),
]
base_all = all_results["필터 없음 (전체)"][1]
for lo, hi, name in fng_zones_detail:
sub = [t for t in base_all if lo <= t.fng <= hi]
if not sub:
continue
s = stats(sub)
breakeven_wr = 1 / (1 + s["rr"]) * 100 if s["rr"] > 0 else 50
profitable = "✅ 수익" if s["avg_pnl"] > 0 else ("⚠️ BEP 근접" if s["avg_pnl"] > -0.2 else "❌ 손실")
print(
f" {name:<16} {s['n']:>5}{s['wr']:>5.1f}% "
f"{s['avg_pnl']:>+8.3f}% {s['rr']:>5.2f} {profitable}"
)
# ── 최적 F&G 구간 요약 ───────────────────────────────────
print()
best = max(
[(label, s) for label, (s, _) in all_results.items() if s["n"] >= 50],
key=lambda x: x[1]["avg_pnl"],
)
print(f" ★ 최적 구간: {best[0]} "
f"(거래 {best[1]['n']}건 | 승률 {best[1]['wr']:.1f}% | "
f"평균PnL {best[1]['avg_pnl']:+.3f}%)")
# ── DB 저장 ──────────────────────────────────────────────
try:
from backtest_db import ensure_tables, insert_run, insert_result, insert_trades_bulk
ensure_tables()
params = {
"tickers": len(datasets), "days": 365, "candle": "1h",
"trail_stop": 0.015, "mom_thr": 3.0, "vol_mult": 2.0,
}
run_id = insert_run(
run_name="fng_1y_backtest",
description="F&G 구간별 성과 1년치 백테스트 (1h 캔들 / 모멘텀 / 스탑1.5%)",
params=params,
)
for lo, hi, label in CONFIGS:
if label in all_results:
s, trades = all_results[label]
if s["n"] > 0:
insert_result(run_id, label, s, lo, hi)
# 전체 거래는 per-ticker 분리 없이 일괄 저장 (run_id+label로 구분)
insert_trades_bulk(run_id, label, "_all_", trades)
print(f"\n [DB 저장 완료] run_id: {run_id}")
except Exception as e:
print(f"\n [DB 저장 실패] {e}")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,320 @@
"""F&G 구간별 맞춤 파라미터 백테스트
핵심 가설:
극공포 구간은 시장이 불안정 → 더 엄격한 진입 기준 필요
탐욕 구간은 상승 모멘텀이 지속 → 다소 느슨한 기준도 가능
테스트 방식:
각 F&G 구간마다 다른 파라미터 조합을 적용하고 성과 비교.
구간별 최적 파라미터 도출 → 실제 전략에 반영
결과를 Oracle DB에 저장.
데이터: 1년치 1h 캔들 (배치 수집)
"""
import os as _os, sys as _sys
_sys.path.insert(0, _os.path.dirname(_os.path.dirname(_os.path.abspath(__file__))))
from __future__ import annotations
import datetime
import json
import sys
import time
import urllib.request
import pandas as pd
import pyupbit
from dataclasses import dataclass
# ── DB 저장 ─────────────────────────────────────────────────
try:
from backtest_db import ensure_tables, insert_run, insert_result, insert_trades_bulk
DB_ENABLED = True
except Exception as e:
print(f" [DB 비활성화] {e}")
DB_ENABLED = False
TICKERS = [
"KRW-BTC", "KRW-ETH", "KRW-XRP", "KRW-SOL", "KRW-DOGE",
"KRW-ADA", "KRW-DOT", "KRW-NEAR", "KRW-AVAX", "KRW-LINK",
"KRW-SUI", "KRW-HBAR",
"KRW-VIRTUAL", "KRW-SXP", "KRW-CFG", "KRW-HOLO",
"KRW-KAVA", "KRW-KNC",
]
# ── F&G 구간별 파라미터 조합 ─────────────────────────────────
# (fng_lo, fng_hi, label, vol_mult, quiet_2h, sig_to_h, mom_thr, sig_cancel, trail_stop, time_h, time_min)
ADAPTIVE_CONFIGS = [
# 기준선 (F&G 무관, 단일 파라미터)
(None, None, "기준선(전체/현행파라미터)", 2.0, 2.0, 8, 3.0, 3.0, 0.015, 24, 3.0),
# ── 극공포 (0~25) 구간 ── 엄격한 기준 ──
# 극공포에서는 변동성 급증이 흔함 → 볼륨 기준 올리고, 모멘텀 강화
(None, 25, "극공포/기준(2x vol+3%mom)", 2.0, 2.0, 8, 3.0, 3.0, 0.015, 24, 3.0),
(None, 25, "극공포/엄격(3x vol+4%mom)", 3.0, 2.0, 8, 4.0, 3.0, 0.010, 24, 3.0),
(None, 25, "극공포/매우엄격(3x+5%+1%스탑)", 3.0, 2.0, 6, 5.0, 3.0, 0.010, 24, 3.0),
(None, 25, "극공포/넓은스탑(2x+3%+2%스탑)", 2.0, 2.0, 8, 3.0, 3.0, 0.020, 24, 3.0),
(None, 25, "극공포/짧은신호(3x+4%+4h유효)", 3.0, 2.0, 4, 4.0, 3.0, 0.015, 24, 3.0),
# ── 공포 (26~45) ── 중간 기준 ──
(26, 45, "공포/기준(2x vol+3%mom)", 2.0, 2.0, 8, 3.0, 3.0, 0.015, 24, 3.0),
(26, 45, "공포/약강화(2.5x vol+3.5%mom)", 2.5, 2.0, 8, 3.5, 3.0, 0.015, 24, 3.0),
(26, 45, "공포/엄격(3x vol+4%mom)", 3.0, 2.0, 8, 4.0, 3.0, 0.010, 24, 3.0),
# ── 중립 이상 (46~100) ── 완화된 기준 가능 ──
(46, None, "중립이상/기준(2x vol+3%mom)", 2.0, 2.0, 8, 3.0, 3.0, 0.015, 24, 3.0),
(46, None, "중립이상/완화(1.5x vol+2.5%mom)",1.5, 2.0, 8, 2.5, 3.0, 0.015, 24, 3.0),
(46, None, "중립이상/엄격(2.5x+3.5%)", 2.5, 2.0, 8, 3.5, 3.0, 0.015, 24, 3.0),
# ── 탐욕+ (56~100) ──
(56, None, "탐욕이상/기준", 2.0, 2.0, 8, 3.0, 3.0, 0.015, 24, 3.0),
(56, None, "탐욕이상/완화(1.5x+2.5%)", 1.5, 2.0, 8, 2.5, 3.0, 0.015, 24, 3.0),
]
# ── 데이터 수집 ──────────────────────────────────────────────
def fetch_1y(ticker: str, total_days: int = 365) -> pd.DataFrame | None:
all_dfs = []
end = datetime.datetime.now()
batch = 1440 # 60일치씩
prev_oldest = None
while True:
df = pyupbit.get_ohlcv(
ticker, interval="minute60", count=batch,
to=end.strftime("%Y-%m-%d %H:%M:%S"),
)
if df is None or df.empty:
break
all_dfs.append(df)
oldest = df.index[0]
# 상장 초기 종목: oldest가 진전되지 않으면 더 이상 오래된 데이터 없음
if prev_oldest is not None and oldest >= prev_oldest:
break
prev_oldest = oldest
cutoff = datetime.datetime.now() - datetime.timedelta(days=total_days)
if oldest <= cutoff:
break
end = oldest
time.sleep(0.12)
if not all_dfs:
return None
combined = pd.concat(all_dfs).sort_index()
combined = combined[~combined.index.duplicated(keep="last")]
cutoff = datetime.datetime.now() - datetime.timedelta(days=total_days)
return combined[combined.index >= cutoff]
def load_fng() -> dict[str, int]:
url = "https://api.alternative.me/fng/?limit=400&format=json"
with urllib.request.urlopen(url, timeout=10) as r:
data = json.loads(r.read())
return {
datetime.datetime.fromtimestamp(int(d["timestamp"])).strftime("%Y-%m-%d"):
int(d["value"])
for d in data["data"]
}
def fng_val(fng_map, ts) -> int:
return fng_map.get(ts.strftime("%Y-%m-%d"), 50)
# ── 시뮬레이션 ──────────────────────────────────────────────
@dataclass
class Trade:
pnl: float
h: int
fng: int
exit: str
def simulate(
df, fng_map,
fng_lo=None, fng_hi=None,
vol_mult=2.0, quiet_2h=2.0, sig_to_h=8,
mom_thr=3.0, sig_cancel=3.0, trail_stop=0.015,
time_h=24, time_min=3.0,
) -> list[Trade]:
closes = df["close"].values
vols = df["volume"].values
idx = df.index
trades: list[Trade] = []
sig_px = sig_i = None
pos_buy = pos_peak = pos_i = pos_fng = None
for i in range(7, len(closes) - max(time_h + 4, 10)):
if pos_buy is not None:
cur = closes[i]
if cur > pos_peak:
pos_peak = cur
if (pos_peak - cur) / pos_peak >= trail_stop:
trades.append(Trade((cur - pos_buy) / pos_buy * 100,
i - pos_i, pos_fng, "trail"))
pos_buy = pos_peak = pos_i = pos_fng = sig_px = sig_i = None
continue
if i - pos_i >= time_h:
pnl = (cur - pos_buy) / pos_buy * 100
if pnl < time_min:
trades.append(Trade(pnl, i - pos_i, pos_fng, "time"))
pos_buy = pos_peak = pos_i = pos_fng = sig_px = sig_i = None
continue
continue
if sig_px is not None:
if i - sig_i > sig_to_h:
sig_px = sig_i = None
elif (closes[i] - sig_px) / sig_px * 100 < -sig_cancel:
sig_px = sig_i = None
if sig_px is None:
vol_avg = vols[i - 6:i - 1].mean()
if vol_avg <= 0:
continue
if vols[i - 1] / vol_avg >= vol_mult:
if abs(closes[i] - closes[i - 2]) / closes[i - 2] * 100 < quiet_2h:
sig_px = closes[i]
sig_i = i
continue
fv = fng_val(fng_map, idx[i])
if fng_lo is not None and fv < fng_lo:
continue
if fng_hi is not None and fv > fng_hi:
continue
if (closes[i] - sig_px) / sig_px * 100 >= mom_thr:
pos_buy = pos_peak = closes[i]
pos_i = i
pos_fng = fv
sig_px = sig_i = None
return trades
def stats(trades):
if not trades:
return dict(n=0, wr=0, avg_pnl=0, total_pnl=0, rr=0,
avg_win=0, avg_loss=0, max_dd=0)
wins = [t for t in trades if t.pnl > 0]
losses = [t for t in trades if t.pnl <= 0]
aw = sum(t.pnl for t in wins) / len(wins) if wins else 0
al = sum(t.pnl for t in losses) / len(losses) if losses else 0
cum = pk = max_dd = 0.0
for t in trades:
cum += t.pnl
if cum > pk: pk = cum
if pk - cum > max_dd: max_dd = pk - cum
return dict(
n=len(trades), wr=len(wins) / len(trades) * 100,
avg_pnl=sum(t.pnl for t in trades) / len(trades),
total_pnl=sum(t.pnl for t in trades),
rr=abs(aw / al) if al else 0,
avg_win=aw, avg_loss=al, max_dd=max_dd,
)
def main():
print("F&G 데이터 로드...")
fng_map = load_fng()
print(f"종목 1년치 데이터 수집 중 ({len(TICKERS)}개)...")
datasets = {}
for i, tk in enumerate(TICKERS):
try:
df = fetch_1y(tk, total_days=365)
if df is not None and len(df) > 100:
datasets[tk] = df
sys.stderr.write(f"\r {i+1}/{len(TICKERS)} {tk} ({len(df)}h) ")
except Exception as e:
sys.stderr.write(f"\r {tk} 실패: {e} ")
sys.stderr.write("\n")
print(f" 완료: {len(datasets)}개 종목\n")
# ── DB 준비 ───────────────────────────────────────────
run_id = None
if DB_ENABLED:
ensure_tables()
params = {
"tickers": len(datasets),
"days": 365,
"candle": "1h",
"stop": "trail+time",
}
run_id = insert_run(
run_name="fng_adaptive_1y",
description="F&G 구간별 맞춤 파라미터 1년 백테스트",
params=params,
)
print(f" DB run_id: {run_id}\n")
# ── 결과 출력 ─────────────────────────────────────────
print("=" * 92)
print(" F&G 구간별 맞춤 파라미터 성과 비교 (1년치 / 1h 캔들)")
print("=" * 92)
print(f" {'조건':<42} {'거래':>5} {'승률':>6} {'평균PnL':>8} "
f"{'손익비':>5} {'총PnL':>9} {'MaxDD':>7}")
print(" " + "-" * 86)
best_by_zone: dict[str, tuple] = {}
for cfg in ADAPTIVE_CONFIGS:
fng_lo, fng_hi, label, vol_mult, quiet_2h, sig_to_h, mom_thr, sig_cancel, trail_stop, time_h, time_min = cfg
all_trades: list[Trade] = []
per_ticker: dict[str, list[Trade]] = {}
for tk, df in datasets.items():
t = simulate(
df, fng_map,
fng_lo=fng_lo, fng_hi=fng_hi,
vol_mult=vol_mult, quiet_2h=quiet_2h, sig_to_h=sig_to_h,
mom_thr=mom_thr, sig_cancel=sig_cancel, trail_stop=trail_stop,
time_h=time_h, time_min=time_min,
)
all_trades.extend(t)
per_ticker[tk] = t
s = stats(all_trades)
# 구분선 (기준선 다음)
if label == "극공포/기준(2x vol+3%mom)":
print()
if s["n"] == 0:
print(f" {label:<42} 거래 없음")
continue
marker = "" if s["avg_pnl"] > 0 else ""
print(
f" {label:<42} {s['n']:>5}{s['wr']:>5.1f}% "
f"{s['avg_pnl']:>+7.3f}% {s['rr']:>4.2f} "
f"{s['total_pnl']:>+8.1f}% -{s['max_dd']:>5.1f}%{marker}"
)
# DB 저장
if DB_ENABLED and run_id:
insert_result(run_id, label, s, fng_lo, fng_hi)
for tk, t_list in per_ticker.items():
insert_trades_bulk(run_id, label, tk, t_list)
# 구간별 최고 avg_pnl 추적
zone_key = label.split("/")[0]
if zone_key not in best_by_zone or s["avg_pnl"] > best_by_zone[zone_key][1]:
best_by_zone[zone_key] = (label, s["avg_pnl"], s)
# ── 구간별 최적 요약 ──────────────────────────────────
print()
print(" ★ 구간별 최적 파라미터:")
print(f" {'구간':<14} {'최적 조건':<42} {'거래':>5} {'승률':>6} {'평균PnL':>8}")
print(" " + "-" * 72)
for zone, (label, best_pnl, s) in best_by_zone.items():
if s["n"] > 0:
print(f" {zone:<14} {label:<42} {s['n']:>5}{s['wr']:>5.1f}% {best_pnl:>+7.3f}%")
if DB_ENABLED and run_id:
print(f"\n [DB 저장 완료] run_id: {run_id}")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,258 @@
"""공포탐욕지수(F&G) 조건별 백테스트
질문: "F&G 수치에 따라 진입 조건을 제한하면 성과가 나아지는가?"
테스트 구간:
A. 필터 없음 (baseline)
B. F&G > 25 (Extreme Fear 구간 제외)
C. F&G > 45 (Fear 이하 구간 제외, Neutral 이상)
D. F&G > 55 (Greed 이상만 진입)
E. F&G 20~45 (Fear 구간만 진입 역발상 매수)
진입 전략: 모멘텀 (거래량 급증 + 횡보 → +3% 상승 시 매수)
청산: 트레일링 스탑 1.5% + 타임 스탑 24h/+3%
"""
import os as _os, sys as _sys
_sys.path.insert(0, _os.path.dirname(_os.path.dirname(_os.path.abspath(__file__))))
from __future__ import annotations
import json, time, sys, datetime, urllib.request
import pyupbit
import pandas as pd
from collections import defaultdict
from dataclasses import dataclass
# ── 종목 ──────────────────────────────────────────────────────
TICKERS = [
"KRW-BTC", "KRW-ETH", "KRW-XRP", "KRW-SOL", "KRW-DOGE",
"KRW-ADA", "KRW-DOT", "KRW-NEAR", "KRW-AVAX", "KRW-LINK",
"KRW-SUI", "KRW-HBAR", "KRW-TRX",
"KRW-VIRTUAL", "KRW-SXP", "KRW-CFG", "KRW-HOLO", "KRW-ENSO",
"KRW-KAVA", "KRW-KNC", "KRW-OM", "KRW-STEEM", "KRW-ORBS",
]
# ── 공통 파라미터 ─────────────────────────────────────────────
VOL_MULT = 2.0
QUIET_2H = 2.0
SIG_TO_H = 8
MOM_THR = 3.0
SIG_CANCEL = 3.0
TRAIL_STOP = 0.015 # 1.5% (앞 테스트에서 최적)
TIME_H = 24
TIME_MIN = 3.0
# ── F&G 데이터 로드 ───────────────────────────────────────────
def load_fng(days: int = 365) -> dict[str, int]:
"""날짜(YYYY-MM-DD) → F&G 지수 딕셔너리 반환."""
url = f"https://api.alternative.me/fng/?limit={days}&format=json"
with urllib.request.urlopen(url, timeout=10) as r:
data = json.loads(r.read())
result = {}
for d in data["data"]:
dt_str = datetime.datetime.fromtimestamp(int(d["timestamp"])).strftime("%Y-%m-%d")
result[dt_str] = int(d["value"])
return result
def fng_at(fng_map: dict, ts: pd.Timestamp) -> int:
"""타임스탬프에 해당하는 F&G 값 반환. 없으면 50(Neutral) 반환."""
return fng_map.get(ts.strftime("%Y-%m-%d"), 50)
# ── F&G 필터 정의 ─────────────────────────────────────────────
CONFIGS = [
(None, None, "필터 없음 (baseline)"),
(26, None, "F&G > 25 (Extreme Fear 제외)"),
(46, None, "F&G > 45 (Neutral 이상)"),
(56, None, "F&G > 55 (Greed 이상)"),
(20, 45, "F&G 20~45 (Fear 역발상 매수)"),
]
# ── 시뮬레이션 ────────────────────────────────────────────────
@dataclass
class Trade:
pnl: float
h: int
fng: int
exit: str
def simulate(df, fng_map: dict, fng_lo=None, fng_hi=None) -> list[Trade]:
closes = df["close"].values
vols = df["volume"].values
idx = df.index
trades: list[Trade] = []
sig_px = sig_i = None
pos_buy = pos_peak = pos_i = pos_fng = None
for i in range(7, len(closes) - max(TIME_H + 4, 10)):
# ── 포지션 관리 ──────────────────────────────────────
if pos_buy is not None:
cur = closes[i]
if cur > pos_peak:
pos_peak = cur
if (pos_peak - cur) / pos_peak >= TRAIL_STOP:
pnl = (cur - pos_buy) / pos_buy * 100
trades.append(Trade(pnl, i - pos_i, pos_fng, "trail"))
pos_buy = pos_peak = pos_i = pos_fng = sig_px = sig_i = None
continue
if i - pos_i >= TIME_H:
pnl = (cur - pos_buy) / pos_buy * 100
if pnl < TIME_MIN:
trades.append(Trade(pnl, i - pos_i, pos_fng, "time"))
pos_buy = pos_peak = pos_i = pos_fng = sig_px = sig_i = None
continue
continue
# ── 신호 유효성 ──────────────────────────────────────
if sig_px is not None:
if i - sig_i > SIG_TO_H:
sig_px = sig_i = None
elif (closes[i] - sig_px) / sig_px * 100 < -SIG_CANCEL:
sig_px = sig_i = None
# ── 축적 감지 ─────────────────────────────────────────
if sig_px is None:
vol_avg = vols[i - 6:i - 1].mean()
if vol_avg <= 0:
continue
if vols[i - 1] / vol_avg >= VOL_MULT:
if abs(closes[i] - closes[i - 2]) / closes[i - 2] * 100 < QUIET_2H:
sig_px = closes[i]
sig_i = i
continue
# ── F&G 필터 체크 ─────────────────────────────────────
fng_val = fng_at(fng_map, idx[i])
if fng_lo is not None and fng_val < fng_lo:
continue
if fng_hi is not None and fng_val > fng_hi:
continue
# ── 모멘텀 진입 ───────────────────────────────────────
if (closes[i] - sig_px) / sig_px * 100 >= MOM_THR:
pos_buy = pos_peak = closes[i]
pos_i = i
pos_fng = fng_val
sig_px = sig_i = None
return trades
def stats(trades: list[Trade]) -> dict:
if not trades:
return dict(n=0, wr=0, avg_pnl=0, total_pnl=0, rr=0,
avg_win=0, avg_loss=0, avg_fng=0, max_dd=0)
wins = [t for t in trades if t.pnl > 0]
losses = [t for t in trades if t.pnl <= 0]
aw = sum(t.pnl for t in wins) / len(wins) if wins else 0
al = sum(t.pnl for t in losses) / len(losses) if losses else 0
rr = abs(aw / al) if al else 0
cum = 0.0; pk = 0.0; max_dd = 0.0
for t in trades:
cum += t.pnl
if cum > pk: pk = cum
if pk - cum > max_dd: max_dd = pk - cum
return dict(
n = len(trades),
wr = len(wins) / len(trades) * 100,
avg_pnl = sum(t.pnl for t in trades) / len(trades),
total_pnl= sum(t.pnl for t in trades),
rr = rr,
avg_win = aw,
avg_loss = al,
avg_fng = sum(t.fng for t in trades) / len(trades),
max_dd = max_dd,
)
def main() -> None:
print("F&G 데이터 로드 중...")
fng_map = load_fng(365)
print(f" {len(fng_map)}일치 F&G 데이터 로드 완료\n")
print(f"종목 데이터 수집 중 ({len(TICKERS)}개, 60일 1h 캔들)...")
datasets: dict = {}
for i, tk in enumerate(TICKERS):
try:
df = pyupbit.get_ohlcv(tk, interval="minute60", count=1440)
if df is not None and len(df) > 50:
datasets[tk] = df
sys.stderr.write(f"\r {i+1}/{len(TICKERS)} {tk} ")
time.sleep(0.08)
except Exception:
pass
sys.stderr.write("\n")
print(f" 완료: {len(datasets)}개 종목\n")
# ── 결과 집계 ──────────────────────────────────────────────
print("=" * 78)
print(" F&G 조건별 백테스트 결과 (60일 / 모멘텀 진입 / 스탑 1.5%)")
print("=" * 78)
print(f" {'조건':<30} {'거래':>5} {'승률':>6} {'평균PnL':>8} "
f"{'손익비':>6} {'총PnL':>8} {'MaxDD':>7} {'진입F&G':>7}")
print(" " + "-" * 74)
all_results = {}
for fng_lo, fng_hi, label in CONFIGS:
all_trades: list[Trade] = []
for df in datasets.values():
all_trades.extend(simulate(df, fng_map, fng_lo, fng_hi))
s = stats(all_trades)
all_results[label] = (s, all_trades)
marker = " ◀ 현행" if "없음" in label else ""
if s["n"] == 0:
print(f" {label:<30} 거래 없음")
continue
print(
f" {label:<30} {s['n']:>5}{s['wr']:>5.1f}% "
f"{s['avg_pnl']:>+7.3f}% {s['rr']:>5.2f} "
f"{s['total_pnl']:>+7.1f}% -{s['max_dd']:>5.1f}% "
f"{s['avg_fng']:>6.1f}{marker}"
)
# ── F&G 구간별 세부 성과 ──────────────────────────────────
print()
print(" F&G 수치 구간별 실제 거래 성과 (필터 없음 전체 거래 기준):")
base_trades = all_results["필터 없음 (baseline)"][1]
zones = [
(0, 25, "Extreme Fear (0~25) "),
(26, 45, "Fear (26~45)"),
(46, 55, "Neutral (46~55)"),
(56, 75, "Greed (56~75)"),
(76, 100, "Extreme Greed(76~100)"),
]
print(f" {'F&G 구간':<28} {'건수':>5} {'승률':>6} {'평균PnL':>9} {'손익비':>6}")
print(" " + "-" * 60)
for lo, hi, name in zones:
subset = [t for t in base_trades if lo <= t.fng <= hi]
if not subset:
continue
s = stats(subset)
print(
f" {name:<28} {s['n']:>5}{s['wr']:>5.1f}% "
f"{s['avg_pnl']:>+8.3f}% {s['rr']:>5.2f}"
)
# ── 월별 F&G 흐름과 성과 ─────────────────────────────────
print()
print(" F&G 추이와 진입 시점 분포:")
sorted_fng = sorted(fng_map.items())[-70:] # 최근 70일
for dt_str, val in sorted_fng[::7]: # 1주 간격
bar_len = val // 5
bar = "" * bar_len
zone = ("극공포" if val <= 25 else "공포" if val <= 45
else "중립" if val <= 55 else "탐욕")
print(f" {dt_str} {val:>3} {bar:<20} {zone}")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,385 @@
"""F&G 필터 전후 수익 비교 시뮬레이션
필터 없음 vs F&G ≥ 41 필터 적용 시 1년치 성과를 직접 비교.
표시:
- 거래 수, 승률, 평균 PnL, 총 누적 PnL
- 거래당 고정 자본 100만 원 기준 KRW 환산 손익
- 월별 손익 흐름 (계절성 확인)
- 극공포 차단 일수 통계
결과는 Oracle DB(backtest_results)에 저장.
데이터: 1년치 1h 캔들 (배치 수집)
"""
import os as _os, sys as _sys
_sys.path.insert(0, _os.path.dirname(_os.path.dirname(_os.path.abspath(__file__))))
from __future__ import annotations
import datetime
import json
import sys
import time
import urllib.request
from dataclasses import dataclass
import pandas as pd
import pyupbit
# ── DB 저장 ─────────────────────────────────────────────────
try:
from backtest_db import ensure_tables, insert_run, insert_result, insert_trades_bulk
DB_ENABLED = True
except Exception as e:
print(f" [DB 비활성화] {e}")
DB_ENABLED = False
TICKERS = [
"KRW-BTC", "KRW-ETH", "KRW-XRP", "KRW-SOL", "KRW-DOGE",
"KRW-ADA", "KRW-DOT", "KRW-NEAR", "KRW-AVAX", "KRW-LINK",
"KRW-SUI", "KRW-HBAR",
"KRW-VIRTUAL", "KRW-SXP", "KRW-CFG", "KRW-HOLO",
"KRW-KAVA", "KRW-KNC",
]
CAPITAL_PER_TRADE = 1_000_000 # 거래당 고정 자본 (KRW)
# 전략 파라미터 (현행)
VOL_MULT = 2.0
QUIET_2H = 2.0
SIG_TO_H = 8
MOM_THR = 3.0
SIG_CANCEL = 3.0
TRAIL_STOP = 0.015
TIME_H = 24
TIME_MIN = 3.0
FNG_MIN = 41 # 이 값 미만이면 진입 차단
# ── 데이터 수집 ──────────────────────────────────────────────
def fetch_1y(ticker: str, total_days: int = 365) -> pd.DataFrame | None:
all_dfs = []
end = datetime.datetime.now()
batch = 1440
prev_oldest = None
while True:
df = pyupbit.get_ohlcv(
ticker, interval="minute60", count=batch,
to=end.strftime("%Y-%m-%d %H:%M:%S"),
)
if df is None or df.empty:
break
all_dfs.append(df)
oldest = df.index[0]
if prev_oldest is not None and oldest >= prev_oldest:
break
prev_oldest = oldest
cutoff = datetime.datetime.now() - datetime.timedelta(days=total_days)
if oldest <= cutoff:
break
end = oldest
time.sleep(0.12)
if not all_dfs:
return None
combined = pd.concat(all_dfs).sort_index()
combined = combined[~combined.index.duplicated(keep="last")]
cutoff = datetime.datetime.now() - datetime.timedelta(days=total_days)
return combined[combined.index >= cutoff]
def load_fng() -> dict[str, int]:
url = "https://api.alternative.me/fng/?limit=400&format=json"
with urllib.request.urlopen(url, timeout=10) as r:
data = json.loads(r.read())
return {
datetime.datetime.fromtimestamp(int(d["timestamp"])).strftime("%Y-%m-%d"):
int(d["value"])
for d in data["data"]
}
def fng_val(fng_map, ts) -> int:
return fng_map.get(ts.strftime("%Y-%m-%d"), 50)
# ── 시뮬레이션 ──────────────────────────────────────────────
@dataclass
class Trade:
pnl: float
h: int
fng: int
exit: str
date: str # YYYY-MM
def simulate(df, fng_map, fng_min: int | None = None) -> list[Trade]:
closes = df["close"].values
vols = df["volume"].values
idx = df.index
trades: list[Trade] = []
sig_px = sig_i = None
pos_buy = pos_peak = pos_i = pos_fng = None
for i in range(7, len(closes) - max(TIME_H + 4, 10)):
if pos_buy is not None:
cur = closes[i]
if cur > pos_peak:
pos_peak = cur
if (pos_peak - cur) / pos_peak >= TRAIL_STOP:
trades.append(Trade(
(cur - pos_buy) / pos_buy * 100,
i - pos_i, pos_fng, "trail",
idx[i].strftime("%Y-%m"),
))
pos_buy = pos_peak = pos_i = pos_fng = sig_px = sig_i = None
continue
if i - pos_i >= TIME_H:
pnl = (cur - pos_buy) / pos_buy * 100
if pnl < TIME_MIN:
trades.append(Trade(
pnl, i - pos_i, pos_fng, "time",
idx[i].strftime("%Y-%m"),
))
pos_buy = pos_peak = pos_i = pos_fng = sig_px = sig_i = None
continue
continue
if sig_px is not None:
if i - sig_i > SIG_TO_H:
sig_px = sig_i = None
elif (closes[i] - sig_px) / sig_px * 100 < -SIG_CANCEL:
sig_px = sig_i = None
if sig_px is None:
vol_avg = vols[i - 6:i - 1].mean()
if vol_avg <= 0:
continue
if vols[i - 1] / vol_avg >= VOL_MULT:
if abs(closes[i] - closes[i - 2]) / closes[i - 2] * 100 < QUIET_2H:
sig_px = closes[i]
sig_i = i
continue
fv = fng_val(fng_map, idx[i])
if fng_min is not None and fv < fng_min:
continue
if (closes[i] - sig_px) / sig_px * 100 >= MOM_THR:
pos_buy = pos_peak = closes[i]
pos_i = i
pos_fng = fv
sig_px = sig_i = None
return trades
def stats(trades: list[Trade]) -> dict:
if not trades:
return dict(n=0, wr=0, avg_pnl=0, total_pnl=0, rr=0,
avg_win=0, avg_loss=0, max_dd=0, krw_total=0)
wins = [t for t in trades if t.pnl > 0]
losses = [t for t in trades if t.pnl <= 0]
aw = sum(t.pnl for t in wins) / len(wins) if wins else 0
al = sum(t.pnl for t in losses) / len(losses) if losses else 0
cum = pk = max_dd = 0.0
for t in trades:
cum += t.pnl
if cum > pk: pk = cum
if pk - cum > max_dd: max_dd = pk - cum
total_pnl = sum(t.pnl for t in trades)
return dict(
n=len(trades), wr=len(wins) / len(trades) * 100,
avg_pnl=total_pnl / len(trades),
total_pnl=total_pnl,
rr=abs(aw / al) if al else 0,
avg_win=aw, avg_loss=al, max_dd=max_dd,
krw_total=total_pnl / 100 * CAPITAL_PER_TRADE,
)
def monthly_pnl(trades: list[Trade]) -> dict[str, float]:
"""월별 누적 PnL(%) 반환."""
monthly: dict[str, float] = {}
for t in trades:
monthly[t.date] = monthly.get(t.date, 0) + t.pnl
return dict(sorted(monthly.items()))
def main():
print("F&G 데이터 로드...")
fng_map = load_fng()
# F&G 분포
block_days = sum(1 for v in fng_map.values() if v < FNG_MIN)
total_days = len(fng_map)
print(f" 1년 F&G 분포: 진입차단(< {FNG_MIN}) = {block_days}일 / {total_days}"
f"({block_days/total_days*100:.1f}%)")
print(f" 진입허용(≥ {FNG_MIN}) = {total_days - block_days}일 ({(total_days-block_days)/total_days*100:.1f}%)\n")
print(f"종목 1년치 데이터 수집 중 ({len(TICKERS)}개)...")
datasets: dict[str, pd.DataFrame] = {}
for i, tk in enumerate(TICKERS):
try:
df = fetch_1y(tk, total_days=365)
if df is not None and len(df) > 100:
datasets[tk] = df
sys.stderr.write(f"\r {i+1}/{len(TICKERS)} {tk} ({len(df)}h) ")
except Exception as e:
sys.stderr.write(f"\r {tk} 실패: {e} ")
sys.stderr.write("\n")
print(f" 완료: {len(datasets)}개 종목\n")
# ── 두 가지 조건 시뮬레이션 ──────────────────────────────
# A: 필터 없음 (현행)
# B: F&G ≥ 41 (신규)
all_trades_A: list[Trade] = []
all_trades_B: list[Trade] = []
per_ticker_A: dict[str, list[Trade]] = {}
per_ticker_B: dict[str, list[Trade]] = {}
for tk, df in datasets.items():
ta = simulate(df, fng_map, fng_min=None)
tb = simulate(df, fng_map, fng_min=FNG_MIN)
all_trades_A.extend(ta)
all_trades_B.extend(tb)
per_ticker_A[tk] = ta
per_ticker_B[tk] = tb
sa = stats(all_trades_A)
sb = stats(all_trades_B)
# ── 결과 출력 ─────────────────────────────────────────────
print("=" * 80)
print(f" F&G 필터 전후 비교 (1년치 / {len(datasets)}개 종목 / 1h캔들 / 자본 {CAPITAL_PER_TRADE:,}원/거래)")
print("=" * 80)
print(f" {'조건':<26} {'거래':>5} {'승률':>6} {'평균PnL':>8} "
f"{'손익비':>5} {'총PnL':>8} {'MaxDD':>7} {'KRW손익':>14}")
print(" " + "-" * 76)
for label, s in [("필터 없음 (현행)", sa), (f"F&G≥{FNG_MIN} 필터 (신규)", sb)]:
krw_str = f"{s['krw_total']:>+,.0f}"
print(
f" {label:<26} {s['n']:>5}{s['wr']:>5.1f}% "
f"{s['avg_pnl']:>+7.3f}% {s['rr']:>4.2f} "
f"{s['total_pnl']:>+7.1f}% -{s['max_dd']:>5.1f}% {krw_str:>14}"
)
diff_trades = sb["n"] - sa["n"]
diff_krw = sb["krw_total"] - sa["krw_total"]
diff_wr = sb["wr"] - sa["wr"]
print(f"\n 변화: 거래수 {diff_trades:+d}건 | 승률 {diff_wr:+.1f}%p | "
f"KRW손익 {diff_krw:>+,.0f}")
# ── 월별 손익 흐름 ────────────────────────────────────────
print()
print(" 월별 손익 비교 (필터없음 vs F&G≥41):")
print(f" {'':>8} {'차단일수':>6} {'필터없음':>9} {'F&G필터':>9} {'개선':>8} {'누적(필터)':>12}")
print(" " + "-" * 62)
ma = monthly_pnl(all_trades_A)
mb = monthly_pnl(all_trades_B)
all_months = sorted(set(ma.keys()) | set(mb.keys()))
cum_b = 0.0
for m in all_months:
pa = ma.get(m, 0.0)
pb = mb.get(m, 0.0)
cum_b += pb
diff = pb - pa
# 해당 월 차단 일수
yr, mo = int(m[:4]), int(m[5:])
blocked = sum(
1 for d, v in fng_map.items()
if d.startswith(m) and v < FNG_MIN
)
bar = "" * min(int(abs(pb) / 3), 12) if pb > 0 else "" * min(int(abs(pb) / 3), 12)
sign = "+" if pb > 0 else ""
diff_sign = "" if diff > 0 else ("" if diff < 0 else "=")
print(
f" {m} {blocked:>4}일차단 "
f"{pa:>+8.1f}% {sign}{pb:>8.1f}% "
f"{diff_sign}{abs(diff):>6.1f}% {cum_b:>+10.1f}%"
)
# ── 종목별 비교 (상위/하위) ───────────────────────────────
print()
print(" 종목별 성과 비교 (필터없음 vs F&G≥41):")
print(f" {'종목':<14} {'현행거래':>6} {'현행PnL':>8} {'필터거래':>7} {'필터PnL':>8} {'개선':>8}")
print(" " + "-" * 58)
ticker_rows = []
for tk in sorted(datasets.keys()):
ta_list = per_ticker_A.get(tk, [])
tb_list = per_ticker_B.get(tk, [])
pa = sum(t.pnl for t in ta_list) if ta_list else 0
pb = sum(t.pnl for t in tb_list) if tb_list else 0
ticker_rows.append((tk, len(ta_list), pa, len(tb_list), pb, pb - pa))
for row in sorted(ticker_rows, key=lambda x: x[5], reverse=True):
tk, na, pa, nb, pb, delta = row
mark = "" if delta > 1 else ("" if delta < -1 else " =")
print(
f" {tk:<14} {na:>6}{pa:>+7.1f}% {nb:>6}{pb:>+7.1f}% "
f"{mark}{abs(delta):>6.1f}%"
)
# ── 극공포 차단 효과 분석 ─────────────────────────────────
print()
print(f" F&G < {FNG_MIN} 구간(차단) 거래 성과 분석:")
blocked_trades = [t for t in all_trades_A if t.fng < FNG_MIN]
if blocked_trades:
sb2 = stats(blocked_trades)
print(f" → 차단된 거래 수: {sb2['n']}")
print(f" → 차단 거래 승률: {sb2['wr']:.1f}%")
print(f" → 차단 거래 평균 PnL: {sb2['avg_pnl']:+.3f}%")
print(f" → 차단으로 절약된 손실: {sb2['krw_total']:>+,.0f}"
f"({CAPITAL_PER_TRADE:,}× {sb2['n']}거래 기준)")
else:
print(" → 차단된 거래 없음")
# ── 최적 임계값 확인 ─────────────────────────────────────
print()
print(f" F&G 임계값별 성과 비교 (현행 기준 비교):")
print(f" {'임계값':>8} {'거래':>5} {'승률':>6} {'평균PnL':>9} {'KRW손익':>14}")
print(" " + "-" * 52)
for thr in [25, 30, 35, 41, 45, 50]:
filtered = [t for t in all_trades_A if t.fng >= thr]
if not filtered:
continue
sf = stats(filtered)
marker = " ◀ 채택" if thr == FNG_MIN else ""
print(
f" {thr:>5}이상 {sf['n']:>5}{sf['wr']:>5.1f}% "
f"{sf['avg_pnl']:>+8.3f}% {sf['krw_total']:>+14,.0f}{marker}"
)
# ── DB 저장 ───────────────────────────────────────────────
if DB_ENABLED:
try:
ensure_tables()
params = {
"tickers": len(datasets), "days": 365, "candle": "1h",
"trail_stop": TRAIL_STOP, "mom_thr": MOM_THR,
"fng_min_new": FNG_MIN, "capital_per_trade": CAPITAL_PER_TRADE,
}
run_id = insert_run(
"fng_sim_comparison",
f"F&G 필터 전후 비교 시뮬레이션 (1년치 / F&G≥{FNG_MIN})",
params,
)
insert_result(run_id, "필터 없음 (현행)", sa, None, None)
insert_result(run_id, f"F&G≥{FNG_MIN} 필터 (신규)", sb, FNG_MIN, None)
for tk, t_list in per_ticker_A.items():
insert_trades_bulk(run_id, "필터없음", tk, t_list)
for tk, t_list in per_ticker_B.items():
insert_trades_bulk(run_id, f"fng_ge{FNG_MIN}", tk, t_list)
print(f"\n [DB 저장 완료] run_id: {run_id}")
except Exception as e:
print(f"\n [DB 저장 실패] {e}")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,240 @@
"""interval_sweep.py — 봉 단위별 vol-lead 전략 성과 비교.
10분봉 캐시 데이터를 리샘플링해 10/20/30/60분봉 성과를 비교한다.
추가로 극단거래량(100x) 즉시 진입 조건도 함께 테스트.
데이터: sim10m_cache.pkl (10분봉 45일)
"""
import pickle
import sys
from pathlib import Path
import pandas as pd
from dotenv import load_dotenv
load_dotenv(dotenv_path=Path(__file__).parent / ".env")
sys.path.insert(0, str(Path(__file__).parent))
# ── 고정 파라미터 ─────────────────────────────────────────
CACHE_FILE = Path("sim10m_cache.pkl")
TOP30_FILE = Path("top30_tickers.pkl")
TOP_N = 20
FEE = 0.0005
TIME_STOP_MIN_PCT = 3.0
ATR_MULT = 1.5
ATR_MIN = 0.010
ATR_MAX = 0.020
VOL_MULT = 2.0
QUIET_PCT = 2.0
THRESH = 4.8
EXTREME_VOL = 100 # 극단적 거래량 배수
# 봉 단위별 시간 기반 파라미터 (모두 "시간"으로 정의 → 봉수로 자동 변환)
INTERVALS = [10, 20, 30, 40, 50, 60] # 분 단위
LOCAL_VOL_H = 5.0 # 로컬 거래량 기준 5시간
QUIET_H = 2.0 # 횡보 기준 2시간
SIGNAL_TO_H = 8.0 # 신호 유효 8시간
ATR_H = 5.0 # ATR 계산 5시간
TIME_STOP_H = 8.0 # 타임스탑 8시간
# ── 리샘플링 ──────────────────────────────────────────────
def resample(df, minutes):
"""10분봉 DataFrame을 N분봉으로 리샘플링."""
rule = f"{minutes}T"
resampled = df.resample(rule).agg({
"open": "first",
"high": "max",
"low": "min",
"close": "last",
"volume": "sum",
}).dropna(subset=["close"])
return resampled
# ── ATR 계산 (시뮬용) ─────────────────────────────────────
def calc_atr(df, buy_idx, n):
sub = df.iloc[max(0, buy_idx - n - 1):buy_idx]
if len(sub) < 3:
return ATR_MIN
try:
avg = ((sub["high"] - sub["low"]) / sub["low"]).iloc[-n:].mean()
return float(max(ATR_MIN, min(ATR_MAX, avg * ATR_MULT)))
except Exception:
return ATR_MIN
# ── 포지션 시뮬 ──────────────────────────────────────────
def simulate_pos(df, buy_idx, buy_price, stop_pct, ts_candles):
peak = buy_price
for i in range(buy_idx + 1, len(df)):
row = df.iloc[i]
ts = df.index[i]
if row["high"] > peak:
peak = row["high"]
if row["low"] <= peak * (1 - stop_pct):
sp = peak * (1 - stop_pct)
pnl = (sp * (1-FEE) - buy_price * (1+FEE)) / (buy_price * (1+FEE)) * 100
return pnl > 0, sp, ts, pnl
pnl_now = (row["close"] - buy_price) / buy_price * 100
if (i - buy_idx) >= ts_candles and pnl_now < TIME_STOP_MIN_PCT:
pnl = (row["close"] * (1-FEE) - buy_price * (1+FEE)) / (buy_price * (1+FEE)) * 100
return pnl > 0, row["close"], ts, pnl
last = df.iloc[-1]["close"]
pnl = (last * (1-FEE) - buy_price * (1+FEE)) / (buy_price * (1+FEE)) * 100
return pnl > 0, last, df.index[-1], pnl
# ── vol-lead 전략 실행 ────────────────────────────────────
def run_vol_lead(df, minutes, use_extreme=False):
"""vol-lead 전략 실행. use_extreme=True이면 극단거래량 즉시 진입 추가."""
candles_per_h = 60 / minutes
local_vol_n = int(LOCAL_VOL_H * candles_per_h)
quiet_n = int(QUIET_H * candles_per_h)
signal_to_n = int(SIGNAL_TO_H * candles_per_h)
atr_n = int(ATR_H * candles_per_h)
ts_n = int(TIME_STOP_H * candles_per_h)
trades = []
sig_i = sig_p = None
extreme_pending = False
in_pos = False
buy_idx = buy_price = stop_pct = entry_type = None
i = max(local_vol_n + 2, quiet_n + 1)
while i < len(df):
if in_pos:
is_win, sp, sdt, pnl = simulate_pos(df, buy_idx, buy_price, stop_pct, ts_n)
next_i = next((j for j in range(i, len(df)) if df.index[j] > sdt), len(df))
trades.append((is_win, pnl, df.index[buy_idx], sdt, entry_type))
in_pos = False
sig_i = sig_p = None
extreme_pending = False
i = next_i
continue
close = df.iloc[i]["close"]
vol_p = df.iloc[i-1]["volume"]
vol_avg = df.iloc[i-local_vol_n-1:i-1]["volume"].mean()
vol_r = vol_p / vol_avg if vol_avg > 0 else 0
# 극단적 거래량 → 다음 봉 즉시 진입
if use_extreme and not extreme_pending and vol_r >= EXTREME_VOL:
extreme_pending = True
i += 1
continue
if use_extreme and extreme_pending:
in_pos = True; buy_idx = i; buy_price = close
stop_pct = calc_atr(df, i, atr_n)
entry_type = "극단"
extreme_pending = False
sig_i = sig_p = None
i += 1
continue
# 일반 vol-lead
close_qh = df.iloc[i - quiet_n]["close"]
chg_qh = abs(close - close_qh) / close_qh * 100
quiet = chg_qh < QUIET_PCT
spike = vol_r >= VOL_MULT
if quiet and spike:
if sig_i is None: sig_i, sig_p = i, close
else:
if sig_i is not None and close < sig_p: sig_i = sig_p = None
if sig_i is not None and (i - sig_i) > signal_to_n:
sig_i = sig_p = None
if sig_i is not None and (close - sig_p) / sig_p * 100 >= THRESH:
in_pos = True; buy_idx = i; buy_price = close
stop_pct = calc_atr(df, i, atr_n)
entry_type = "일반"
sig_i = sig_p = None
i += 1
return trades
# ── 통계 ─────────────────────────────────────────────────
def calc_stats(trades):
if not trades:
return {"n": 0, "wr": 0.0, "cum": 0.0, "dd": 0.0}
wins = sum(1 for t in trades if t[0])
cum = peak = dd = 0.0
for t in sorted(trades, key=lambda x: x[2]):
cum += t[1]
peak = max(peak, cum)
dd = max(dd, peak - cum)
return {"n": len(trades), "wr": wins / len(trades) * 100, "cum": cum, "dd": dd}
# ── 메인 ─────────────────────────────────────────────────
def main():
print("캐시 로드 중...")
cache = pickle.load(open(CACHE_FILE, "rb"))
top30 = pickle.load(open(TOP30_FILE, "rb"))
tickers = [t for t in top30[:TOP_N] if t in cache["10m"]]
print(f"유효 종목: {len(tickers)}\n")
results = []
for minutes in INTERVALS:
# 10분봉은 그대로, 나머지는 리샘플링
ticker_data = {}
for t in tickers:
df10 = cache["10m"][t]
if minutes == 10:
ticker_data[t] = df10
else:
ticker_data[t] = resample(df10, minutes)
# 일반 vol-lead
all_trades = []
for t in tickers:
if t in ticker_data and len(ticker_data[t]) >= 50:
all_trades.extend(run_vol_lead(ticker_data[t], minutes, use_extreme=False))
s = calc_stats(all_trades)
results.append((f"{minutes}분봉 (일반)", s))
# 일반 + 극단 거래량
all_trades_ex = []
for t in tickers:
if t in ticker_data and len(ticker_data[t]) >= 50:
all_trades_ex.extend(run_vol_lead(ticker_data[t], minutes, use_extreme=True))
s_ex = calc_stats(all_trades_ex)
# 극단 거래량만 분리
extreme_trades = [t for t in all_trades_ex if t[4] == "극단"]
s_ext = calc_stats(extreme_trades)
results.append((f" +극단{EXTREME_VOL}x", s_ex, s_ext))
# 출력
print(f"{'='*72}")
print(f"봉 단위별 vol-lead 전략 비교 | 45일 | {len(tickers)}종목")
print(f"{'='*72}")
print(f"{'전략':20} {'거래수':>6} {'승률':>6} {'누적PnL%':>10} {'최대낙폭%':>10}")
print(f"{''*72}")
for row in results:
label = row[0]
s = row[1]
if s["n"] == 0:
print(f"{label:20} {'없음':>6}")
continue
print(f"{label:20} {s['n']:>6}{s['wr']:>5.1f}% {s['cum']:>+9.2f}% {-s['dd']:>+9.2f}%", end="")
# 극단 거래량 정보 추가
if len(row) == 3:
se = row[2]
if se["n"] > 0:
print(f" (극단:{se['n']}{se['wr']:.0f}% {se['cum']:+.1f}%)", end="")
print()
if label.startswith(" +"):
print() # 구분선
print(f"{'='*72}")
if __name__ == "__main__":
main()

177
archive/tests/krw_sim.py Normal file
View File

@@ -0,0 +1,177 @@
"""천만원 시드 기준 KRW 시뮬레이션.
- 20개 종목 × vol-lead 4.8% 전략
- MAX_POSITIONS=3 동시 보유 제약 적용
- 포지션별 예산 = 포트폴리오 / MAX_POSITIONS (복리)
- 거래를 시간순으로 처리 → 3개 이상 동시 보유 시 신호 스킵
"""
import os
import pickle
import sys
from datetime import datetime
from pathlib import Path
from dotenv import load_dotenv
load_dotenv()
sys.path.insert(0, str(Path(__file__).parent))
from vol_lead_sim import run_trend, run_vol_lead_thresh
BUDGET = 10_000_000 # 초기 시드
MAX_POS = 3 # 최대 동시 보유
THRESH = 4.8 # 진입 임계값 (%)
CACHE_FILE = Path("vol_lead_cache_30.pkl")
TOP30_FILE = Path("top30_tickers.pkl")
def collect_all_trades(data: dict, tickers: list, thresh: float) -> list:
"""모든 종목의 거래를 (buy_dt, sell_dt, ticker, is_win, pnl, reason) 목록으로 반환."""
all_trades = []
for t in tickers:
if t not in data:
continue
trades = run_vol_lead_thresh(data[t], thresh)
for is_win, pnl, buy_dt, sell_dt, reason in trades:
all_trades.append((buy_dt, sell_dt, t, is_win, pnl, reason))
all_trades.sort(key=lambda x: x[0]) # 진입 시간순 정렬
return all_trades
def apply_max_positions(all_trades: list, max_pos: int) -> tuple[list, list]:
"""MAX_POSITIONS 제약 적용. (허용 거래, 스킵 거래) 반환."""
open_exits = [] # 현재 열린 포지션의 청산 시각 목록
accepted = []
skipped = []
for trade in all_trades:
buy_dt, sell_dt = trade[0], trade[1]
# 이미 청산된 포지션 제거
open_exits = [s for s in open_exits if s > buy_dt]
if len(open_exits) < max_pos:
open_exits.append(sell_dt)
accepted.append(trade)
else:
skipped.append(trade)
return accepted, skipped
def simulate_krw(accepted: list, budget: float, max_pos: int) -> dict:
"""복리 KRW 시뮬레이션. 포지션당 예산 = 포트폴리오 / MAX_POSITIONS."""
portfolio = budget
total_krw = 0.0
monthly = {} # YYYY-MM → {'trades':0,'wins':0,'pnl':0}
trade_log = []
for buy_dt, sell_dt, ticker, is_win, pnl, reason in accepted:
pos_size = portfolio / max_pos
krw_profit = pos_size * pnl / 100
portfolio += krw_profit
total_krw += krw_profit
ym = buy_dt.strftime("%Y-%m")
if ym not in monthly:
monthly[ym] = {"trades": 0, "wins": 0, "pnl_krw": 0.0}
monthly[ym]["trades"] += 1
monthly[ym]["wins"] += int(is_win)
monthly[ym]["pnl_krw"] += krw_profit
trade_log.append({
"buy_dt": buy_dt,
"sell_dt": sell_dt,
"ticker": ticker,
"is_win": is_win,
"pnl_pct": pnl,
"krw_profit": krw_profit,
"portfolio": portfolio,
"reason": reason,
})
wins = sum(1 for t in accepted if t[3])
return {
"portfolio": portfolio,
"total_krw": total_krw,
"roi_pct": (portfolio - budget) / budget * 100,
"total": len(accepted),
"wins": wins,
"wr": wins / len(accepted) * 100 if accepted else 0,
"monthly": monthly,
"trade_log": trade_log,
}
def main() -> None:
data = pickle.load(open(CACHE_FILE, "rb"))
top30 = pickle.load(open(TOP30_FILE, "rb"))
valid = [t for t in top30 if t in data and len(data[t]) >= 400]
use20 = valid[:20]
print(f"{'='*65}")
print(f"천만원 시드 KRW 시뮬레이션 | vol-lead +{THRESH}% | 20종목")
print(f"MAX_POSITIONS={MAX_POS} | 복리 포지션 크기")
print(f"기간: 2026-01-15 ~ 2026-03-02 (46일)")
print(f"{'='*65}")
all_trades = collect_all_trades(data, use20, THRESH)
accepted, skipped = apply_max_positions(all_trades, MAX_POS)
result = simulate_krw(accepted, BUDGET, MAX_POS)
print(f"\n── 전체 결과 ─────────────────────────────────────────")
print(f" 신호 발생: {len(all_trades):>4}")
print(f" 실제 진입: {result['total']:>4}건 (MAX_POS={MAX_POS} 제약으로 {len(skipped)}건 스킵)")
print(f" 승/패: {result['wins']}{result['total']-result['wins']}패 (승률 {result['wr']:.0f}%)")
print(f" ─────────────────────────────────────────────────")
print(f" 초기 시드: {BUDGET:>14,.0f}")
print(f" 최종 자산: {result['portfolio']:>14,.0f}")
print(f" 순수익: {result['total_krw']:>+14,.0f}")
print(f" 수익률: {result['roi_pct']:>+13.2f}%")
# ── 월별 수익 ─────────────────────────────────────
print(f"\n── 월별 수익 ─────────────────────────────────────────")
print(f" {'':^8}{'거래':>4} {'승률':>5}{'월수익(KRW)':>14} {'누적수익(KRW)':>15}")
print(f" {''*58}")
cum = 0.0
for ym, m in sorted(result["monthly"].items()):
wr = m["wins"] / m["trades"] * 100 if m["trades"] else 0
cum += m["pnl_krw"]
print(f" {ym:^8}{m['trades']:>4}{wr:>4.0f}% │ "
f"{m['pnl_krw']:>+14,.0f}{cum:>+14,.0f}")
# ── 종목별 수익 ───────────────────────────────────
print(f"\n── 종목별 수익 ───────────────────────────────────────")
print(f" {'종목':<14}{'거래':>4} {'승률':>5}{'KRW수익':>14} {'평균/건':>10}")
print(f" {''*58}")
ticker_stats: dict = {}
for t in result["trade_log"]:
k = t["ticker"]
if k not in ticker_stats:
ticker_stats[k] = {"n": 0, "wins": 0, "krw": 0.0}
ticker_stats[k]["n"] += 1
ticker_stats[k]["wins"] += int(t["is_win"])
ticker_stats[k]["krw"] += t["krw_profit"]
for t, s in sorted(ticker_stats.items(), key=lambda x: -x[1]["krw"]):
wr = s["wins"] / s["n"] * 100 if s["n"] else 0
avg = s["krw"] / s["n"] if s["n"] else 0
print(f" {t:<14}{s['n']:>4}{wr:>4.0f}% │ "
f"{s['krw']:>+14,.0f}{avg:>+9,.0f}원/건")
# ── 전체 거래 내역 ────────────────────────────────
print(f"\n── 전체 거래 내역 ({len(result['trade_log'])}건) ─────────────────────")
print(f" {'#':>3} {'종목':<14} {'매수':^13} {'매도':^13} "
f"{'수익률':>7} {'KRW수익':>12} {'잔고':>12} {'사유'}")
print(f" {''*90}")
for i, t in enumerate(result["trade_log"], 1):
mark = "" if t["is_win"] else ""
print(f" {i:>3} {t['ticker']:<14} "
f"{t['buy_dt'].strftime('%m-%d %H:%M'):^13} "
f"{t['sell_dt'].strftime('%m-%d %H:%M'):^13} "
f"{mark}{t['pnl_pct']:>+6.2f}% "
f"{t['krw_profit']:>+12,.0f}"
f"{t['portfolio']:>12,.0f}"
f"{t['reason']}")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,189 @@
"""모멘텀 필터 유/무 비교 시뮬레이션.
A안: 추세(2h +5%) + 15분 워치리스트 (모멘텀 없음)
B안: 추세(2h +5%) + 모멘텀 + 15분 워치리스트 (현행)
"""
import os as _os, sys as _sys
_sys.path.insert(0, _os.path.dirname(_os.path.dirname(_os.path.abspath(__file__))))
import os, time
from dotenv import load_dotenv
load_dotenv()
import oracledb
import pyupbit
STOP_LOSS_PCT = float(os.getenv("STOP_LOSS_PCT", "1.5")) / 100
TIME_STOP_HOURS = int(os.getenv("TIME_STOP_HOURS", "8"))
TIME_STOP_MIN_PCT = float(os.getenv("TIME_STOP_MIN_GAIN_PCT", "3")) / 100
TREND_MIN_PCT = 5.0
MA_PERIOD = 20
LOCAL_VOL_HOURS = 5
VOL_MULT = float(os.getenv("VOLUME_MULTIPLIER", "2.0"))
CONFIRM_MINUTES = 15
FEE = 0.0005
_daily_cache = {}
_hourly_cache = {}
def get_conn():
return oracledb.connect(
user=os.getenv('ORACLE_USER'), password=os.getenv('ORACLE_PASSWORD'),
dsn=os.getenv('ORACLE_DSN'), config_dir=os.getenv('ORACLE_WALLET'))
def load_prices(cur, ticker, from_dt):
cur.execute("""SELECT price, recorded_at FROM price_history
WHERE ticker=:t AND recorded_at>=:f ORDER BY recorded_at""", t=ticker, f=from_dt)
return cur.fetchall()
def get_ma20(ticker, dt):
key = (ticker, dt.strftime("%Y-%m-%d"))
if key not in _daily_cache:
try:
df = pyupbit.get_ohlcv(ticker, interval="day", count=MA_PERIOD+2,
to=dt.strftime("%Y-%m-%d 09:00:00"))
_daily_cache[key] = df
time.sleep(0.1)
except:
_daily_cache[key] = None
df = _daily_cache[key]
if df is None or len(df) < MA_PERIOD:
return None
return df["close"].iloc[-MA_PERIOD:].mean()
def get_vol_ratio(ticker, dt):
key = (ticker, dt.strftime("%Y-%m-%d %H"))
if key not in _hourly_cache:
try:
df = pyupbit.get_ohlcv(ticker, interval="minute60", count=LOCAL_VOL_HOURS+3,
to=dt.strftime("%Y-%m-%d %H:%M:%S"))
_hourly_cache[key] = df
time.sleep(0.1)
except:
_hourly_cache[key] = None
df = _hourly_cache[key]
if df is None or len(df) < LOCAL_VOL_HOURS+1:
return 0.0
rv = df["volume"].iloc[-2]
la = df["volume"].iloc[-(LOCAL_VOL_HOURS+1):-2].mean()
return rv/la if la > 0 else 0.0
def check_trend(prices, idx):
lb = 12 # 2h = 12 * 10min
if idx < lb: return False
curr, past = prices[idx][0], prices[idx-lb][0]
return past > 0 and (curr-past)/past*100 >= TREND_MIN_PCT
def check_momentum(ticker, price, dt):
ma = get_ma20(ticker, dt)
if ma is None or price <= ma: return False
return get_vol_ratio(ticker, dt) >= VOL_MULT
def simulate_pos(prices, buy_idx, buy_price):
buy_dt = prices[buy_idx][1]
peak = buy_price
for price, ts in prices[buy_idx+1:]:
if price > peak: peak = price
elapsed_h = (ts - buy_dt).total_seconds() / 3600
pnl = (price - buy_price) / buy_price
if (peak - price) / peak >= STOP_LOSS_PCT:
net = (price*(1-FEE) - buy_price*(1+FEE)) / (buy_price*(1+FEE)) * 100
return net > 0, price, ts, f"트레일링({pnl*100:+.1f}%)", net
if elapsed_h >= TIME_STOP_HOURS and pnl < TIME_STOP_MIN_PCT:
net = (price*(1-FEE) - buy_price*(1+FEE)) / (buy_price*(1+FEE)) * 100
return net > 0, price, ts, f"타임스탑", net
lp, lt = prices[-1]
net = (lp*(1-FEE) - buy_price*(1+FEE)) / (buy_price*(1+FEE)) * 100
return net > 0, lp, lt, "데이터종료", net
def run_scenario(prices, ticker, use_momentum, label):
wins = losses = 0
total_pnl = 0.0
watchlist_dt = None
in_pos = False
buy_idx = buy_price = None
idx = 0
trades = []
while idx < len(prices):
price, dt = prices[idx]
if in_pos:
is_win, sp, sdt, reason, pnl = simulate_pos(prices, buy_idx, buy_price)
next_idx = next((i for i,(_, ts) in enumerate(prices) if ts > sdt), len(prices))
if is_win: wins += 1
else: losses += 1
total_pnl += pnl
trades.append((is_win, buy_price, sp, pnl, dt, sdt, reason))
in_pos = False
watchlist_dt = None
idx = next_idx
continue
trend_ok = check_trend(prices, idx)
mom_ok = check_momentum(ticker, price, dt) if (use_momentum and trend_ok) else True
if trend_ok and mom_ok:
if watchlist_dt is None:
watchlist_dt = dt
elif (dt - watchlist_dt).total_seconds() >= CONFIRM_MINUTES * 60:
in_pos = True
buy_idx = idx
buy_price = price
watchlist_dt = None
else:
watchlist_dt = None
idx += 1
total = wins + losses
wr = wins/total*100 if total else 0
return {'label': label, 'total': total, 'wins': wins, 'losses': losses,
'wr': wr, 'pnl': total_pnl, 'trades': trades}
def print_result(r):
print(f"\n [{r['label']}]")
print(f"{r['total']}건 | 승률={r['wr']:.0f}% ({r['wins']}{r['losses']}패) | 누적={r['pnl']:+.2f}%")
for i, (iw, bp, sp, pnl, bdt, sdt, reason) in enumerate(r['trades'], 1):
mark = "" if iw else ""
print(f" #{i}: {bp:.4f}{sp:.4f}원 | {mark} {pnl:+.2f}% | {reason}"
f" ({bdt.strftime('%m-%d %H:%M')}{sdt.strftime('%m-%d %H:%M')})")
def main():
conn = get_conn()
cur = conn.cursor()
cur.execute("SELECT MAX(recorded_at) FROM price_history")
end_dt = cur.fetchone()[0]
print("=" * 62)
print("모멘텀 필터 유/무 비교 (WF차단 발동 이후 전 기간)")
print("A안: 추세+워치리스트만 B안: 추세+모멘텀+워치리스트(현행)")
print("=" * 62)
summary = []
for ticker in ['KRW-DKA', 'KRW-LAYER', 'KRW-SIGN']:
cur.execute("SELECT traded_at FROM trade_results WHERE ticker=:t ORDER BY traded_at", t=ticker)
rows = cur.fetchall()
wf_dt = rows[4][0]
prices = load_prices(cur, ticker, wf_dt)
print(f"\n{''*62}")
print(f"[{ticker}] WF차단: {wf_dt.strftime('%m-%d %H:%M')} 데이터: {len(prices)}")
rA = run_scenario(prices, ticker, use_momentum=False, label="A: 추세+워치만")
rB = run_scenario(prices, ticker, use_momentum=True, label="B: 추세+모멘텀+워치(현행)")
print_result(rA)
print_result(rB)
summary.append((ticker, rA, rB))
print(f"\n{'='*62}")
print(f"{'종목':<12} {'A안 거래':>6} {'A안 승률':>8} {'A안 PnL':>10}{'B안 거래':>6} {'B안 승률':>8} {'B안 PnL':>10}")
print(f"{''*62}")
for ticker, rA, rB in summary:
print(f"{ticker:<12} {rA['total']:>6}{rA['wr']:>6.0f}% {rA['pnl']:>+9.2f}% │"
f" {rB['total']:>6}{rB['wr']:>6.0f}% {rB['pnl']:>+9.2f}%")
conn.close()
if __name__ == "__main__":
main()

216
archive/tests/ohlcv_db.py Normal file
View File

@@ -0,0 +1,216 @@
"""OHLCV 시계열 캐시 — Oracle ADB ohlcv_hourly 테이블.
기능:
- 테이블 생성 (없으면)
- pkl → DB 최초 적재
- DB → DataFrame dict 로드 (시뮬용)
- 증분 업데이트 (신규 봉만 API 페치)
"""
import os as _os, sys as _sys
_sys.path.insert(0, _os.path.dirname(_os.path.dirname(_os.path.abspath(__file__))))
from __future__ import annotations
import os
import pickle
import time
from datetime import datetime
from pathlib import Path
import pandas as pd
import pyupbit
from dotenv import load_dotenv
load_dotenv(dotenv_path=Path(__file__).parent / ".env")
from core.price_db import _conn
# ── DDL ───────────────────────────────────────────────
_DDL = """
CREATE TABLE ohlcv_hourly (
ticker VARCHAR2(20) NOT NULL,
candle_time TIMESTAMP NOT NULL,
open_price NUMBER(20,8) NOT NULL,
high_price NUMBER(20,8) NOT NULL,
low_price NUMBER(20,8) NOT NULL,
close_price NUMBER(20,8) NOT NULL,
volume NUMBER(30,8) NOT NULL,
CONSTRAINT pk_ohlcv PRIMARY KEY (ticker, candle_time)
)
"""
def ensure_table() -> None:
with _conn() as conn:
cur = conn.cursor()
cur.execute("SELECT COUNT(*) FROM user_tables WHERE table_name='OHLCV_HOURLY'")
if cur.fetchone()[0] == 0:
conn.cursor().execute(_DDL)
print("ohlcv_hourly 테이블 생성 완료")
else:
print("ohlcv_hourly 테이블 이미 존재")
# ── 적재 ──────────────────────────────────────────────
def insert_df(ticker: str, df: pd.DataFrame, batch: int = 500) -> int:
"""DataFrame → ohlcv_hourly 배치 삽입.
신규 레코드만 삽입: 기존 candle_time 조회 후 Python에서 필터링.
"""
sql_existing = """
SELECT candle_time FROM ohlcv_hourly
WHERE ticker = :1
"""
sql_insert = """
INSERT INTO ohlcv_hourly
(ticker, candle_time, open_price, high_price, low_price, close_price, volume)
VALUES (:1, :2, :3, :4, :5, :6, :7)
"""
rows = [
(
ticker,
row.name.to_pydatetime().replace(tzinfo=None),
float(row["open"]),
float(row["high"]),
float(row["low"]),
float(row["close"]),
float(row["volume"]),
)
for _, row in df.iterrows()
]
with _conn() as conn:
cur = conn.cursor()
# 기존 candle_time 조회 → 중복 제거
cur.execute(sql_existing, [ticker])
existing = {r[0].replace(tzinfo=None) for r in cur.fetchall()}
new_rows = [r for r in rows if r[1] not in existing]
if not new_rows:
return 0
for i in range(0, len(new_rows), batch):
cur.executemany(sql_insert, new_rows[i : i + batch])
return len(new_rows)
def load_from_pkl(pkl_path: str | Path) -> None:
"""pkl 파일의 모든 종목을 DB에 적재."""
pkl_path = Path(pkl_path)
data = pickle.load(open(pkl_path, "rb"))
ensure_table()
total = 0
for ticker, df in data.items():
n = insert_df(ticker, df)
total += n
print(f" {ticker}: {n}건 적재")
print(f"\n{total:,}건 적재 완료")
# ── 로드 ──────────────────────────────────────────────
def load_from_db(tickers: list[str], from_date: str = "2025-03-02") -> dict:
"""DB → {ticker: DataFrame} 반환 (시뮬용)."""
from_dt = datetime.strptime(from_date, "%Y-%m-%d")
data = {}
sql = """
SELECT candle_time, open_price, high_price, low_price, close_price, volume
FROM ohlcv_hourly
WHERE ticker = :1 AND candle_time >= :2
ORDER BY candle_time
"""
with _conn() as conn:
for ticker in tickers:
cur = conn.cursor()
cur.execute(sql, [ticker, from_dt])
rows = cur.fetchall()
if not rows:
continue
df = pd.DataFrame(
rows,
columns=["candle_time", "open", "high", "low", "close", "volume"],
)
df.set_index("candle_time", inplace=True)
df.index = pd.to_datetime(df.index)
data[ticker] = df
return data
# ── 증분 업데이트 ──────────────────────────────────────
def update_incremental(tickers: list[str]) -> None:
"""각 종목의 최신 봉 이후 데이터를 API에서 가져와 적재."""
sql_max = "SELECT MAX(candle_time) FROM ohlcv_hourly WHERE ticker = :1"
for ticker in tickers:
with _conn() as conn:
cur = conn.cursor()
cur.execute(sql_max, [ticker])
row = cur.fetchone()
latest = row[0] if row and row[0] else None
if latest:
to_dt = None # 최신까지 fetch
kwargs: dict = dict(ticker=ticker, interval="minute60", count=200)
df = pyupbit.get_ohlcv(**kwargs)
if df is None or df.empty:
continue
df.index = df.index.tz_localize(None)
# latest 이후만 삽입
new_df = df[df.index > latest.replace(tzinfo=None)]
if new_df.empty:
print(f" {ticker}: 신규 봉 없음")
continue
n = insert_df(ticker, new_df)
print(f" {ticker}: +{n}봉 추가")
else:
print(f" {ticker}: DB에 없음, 전체 로드 필요")
time.sleep(0.2)
# ── CLI ───────────────────────────────────────────────
if __name__ == "__main__":
import sys
cmd = sys.argv[1] if len(sys.argv) > 1 else "status"
if cmd == "init":
# pkl → DB 최초 적재
pkl = sys.argv[2] if len(sys.argv) > 2 else "vol_lead_cache_365.pkl"
print(f"pkl 적재: {pkl}")
load_from_pkl(pkl)
elif cmd == "update":
# 증분 업데이트
import pickle as _pk
top30 = _pk.load(open("top30_tickers.pkl", "rb"))
print("증분 업데이트...")
update_incremental(top30)
elif cmd == "status":
# 종목별 레코드 수 확인
with _conn() as conn:
cur = conn.cursor()
try:
cur.execute("""
SELECT ticker, COUNT(*), MIN(candle_time), MAX(candle_time)
FROM ohlcv_hourly
GROUP BY ticker
ORDER BY ticker
""")
rows = cur.fetchall()
if rows:
print(f"{'종목':<16} {'봉수':>6} {'시작':^12} {'종료':^12}")
print("-" * 52)
for r in rows:
print(f"{r[0]:<16} {r[1]:>6}"
f"{r[2].strftime('%y-%m-%d'):^12} "
f"{r[3].strftime('%y-%m-%d'):^12}")
print(f"\n{sum(r[1] for r in rows):,}봉 / {len(rows)}종목")
else:
print("ohlcv_hourly 테이블이 비어 있거나 없음")
except Exception as e:
print(f"오류: {e}")

View File

@@ -0,0 +1,248 @@
"""눌림목 진입 vs 모멘텀 진입 백테스트 비교
전략 A — 모멘텀 (현행):
거래량 급증 + 횡보 감지 → 신호가 기록
→ 신호가 대비 +3% 상승 확인 시 즉시 매수
전략 B — 눌림목:
거래량 급증 + 횡보 감지 → 신호가 기록
→ 신호가 대비 -1.5% 이하로 눌림 확인
→ 눌림 저점에서 +0.5% 회복 시 매수
공통 청산:
트레일링 스탑 (최고가 대비 -2%) + 타임 스탑 (24h / +3% 미달 시)
데이터: 1h 캔들 60일 (최근)
"""
import os as _os, sys as _sys
_sys.path.insert(0, _os.path.dirname(_os.path.dirname(_os.path.abspath(__file__))))
from __future__ import annotations
import pyupbit, time, sys
from collections import defaultdict
# ─── 종목 (현재 전략이 스캔하는 상위권 종목) ───────────────────
TICKERS = [
"KRW-BTC", "KRW-ETH", "KRW-XRP", "KRW-SOL", "KRW-DOGE",
"KRW-ADA", "KRW-DOT", "KRW-NEAR", "KRW-AVAX", "KRW-LINK",
"KRW-SUI", "KRW-HBAR", "KRW-TRX", "KRW-ATOM", "KRW-SAND",
"KRW-VIRTUAL", "KRW-SXP", "KRW-CFG", "KRW-HOLO", "KRW-ENSO",
"KRW-KAVA", "KRW-KNC", "KRW-OM", "KRW-STEEM", "KRW-ORBS",
]
# ─── 공통 파라미터 ──────────────────────────────────────────────
VOL_MULT = 2.0 # 거래량 급증 배수 (직전 캔들 / 5h 평균)
QUIET_2H = 2.0 # 2h 횡보 기준 (%)
SIGNAL_TO_H = 8 # 신호 유효 시간 (h)
TRAIL_STOP = 0.020 # 트레일링 스탑 2%
TIME_STOP_H = 24 # 타임 스탑 기준 시간
TIME_STOP_MIN = 3.0 # 타임 스탑 최소 수익 (%)
SIGNAL_CANCEL = 3.0 # 신호가 대비 -3% 하락 시 신호 취소 (%)
# ─── 전략 A: 모멘텀 진입 ─────────────────────────────────────
MOMENTUM_THR = 3.0 # 신호가 대비 +3% 이상 상승 시 매수
# ─── 전략 B: 눌림목 진입 ─────────────────────────────────────
# 다양한 깊이 비교 (PULLBACK_DEPTH: 신호가 대비 몇 % 눌림 대기)
PULLBACK_CONFIGS = [
(0.5, 0.3, "눌림0.5%+회복0.3%"),
(1.0, 0.5, "눌림1.0%+회복0.5%"),
(1.5, 0.5, "눌림1.5%+회복0.5%"),
(2.0, 0.5, "눌림2.0%+회복0.5%"),
]
def simulate(df, strategy: str, pb_depth: float = 1.5, pb_recovery: float = 0.5) -> dict:
closes = df["close"].values
vols = df["volume"].values
trades = []
sig_px = sig_i = dip_px = None
pos_buy = pos_peak = pos_i = None
vol_window = 5 # 5h 평균 (1h 캔들 기준)
for i in range(vol_window + 2, len(closes) - max(TIME_STOP_H + 4, 10)):
# ── 포지션 관리 ────────────────────────────────────────
if pos_buy is not None:
cur = closes[i]
if cur > pos_peak:
pos_peak = cur
# 트레일링 스탑
if (pos_peak - cur) / pos_peak >= TRAIL_STOP:
pnl = (cur - pos_buy) / pos_buy * 100
trades.append({"pnl": pnl, "h": i - pos_i, "exit": "trail"})
pos_buy = pos_peak = pos_i = sig_px = sig_i = dip_px = None
continue
# 타임 스탑
if i - pos_i >= TIME_STOP_H:
pnl = (cur - pos_buy) / pos_buy * 100
if pnl < TIME_STOP_MIN:
trades.append({"pnl": pnl, "h": i - pos_i, "exit": "time"})
pos_buy = pos_peak = pos_i = sig_px = sig_i = dip_px = None
continue
continue
# ── 신호 유효성 체크 ──────────────────────────────────
if sig_px is not None:
# 시간 초과
if i - sig_i > SIGNAL_TO_H:
sig_px = sig_i = dip_px = None
# 신호가 대비 큰 하락 → 축적 실패
elif (closes[i] - sig_px) / sig_px * 100 < -SIGNAL_CANCEL:
sig_px = sig_i = dip_px = None
# ── 신호 없으면 축적 조건 탐색 ────────────────────────
if sig_px is None:
vol_avg = vols[i - vol_window - 1 : i - 1].mean()
if vol_avg <= 0:
continue
vol_ratio = vols[i - 1] / vol_avg
quiet = abs(closes[i] - closes[i - 2]) / closes[i - 2] * 100 < QUIET_2H
if vol_ratio >= VOL_MULT and quiet:
sig_px = closes[i]
sig_i = i
dip_px = None
continue
# ── 진입 로직 ─────────────────────────────────────────
cur = closes[i]
move = (cur - sig_px) / sig_px * 100
if strategy == "momentum":
if move >= MOMENTUM_THR:
pos_buy = pos_peak = cur
pos_i = i
sig_px = sig_i = None
else: # pullback
if dip_px is None:
if move <= -pb_depth:
dip_px = cur
else:
if cur < dip_px:
dip_px = cur # 저점 갱신
recovery = (cur - dip_px) / dip_px * 100
if recovery >= pb_recovery:
pos_buy = pos_peak = cur
pos_i = i
sig_px = sig_i = dip_px = None
if not trades:
return {"n": 0, "wins": 0, "wr": 0.0, "avg_pnl": 0.0, "total_pnl": 0.0, "avg_h": 0.0}
wins = [t for t in trades if t["pnl"] > 0]
return {
"n": len(trades),
"wins": len(wins),
"wr": len(wins) / len(trades) * 100,
"avg_pnl": sum(t["pnl"] for t in trades) / len(trades),
"total_pnl": sum(t["pnl"] for t in trades),
"avg_h": sum(t["h"] for t in trades) / len(trades),
}
def agg(results: list[dict]) -> dict:
"""종목별 결과 리스트를 합산."""
if not results:
return {"n": 0, "wins": 0, "wr": 0, "avg_pnl": 0, "total_pnl": 0, "avg_h": 0}
total_n = sum(r["n"] for r in results)
total_wins = sum(r["wins"] for r in results)
total_pnl = sum(r["total_pnl"] for r in results)
all_h = [r["avg_h"] for r in results if r["n"] > 0]
return {
"n": total_n,
"wins": total_wins,
"wr": total_wins / total_n * 100 if total_n else 0,
"avg_pnl": total_pnl / total_n if total_n else 0,
"total_pnl": total_pnl,
"avg_h": sum(all_h) / len(all_h) if all_h else 0,
}
def main() -> None:
print(f"데이터 수집 중 ({len(TICKERS)}개 종목, 60일 1h 캔들)...")
datasets: dict[str, object] = {}
for i, ticker in enumerate(TICKERS):
try:
df = pyupbit.get_ohlcv(ticker, interval="minute60", count=1440)
if df is not None and len(df) > 50:
datasets[ticker] = df
sys.stderr.write(f"\r {i+1}/{len(TICKERS)} {ticker} ")
time.sleep(0.08)
except Exception:
pass
sys.stderr.write("\n")
print(f" 수집 완료: {len(datasets)}개 종목\n")
# 전략별 결과 수집
strat_labels = ["모멘텀(현행)", "눌림0.5%", "눌림1.0%", "눌림1.5%", "눌림2.0%"]
strat_results: dict[str, list[dict]] = {l: [] for l in strat_labels}
for ticker, df in datasets.items():
r_mom = simulate(df, "momentum")
strat_results["모멘텀(현행)"].append(r_mom)
for depth, rec, label in PULLBACK_CONFIGS:
lbl = f"눌림{depth:.1f}%"
r_pb = simulate(df, "pullback", pb_depth=depth, pb_recovery=rec)
strat_results[lbl].append(r_pb)
# ─── 결과 출력 ─────────────────────────────────────────────
print("=" * 72)
print(" 전략 비교 (60일 / 25개 종목 합산)")
print("=" * 72)
print(f" {'전략':<16} {'거래':>5} {'승률':>7} {'평균PnL':>9} {'총PnL(%)':>10} {'평균보유':>8}")
print(" " + "-" * 62)
for label in strat_labels:
r = agg(strat_results[label])
if r["n"] == 0:
print(f" {label:<16} 데이터 없음")
continue
marker = " ◀ 현행" if label == "모멘텀(현행)" else ""
print(
f" {label:<16} {r['n']:>5}{r['wr']:>6.1f}% "
f"{r['avg_pnl']:>+8.3f}% {r['total_pnl']:>+9.2f}% "
f"{r['avg_h']:>6.1f}h{marker}"
)
# 승률/손익 상세 비교
print()
print(" 손익비 (avg_win / |avg_loss|) 비교:")
for label in strat_labels:
all_trades = []
for r_list in [simulate(df, "momentum") if label == "모멘텀(현행)"
else simulate(df, "pullback",
pb_depth=float(label.replace("눌림","").replace("%","")),
pb_recovery=0.5)
for df in datasets.values()]:
pass # need per-trade data
# 종목별 상세 (상위/하위)
print()
print(" 종목별 모멘텀 vs 눌림1.5% 비교:")
print(f" {'종목':<14} {'모멘텀 WR':>9} {'모멘텀 PnL':>11} {'눌림1.5% WR':>11} {'눌림1.5% PnL':>12}")
print(" " + "-" * 62)
ticker_list = list(datasets.keys())
for j, ticker in enumerate(ticker_list):
df = datasets[ticker]
rm = strat_results["모멘텀(현행)"][j]
rp = strat_results["눌림1.5%"][j]
if rm["n"] == 0 and rp["n"] == 0:
continue
rm_wr = f"{rm['wr']:.0f}%" if rm["n"] else "-"
rm_pnl = f"{rm['total_pnl']:+.2f}%" if rm["n"] else "-"
rp_wr = f"{rp['wr']:.0f}%" if rp["n"] else "-"
rp_pnl = f"{rp['total_pnl']:+.2f}%" if rp["n"] else "-"
diff = (rp["total_pnl"] - rm["total_pnl"]) if (rm["n"] and rp["n"]) else 0
mark = "" if diff > 0.5 else ("" if diff < -0.5 else "=")
print(f" {ticker:<14} {rm_wr:>9} {rm_pnl:>11} {rp_wr:>11} {rp_pnl:>12} {mark}")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,81 @@
"""10분봉 캐시 갱신 스크립트 — 최신 45일 데이터를 Upbit API로 재수집."""
import os, sys, pickle, time
from pathlib import Path
from datetime import datetime, timedelta
import pyupbit
from dotenv import load_dotenv
load_dotenv(dotenv_path=Path(__file__).parent.parent / ".env")
sys.path.insert(0, str(Path(__file__).parent.parent))
CACHE_FILE = Path(__file__).parent.parent / "data" / "sim10m_cache.pkl"
TOP30_FILE = Path(__file__).parent.parent / "data" / "top30_tickers.pkl"
SIM_DAYS = 45
TOP_N = 20
def fetch_10m(ticker: str, days: int) -> "pd.DataFrame | None":
import pandas as pd
target_start = datetime.now() - timedelta(days=days)
all_dfs, to, prev_oldest = [], None, None
while True:
kwargs = dict(ticker=ticker, interval="minute10", count=200)
if to:
kwargs["to"] = to.strftime("%Y-%m-%d %H:%M:%S")
try:
df = pyupbit.get_ohlcv(**kwargs)
except Exception:
time.sleep(0.5)
break
if df is None or df.empty:
break
all_dfs.append(df)
oldest = df.index[0]
if prev_oldest is not None and oldest >= prev_oldest:
break
prev_oldest = oldest
if oldest <= target_start:
break
to = oldest
time.sleep(0.12)
if not all_dfs:
return None
combined = pd.concat(all_dfs).sort_index()
combined = combined[~combined.index.duplicated(keep="last")]
return combined[combined.index >= target_start]
def main():
# 현재 Top20 종목 가져오기
from core.market import get_top_tickers
print("Top20 종목 조회...")
tickers = get_top_tickers()[:TOP_N]
print(f" {tickers}\n")
data = {"10m": {}}
for i, ticker in enumerate(tickers, 1):
print(f"\r {i:>2}/{len(tickers)} {ticker} ", end="", flush=True)
df = fetch_10m(ticker, SIM_DAYS)
if df is not None and len(df) > 100:
data["10m"][ticker] = df
time.sleep(0.15)
print(f"\n\n종목: {len(data['10m'])}")
if data["10m"]:
sample = next(iter(data["10m"].values()))
print(f"기간: {sample.index[0].strftime('%Y-%m-%d')} ~ {sample.index[-1].strftime('%Y-%m-%d')}")
print(f"레코드: {len(sample)}")
# 저장
pickle.dump(data, open(CACHE_FILE, "wb"))
print(f"\n캐시 저장: {CACHE_FILE}")
# top30 갱신
pickle.dump(tickers, open(TOP30_FILE, "wb"))
print(f"종목 저장: {TOP30_FILE}")
if __name__ == "__main__":
main()

159
archive/tests/shadow_sim.py Normal file
View File

@@ -0,0 +1,159 @@
"""Shadow Trading 재활 시뮬레이션.
WF차단 종목들에 대해 shadow 포지션을 시뮬레이션하여
몇 번의 shadow 승리 후 WF차단이 해제될 수 있었는지 분석.
"""
import os as _os, sys as _sys
_sys.path.insert(0, _os.path.dirname(_os.path.dirname(_os.path.abspath(__file__))))
import os
from datetime import datetime
from dotenv import load_dotenv
load_dotenv()
import oracledb
STOP_LOSS_PCT = float(os.getenv("STOP_LOSS_PCT", "1.5")) / 100
TIME_STOP_HOURS = int(os.getenv("TIME_STOP_HOURS", "8"))
TIME_STOP_MIN_PCT = float(os.getenv("TIME_STOP_MIN_GAIN_PCT", "3")) / 100
FEE = 0.0005
REHABILITATE_WINS = 2 # shadow N승 → WF 해제
def get_price_series(cur, ticker, from_dt):
"""WF차단 이후 가격 시계열 조회."""
cur.execute("""
SELECT price, recorded_at FROM price_history
WHERE ticker = :t AND recorded_at >= :dt
ORDER BY recorded_at
""", t=ticker, dt=from_dt)
return cur.fetchall()
def simulate_shadow(prices, buy_price, buy_dt):
"""
단일 shadow 포지션 시뮬레이션.
Returns: (is_win, sell_price, sell_dt, reason, pnl_pct)
"""
peak = buy_price
for price, ts in prices:
# 고점 갱신
if price > peak:
peak = price
elapsed_h = (ts - buy_dt).total_seconds() / 3600
pnl = (price - buy_price) / buy_price
# 트레일링 스탑 (최고가 대비 -STOP_LOSS_PCT)
drop_from_peak = (peak - price) / peak
if drop_from_peak >= STOP_LOSS_PCT:
sell_pnl = (price * (1 - FEE) - buy_price * (1 + FEE)) / (buy_price * (1 + FEE)) * 100
return (sell_pnl > 0, price, ts, f"트레일링스탑(peak={peak:.4f})", sell_pnl)
# 타임 스탑
if elapsed_h >= TIME_STOP_HOURS:
if pnl < TIME_STOP_MIN_PCT:
sell_pnl = (price * (1 - FEE) - buy_price * (1 + FEE)) / (buy_price * (1 + FEE)) * 100
return (sell_pnl > 0, price, ts, f"타임스탑({elapsed_h:.1f}h,{pnl*100:+.1f}%)", sell_pnl)
# 데이터 끝까지 보유 중
last_price, last_ts = prices[-1]
sell_pnl = (last_price * (1 - FEE) - buy_price * (1 + FEE)) / (buy_price * (1 + FEE)) * 100
return (sell_pnl > 0, last_price, last_ts, "데이터종료(보유중)", sell_pnl)
def run():
conn = oracledb.connect(
user=os.getenv('ORACLE_USER'),
password=os.getenv('ORACLE_PASSWORD'),
dsn=os.getenv('ORACLE_DSN'),
config_dir=os.getenv('ORACLE_WALLET')
)
cur = conn.cursor()
# WF차단 종목별 5번째 패배 시점 조회
blocked_tickers = {}
for ticker in ['KRW-DKA', 'KRW-SAHARA', 'KRW-LAYER', 'KRW-SIGN']:
cur.execute("""
SELECT traded_at, pnl_pct, sell_price FROM trade_results
WHERE ticker = :t ORDER BY traded_at
""", t=ticker)
rows = cur.fetchall()
if len(rows) >= 5:
wf_trigger_dt = rows[4][0] # 5번째 거래 완료 시점
last_sell_price = rows[4][2]
blocked_tickers[ticker] = {
'wf_trigger_dt': wf_trigger_dt,
'last_sell_price': last_sell_price,
'trades': rows
}
print("=" * 60)
print("Shadow Trading 재활 시뮬레이션")
print("=" * 60)
for ticker, info in blocked_tickers.items():
wf_dt = info['wf_trigger_dt']
print(f"\n{''*60}")
print(f"[{ticker}] WF차단 발동: {wf_dt.strftime('%m-%d %H:%M')}")
print(f" 직전 5건: {[f'{r[1]:+.2f}%' for r in info['trades']]}")
# WF차단 이후 가격 시계열
prices = get_price_series(cur, ticker, wf_dt)
if not prices:
print(" → 이후 가격 데이터 없음")
continue
print(f" 이후 가격 데이터: {len(prices)}개 ({prices[0][1].strftime('%m-%d %H:%M')} ~ {prices[-1][1].strftime('%m-%d %H:%M')})")
print()
# Shadow 포지션 시뮬레이션
shadow_wins = 0
pos_idx = 0
cursor_idx = 0
rehabilitated = False
while cursor_idx < len(prices) and not rehabilitated:
buy_price, buy_dt = prices[cursor_idx]
remaining = prices[cursor_idx + 1:]
if not remaining:
break
pos_idx += 1
is_win, sell_price, sell_dt, reason, pnl = simulate_shadow(remaining, buy_price, buy_dt)
win_mark = "✅ WIN" if is_win else "❌ LOSS"
if is_win:
shadow_wins += 1
else:
shadow_wins = 0 # 패배 시 카운터 리셋
print(f" Shadow #{pos_idx}: 진입={buy_price:.4f}원 ({buy_dt.strftime('%m-%d %H:%M')})")
print(f" 청산={sell_price:.4f}원 ({sell_dt.strftime('%m-%d %H:%M')}) | {reason}")
print(f" {win_mark} {pnl:+.2f}% | 연속승={shadow_wins}/{REHABILITATE_WINS}")
if shadow_wins >= REHABILITATE_WINS:
print(f"\n 🎉 {REHABILITATE_WINS}연승 달성 → WF 차단 해제! ({sell_dt.strftime('%m-%d %H:%M')})")
rehabilitated = True
break
# 다음 진입: 청산 시점 이후 첫 번째 가격
next_idx = next(
(i for i, (_, ts) in enumerate(prices) if ts > sell_dt),
None
)
if next_idx is None:
break
cursor_idx = next_idx
if not rehabilitated:
print(f"\n ⛔ 데이터 범위 내 재활 실패 (shadow_wins={shadow_wins})")
conn.close()
if __name__ == "__main__":
run()

View File

@@ -0,0 +1,158 @@
"""Shadow 재활 이후 실제 진입 성과 시뮬레이션."""
import os as _os, sys as _sys
_sys.path.insert(0, _os.path.dirname(_os.path.dirname(_os.path.abspath(__file__))))
import os
from datetime import datetime
from dotenv import load_dotenv
load_dotenv()
import oracledb
STOP_LOSS_PCT = float(os.getenv("STOP_LOSS_PCT", "1.5")) / 100
TIME_STOP_HOURS = int(os.getenv("TIME_STOP_HOURS", "8"))
TIME_STOP_MIN_PCT = float(os.getenv("TIME_STOP_MIN_GAIN_PCT", "3")) / 100
FEE = 0.0005
REHABILITATE_WINS = 2
def get_price_series(cur, ticker, from_dt):
cur.execute("""
SELECT price, recorded_at FROM price_history
WHERE ticker = :t AND recorded_at >= :dt
ORDER BY recorded_at
""", t=ticker, dt=from_dt)
return cur.fetchall()
def simulate_one(prices, buy_price, buy_dt):
"""단일 포지션 시뮬레이션. Returns (is_win, sell_price, sell_dt, reason, pnl_pct)"""
peak = buy_price
for price, ts in prices:
if price > peak:
peak = price
elapsed_h = (ts - buy_dt).total_seconds() / 3600
pnl = (price - buy_price) / buy_price
drop_from_peak = (peak - price) / peak
if drop_from_peak >= STOP_LOSS_PCT:
sell_pnl = (price*(1-FEE) - buy_price*(1+FEE)) / (buy_price*(1+FEE)) * 100
return (sell_pnl > 0, price, ts, f"트레일링({pnl*100:+.1f}%)", sell_pnl)
if elapsed_h >= TIME_STOP_HOURS and pnl < TIME_STOP_MIN_PCT:
sell_pnl = (price*(1-FEE) - buy_price*(1+FEE)) / (buy_price*(1+FEE)) * 100
return (sell_pnl > 0, price, ts, f"타임스탑({elapsed_h:.1f}h)", sell_pnl)
last_price, last_ts = prices[-1]
sell_pnl = (last_price*(1-FEE) - buy_price*(1+FEE)) / (buy_price*(1+FEE)) * 100
return (sell_pnl > 0, last_price, last_ts, "데이터종료(보유중)", sell_pnl)
def run_shadow_then_real(cur, ticker, wf_trigger_dt):
"""shadow로 재활 후, 재활 시점 이후 실제 거래 성과 시뮬레이션."""
prices = get_price_series(cur, ticker, wf_trigger_dt)
if not prices:
return None
# 1단계: shadow로 재활 시점 찾기
shadow_wins = 0
cursor_idx = 0
rehab_dt = None
while cursor_idx < len(prices):
buy_price, buy_dt = prices[cursor_idx]
remaining = prices[cursor_idx + 1:]
if not remaining:
break
is_win, sell_price, sell_dt, reason, pnl = simulate_one(remaining, buy_price, buy_dt)
if is_win:
shadow_wins += 1
else:
shadow_wins = 0
if shadow_wins >= REHABILITATE_WINS:
rehab_dt = sell_dt
break
next_idx = next((i for i, (_, ts) in enumerate(prices) if ts > sell_dt), None)
if next_idx is None:
break
cursor_idx = next_idx
if rehab_dt is None:
return None # 재활 실패
# 2단계: 재활 이후 실제 거래 시뮬레이션
print(f"\n ★ WF 해제 시점: {rehab_dt.strftime('%m-%d %H:%M')}")
print(f" ─ 이후 실제 진입 시뮬레이션 ─")
post_prices = get_price_series(cur, ticker, rehab_dt)
if not post_prices:
print(" → 재활 이후 가격 데이터 없음")
return
cursor_idx = 0
trade_no = 0
wins = 0
total_pnl = 0.0
while cursor_idx < len(post_prices):
buy_price, buy_dt = post_prices[cursor_idx]
remaining = post_prices[cursor_idx + 1:]
if not remaining:
break
is_win, sell_price, sell_dt, reason, pnl = simulate_one(remaining, buy_price, buy_dt)
trade_no += 1
if is_win:
wins += 1
total_pnl += pnl
mark = "" if is_win else ""
print(f" 실제#{trade_no}: {buy_price:.4f}{sell_price:.4f}원 | {mark} {pnl:+.2f}% | {reason} ({sell_dt.strftime('%m-%d %H:%M')})")
next_idx = next((i for i, (_, ts) in enumerate(post_prices) if ts > sell_dt), None)
if next_idx is None:
break
cursor_idx = next_idx
if trade_no > 0:
wr = wins / trade_no * 100
print(f"\n 📊 재활 후 성과: {trade_no}건 중 {wins}승 | 승률={wr:.0f}% | 누적PnL={total_pnl:+.2f}%")
return {'trades': trade_no, 'wins': wins, 'win_rate': wr, 'total_pnl': total_pnl}
return None
def run():
conn = oracledb.connect(
user=os.getenv('ORACLE_USER'),
password=os.getenv('ORACLE_PASSWORD'),
dsn=os.getenv('ORACLE_DSN'),
config_dir=os.getenv('ORACLE_WALLET')
)
cur = conn.cursor()
results = {}
for ticker in ['KRW-DKA', 'KRW-LAYER', 'KRW-SIGN']:
cur.execute("""
SELECT traded_at FROM trade_results
WHERE ticker = :t ORDER BY traded_at
""", t=ticker)
rows = cur.fetchall()
wf_trigger_dt = rows[4][0]
print(f"\n{'='*60}")
print(f"[{ticker}] WF차단 발동: {wf_trigger_dt.strftime('%m-%d %H:%M')}")
r = run_shadow_then_real(cur, ticker, wf_trigger_dt)
if r:
results[ticker] = r
print(f"\n{'='*60}")
print("전체 요약")
print(f"{'='*60}")
for ticker, r in results.items():
print(f"{ticker}: 재활 후 {r['trades']}건 | 승률={r['win_rate']:.0f}% | 누적={r['total_pnl']:+.2f}%")
conn.close()
if __name__ == "__main__":
run()

View File

@@ -0,0 +1,323 @@
"""Shadow 재활 시뮬레이션 v3 - 실제 전략 필터 포함.
전략 조건:
1. 추세: 현재가 vs 2h 전 가격 >= TREND_MIN_GAIN_PCT%
2. 모멘텀: 현재가 > MA20(일봉) AND 최근 1h 거래량 > 로컬 5h 평균 * VOL_MULT
3. 15분 워치리스트: 첫 신호 후 15분 재확인
"""
import os as _os, sys as _sys
_sys.path.insert(0, _os.path.dirname(_os.path.dirname(_os.path.abspath(__file__))))
import os, time
from datetime import datetime, timedelta
from collections import defaultdict
from dotenv import load_dotenv
load_dotenv()
import oracledb
import pyupbit
# ── 파라미터 ──────────────────────────────────────────
STOP_LOSS_PCT = float(os.getenv("STOP_LOSS_PCT", "1.5")) / 100
TIME_STOP_HOURS = int(os.getenv("TIME_STOP_HOURS", "8"))
TIME_STOP_MIN_PCT = float(os.getenv("TIME_STOP_MIN_GAIN_PCT", "3")) / 100
TREND_HOURS = 2
TREND_MIN_PCT = float(os.getenv("TREND_MIN_GAIN_PCT", "5")) # Neutral 기준
MA_PERIOD = 20
LOCAL_VOL_HOURS = 5
VOL_MULT = float(os.getenv("VOLUME_MULTIPLIER", "2.0"))
CONFIRM_MINUTES = 15
FEE = 0.0005
REHABILITATE_WINS = 2
# ── DB 연결 ───────────────────────────────────────────
def get_conn():
return oracledb.connect(
user=os.getenv('ORACLE_USER'),
password=os.getenv('ORACLE_PASSWORD'),
dsn=os.getenv('ORACLE_DSN'),
config_dir=os.getenv('ORACLE_WALLET')
)
def load_price_history(cur, ticker, from_dt, to_dt):
"""price_history 전체 로드."""
cur.execute("""
SELECT price, recorded_at FROM price_history
WHERE ticker = :t AND recorded_at BETWEEN :f AND :e
ORDER BY recorded_at
""", t=ticker, f=from_dt, e=to_dt)
return cur.fetchall() # [(price, dt), ...]
# ── pyupbit 과거 데이터 캐시 ───────────────────────────
_daily_cache = {} # (ticker, date_str) → df
_hourly_cache = {} # (ticker, hour_str) → df
def get_ma20(ticker, as_of_dt):
"""as_of_dt 기준 MA20 (일봉 종가)."""
date_str = as_of_dt.strftime("%Y-%m-%d")
key = (ticker, date_str)
if key not in _daily_cache:
try:
df = pyupbit.get_ohlcv(ticker, interval="day", count=MA_PERIOD + 2,
to=as_of_dt.strftime("%Y-%m-%d 09:00:00"))
_daily_cache[key] = df
time.sleep(0.1)
except Exception as e:
_daily_cache[key] = None
df = _daily_cache[key]
if df is None or len(df) < MA_PERIOD:
return None
return df["close"].iloc[-MA_PERIOD:].mean()
def get_volume_ratio(ticker, as_of_dt):
"""최근 1h 거래량 / 로컬 5h 평균. (직전 완성봉 기준)"""
# 시간봉은 해당 시각의 이전 7h 데이터 필요
hour_str = as_of_dt.strftime("%Y-%m-%d %H:00:00")
key = (ticker, hour_str)
if key not in _hourly_cache:
try:
df = pyupbit.get_ohlcv(ticker, interval="minute60", count=LOCAL_VOL_HOURS + 3,
to=as_of_dt.strftime("%Y-%m-%d %H:%M:%S"))
_hourly_cache[key] = df
time.sleep(0.1)
except Exception as e:
_hourly_cache[key] = None
df = _hourly_cache[key]
if df is None or len(df) < LOCAL_VOL_HOURS + 1:
return 0.0
recent_vol = df["volume"].iloc[-2]
local_avg = df["volume"].iloc[-(LOCAL_VOL_HOURS + 1):-2].mean()
if local_avg <= 0:
return 0.0
return recent_vol / local_avg
# ── 전략 조건 체크 ────────────────────────────────────
def check_trend(prices, idx):
"""현재 idx 기준 2h 전(12틱 전) 대비 +TREND_MIN_PCT% 이상."""
lookback = TREND_HOURS * 6 # 10분봉 기준 2h = 12틱
if idx < lookback:
return False
current = prices[idx][0]
past = prices[idx - lookback][0]
if past <= 0:
return False
gain = (current - past) / past * 100
return gain >= TREND_MIN_PCT
def check_momentum(ticker, current_price, as_of_dt):
"""현재가 > MA20 AND 거래량 비율 >= VOL_MULT."""
ma20 = get_ma20(ticker, as_of_dt)
if ma20 is None:
return False
if current_price <= ma20:
return False
vol_ratio = get_volume_ratio(ticker, as_of_dt)
return vol_ratio >= VOL_MULT
# ── 단일 포지션 시뮬레이션 ────────────────────────────
def simulate_position(prices, buy_idx, buy_price):
"""buy_idx 이후 가격으로 포지션 시뮬레이션."""
buy_dt = prices[buy_idx][1]
peak = buy_price
for price, ts in prices[buy_idx + 1:]:
if price > peak:
peak = price
elapsed_h = (ts - buy_dt).total_seconds() / 3600
pnl = (price - buy_price) / buy_price
drop_from_peak = (peak - price) / peak
if drop_from_peak >= STOP_LOSS_PCT:
net = (price*(1-FEE) - buy_price*(1+FEE)) / (buy_price*(1+FEE)) * 100
return (net > 0, price, ts, f"트레일링({pnl*100:+.1f}%)", net)
if elapsed_h >= TIME_STOP_HOURS and pnl < TIME_STOP_MIN_PCT:
net = (price*(1-FEE) - buy_price*(1+FEE)) / (buy_price*(1+FEE)) * 100
return (net > 0, price, ts, f"타임스탑({elapsed_h:.1f}h)", net)
last_price, last_ts = prices[-1]
net = (last_price*(1-FEE) - buy_price*(1+FEE)) / (buy_price*(1+FEE)) * 100
return (net > 0, last_price, last_ts, "데이터종료", net)
# ── Shadow → 재활 → 실제 진입 시뮬레이션 ─────────────
def run_full_sim(cur, ticker, wf_trigger_dt, end_dt):
"""
1. WF차단 시점부터 shadow 포지션 (전략 필터 적용)
2. REHABILITATE_WINS 연승 → WF 해제
3. 해제 이후 실제 진입 성과
"""
prices = load_price_history(cur, ticker, wf_trigger_dt, end_dt)
if not prices:
print(f" 가격 데이터 없음")
return
print(f" 가격 데이터: {len(prices)}개 ({prices[0][1].strftime('%m-%d %H:%M')} ~ {prices[-1][1].strftime('%m-%d %H:%M')})")
# ── Phase 1: Shadow (WF 해제까지) ────────────────
shadow_wins = 0
rehab_dt = None
watchlist_dt = None # 신호 첫 발생 시각
in_position = False
pos_buy_idx = None
pos_buy_price = None
shadow_trade_no = 0
idx = 0
while idx < len(prices) and rehab_dt is None:
current_price, current_dt = prices[idx]
if in_position:
# 포지션 청산 체크
is_win, sell_price, sell_dt, reason, pnl = simulate_position(prices, pos_buy_idx, pos_buy_price)
# 청산 시각에 해당하는 idx로 점프
sell_idx = next((i for i, (_, ts) in enumerate(prices) if ts >= sell_dt), len(prices)-1)
shadow_trade_no += 1
if is_win:
shadow_wins += 1
else:
shadow_wins = 0
mark = "" if is_win else ""
print(f" [Shadow#{shadow_trade_no}] {pos_buy_price:.4f}{sell_price:.4f}"
f"| {mark} {pnl:+.2f}% | {reason} | 연속승={shadow_wins}/{REHABILITATE_WINS}"
f" ({sell_dt.strftime('%m-%d %H:%M')})")
if shadow_wins >= REHABILITATE_WINS:
rehab_dt = sell_dt
idx = sell_idx
break
in_position = False
watchlist_dt = None
idx = sell_idx
continue
# 전략 조건 체크
trend_ok = check_trend(prices, idx)
if trend_ok:
mom_ok = check_momentum(ticker, current_price, current_dt)
else:
mom_ok = False
if trend_ok and mom_ok:
if watchlist_dt is None:
watchlist_dt = current_dt # 첫 신호
elif (current_dt - watchlist_dt).total_seconds() >= CONFIRM_MINUTES * 60:
# 15분 재확인 → shadow 진입
in_position = True
pos_buy_idx = idx
pos_buy_price = current_price
watchlist_dt = None
# 청산은 다음 루프에서
else:
watchlist_dt = None # 조건 깨지면 초기화
idx += 1
if rehab_dt is None:
print(f"\n ⛔ 전략 필터 적용 시 데이터 범위 내 재활 실패 (shadow_wins={shadow_wins})")
return
print(f"\n 🎉 WF 해제: {rehab_dt.strftime('%m-%d %H:%M')} ({shadow_trade_no}번 shadow 거래)")
# ── Phase 2: 실제 진입 (재활 이후) ───────────────
print(f"\n ── 재활 후 실제 진입 ──")
post_prices = [(p, dt) for p, dt in prices if dt >= rehab_dt]
if not post_prices:
print(" 재활 이후 데이터 없음")
return
real_wins = 0
real_total = 0
real_pnl_sum = 0.0
watchlist_dt = None
in_position = False
pos_buy_idx_g = None # global idx in post_prices
pos_buy_price = None
idx2 = 0
while idx2 < len(post_prices):
current_price, current_dt = post_prices[idx2]
if in_position:
is_win, sell_price, sell_dt, reason, pnl = simulate_position(post_prices, pos_buy_idx_g, pos_buy_price)
sell_idx2 = next((i for i, (_, ts) in enumerate(post_prices) if ts >= sell_dt), len(post_prices)-1)
real_total += 1
if is_win:
real_wins += 1
real_pnl_sum += pnl
mark = "" if is_win else ""
print(f" 실제#{real_total}: {pos_buy_price:.4f}{sell_price:.4f}"
f"| {mark} {pnl:+.2f}% | {reason} ({sell_dt.strftime('%m-%d %H:%M')})")
in_position = False
watchlist_dt = None
idx2 = sell_idx2
continue
trend_ok = check_trend(post_prices, idx2)
if trend_ok:
mom_ok = check_momentum(ticker, current_price, current_dt)
else:
mom_ok = False
if trend_ok and mom_ok:
if watchlist_dt is None:
watchlist_dt = current_dt
elif (current_dt - watchlist_dt).total_seconds() >= CONFIRM_MINUTES * 60:
in_position = True
pos_buy_idx_g = idx2
pos_buy_price = current_price
watchlist_dt = None
else:
watchlist_dt = None
idx2 += 1
if real_total == 0:
print(" 재활 후 전략 조건 충족 진입 없음")
else:
wr = real_wins / real_total * 100
print(f"\n 📊 재활 후: {real_total}건 | 승률={wr:.0f}% | 누적={real_pnl_sum:+.2f}%")
return {'trades': real_total, 'wins': real_wins, 'wr': wr, 'pnl': real_pnl_sum}
def main():
conn = get_conn()
cur = conn.cursor()
# price_history 최대 시각
cur.execute("SELECT MAX(recorded_at) FROM price_history")
end_dt = cur.fetchone()[0]
summary = {}
for ticker in ['KRW-DKA', 'KRW-LAYER', 'KRW-SIGN']:
cur.execute("""
SELECT traded_at FROM trade_results
WHERE ticker = :t ORDER BY traded_at
""", t=ticker)
rows = cur.fetchall()
wf_dt = rows[4][0]
print(f"\n{'='*62}")
print(f"[{ticker}] WF차단: {wf_dt.strftime('%m-%d %H:%M')}")
print(f"{'='*62}")
r = run_full_sim(cur, ticker, wf_dt, end_dt)
if r:
summary[ticker] = r
print(f"\n{'='*62}")
print("전체 요약 (전략 필터 적용)")
print(f"{'='*62}")
for ticker, r in summary.items():
print(f"{ticker}: {r['trades']}건 | 승률={r['wr']:.0f}% | 누적={r['pnl']:+.2f}%")
conn.close()
if __name__ == "__main__":
main()

308
archive/tests/sim10m.py Normal file
View File

@@ -0,0 +1,308 @@
"""sim10m.py — 10분봉 + 극단적 거래량 즉시 진입 전략 시뮬.
기존 전략 (1h봉 vol-lead) vs 신규 전략 (10분봉 + 100x 이상 거래량 즉시 진입) 비교.
데이터: 최근 45일 Upbit API
"""
import pickle
import time
import sys
from pathlib import Path
from datetime import datetime, timedelta
import pandas as pd
import pyupbit
from dotenv import load_dotenv
load_dotenv(dotenv_path=Path(__file__).parent / ".env")
sys.path.insert(0, str(Path(__file__).parent))
# ── 파라미터 ──────────────────────────────────────────────
SIM_DAYS = 45
TOP30_FILE = Path("top30_tickers.pkl")
CACHE_FILE = Path("sim10m_cache.pkl")
TOP_N = 20
FEE = 0.0005
TIME_STOP_MIN_PCT = 3.0
ATR_MULT = 1.5
ATR_MIN = 0.010
ATR_MAX = 0.020
# A: 기존 1h봉
A_LOCAL_VOL = 5 # 봉수 (= 5h)
A_VOL_MULT = 2.0
A_QUIET_PCT = 2.0
A_THRESH = 4.8
A_SIGNAL_TO = 8 # 신호 유효 봉수 (= 8h)
A_ATR_CANDLES = 5
A_TIME_STOP = 8 # 타임스탑 봉수 (= 8h)
# B: 신규 10분봉
B_LOCAL_VOL = 30 # 봉수 (5h = 30 × 10min)
B_VOL_MULT = 2.0
B_EXTREME_VOL = 100 # 이 이상 → 횡보 조건 면제, 다음 봉 즉시 진입
B_QUIET_CANDLES = 12 # 2h = 12봉
B_QUIET_PCT = 2.0
B_THRESH = 4.8
B_SIGNAL_TO = 48 # 신호 유효 봉수 (8h = 48봉)
B_ATR_CANDLES = 30 # ATR 봉수 (5h = 30봉)
B_TIME_STOP = 48 # 타임스탑 봉수 (8h = 48봉)
# ── 데이터 로드 ──────────────────────────────────────────
def fetch_ohlcv(ticker, interval, days):
"""OHLCV 과거 데이터 페이징 로드."""
target_start = datetime.now() - timedelta(days=days)
all_dfs, to, prev_oldest = [], None, None
while True:
kwargs = dict(ticker=ticker, interval=interval, count=200)
if to:
kwargs["to"] = to
df = pyupbit.get_ohlcv(**kwargs)
if df is None or df.empty:
break
df.index = df.index.tz_localize(None)
oldest = df.index[0]
# 더 이상 과거로 못 가면 중단 (상장일에 도달)
if prev_oldest is not None and oldest >= prev_oldest:
all_dfs.append(df)
break
all_dfs.append(df)
prev_oldest = oldest
if oldest <= target_start:
break
to = oldest.strftime("%Y-%m-%d %H:%M:%S")
time.sleep(0.15)
if not all_dfs:
return None
result = pd.concat(all_dfs).sort_index().drop_duplicates()
return result[result.index >= target_start]
def load_data(tickers):
if CACHE_FILE.exists():
print(f"캐시 로드: {CACHE_FILE}")
return pickle.load(open(CACHE_FILE, "rb"))
data = {"1h": {}, "10m": {}}
for idx, ticker in enumerate(tickers, 1):
print(f" [{idx}/{len(tickers)}] {ticker}...", end=" ", flush=True)
df1h = fetch_ohlcv(ticker, "minute60", SIM_DAYS)
df10m = fetch_ohlcv(ticker, "minute10", SIM_DAYS)
n1h = len(df1h) if df1h is not None else 0
n10m = len(df10m) if df10m is not None else 0
print(f"1h={n1h}봉 10m={n10m}")
if df1h is not None and n1h >= 50: data["1h"][ticker] = df1h
if df10m is not None and n10m >= 200: data["10m"][ticker] = df10m
pickle.dump(data, open(CACHE_FILE, "wb"))
print(f"캐시 저장: {CACHE_FILE}\n")
return data
# ── ATR 계산 (시뮬용) ─────────────────────────────────────
def calc_atr(df, buy_idx, n):
sub = df.iloc[max(0, buy_idx - n - 1):buy_idx]
if len(sub) < 3:
return ATR_MIN
try:
avg = ((sub["high"] - sub["low"]) / sub["low"]).iloc[-n:].mean()
return float(max(ATR_MIN, min(ATR_MAX, avg * ATR_MULT)))
except Exception:
return ATR_MIN
# ── 포지션 시뮬 ──────────────────────────────────────────
def simulate_pos(df, buy_idx, buy_price, stop_pct, time_stop_candles):
peak = buy_price
for i in range(buy_idx + 1, len(df)):
row = df.iloc[i]
ts = df.index[i]
if row["high"] > peak:
peak = row["high"]
if row["low"] <= peak * (1 - stop_pct):
sp = peak * (1 - stop_pct)
pnl = (sp * (1-FEE) - buy_price * (1+FEE)) / (buy_price * (1+FEE)) * 100
return pnl > 0, sp, ts, f"트레일링({pnl:+.1f}%)", pnl
pnl_now = (row["close"] - buy_price) / buy_price * 100
if (i - buy_idx) >= time_stop_candles and pnl_now < TIME_STOP_MIN_PCT:
pnl = (row["close"] * (1-FEE) - buy_price * (1+FEE)) / (buy_price * (1+FEE)) * 100
return pnl > 0, row["close"], ts, "타임스탑", pnl
last = df.iloc[-1]["close"]
pnl = (last * (1-FEE) - buy_price * (1+FEE)) / (buy_price * (1+FEE)) * 100
return pnl > 0, last, df.index[-1], "데이터종료", pnl
# ── 전략 A: 기존 1h봉 vol-lead ──────────────────────────
def run_a(df):
trades = []
sig_i = sig_p = None
in_pos = False
buy_idx = buy_price = stop_pct = None
i = max(A_LOCAL_VOL + 2, 3)
while i < len(df):
if in_pos:
is_win, sp, sdt, reason, pnl = simulate_pos(df, buy_idx, buy_price, stop_pct, A_TIME_STOP)
next_i = next((j for j in range(i, len(df)) if df.index[j] > sdt), len(df))
trades.append((is_win, pnl, df.index[buy_idx], sdt, "일반"))
in_pos = False
sig_i = sig_p = None
i = next_i
continue
close = df.iloc[i]["close"]
chg_2h = abs(close - df.iloc[i-2]["close"]) / df.iloc[i-2]["close"] * 100
quiet = chg_2h < A_QUIET_PCT
vol_p = df.iloc[i-1]["volume"]
vol_avg = df.iloc[i-A_LOCAL_VOL-1:i-1]["volume"].mean()
spike = vol_avg > 0 and vol_p >= vol_avg * A_VOL_MULT
if quiet and spike:
if sig_i is None: sig_i, sig_p = i, close
else:
if sig_i is not None and close < sig_p: sig_i = sig_p = None
if sig_i is not None and (i - sig_i) > A_SIGNAL_TO:
sig_i = sig_p = None
if sig_i is not None and (close - sig_p) / sig_p * 100 >= A_THRESH:
in_pos = True; buy_idx = i; buy_price = close
stop_pct = calc_atr(df, i, A_ATR_CANDLES)
sig_i = sig_p = None
i += 1
return trades
# ── 전략 B: 10분봉 + 극단적 거래량 즉시 진입 ────────────
def run_b(df):
# trade tuple: (is_win, pnl, buy_dt, sell_dt, entry_type)
# entry_type: '일반' | '극단'
trades = []
sig_i = sig_p = None
extreme_pending = False
in_pos = False
buy_idx = buy_price = stop_pct = None
i = max(B_LOCAL_VOL + 2, B_QUIET_CANDLES + 1)
while i < len(df):
if in_pos:
is_win, sp, sdt, reason, pnl = simulate_pos(df, buy_idx, buy_price, stop_pct, B_TIME_STOP)
next_i = next((j for j in range(i, len(df)) if df.index[j] > sdt), len(df))
trades.append((is_win, pnl, df.index[buy_idx], sdt, entry_type))
in_pos = False
sig_i = sig_p = None
extreme_pending = False
i = next_i
continue
close = df.iloc[i]["close"]
vol_p = df.iloc[i-1]["volume"]
vol_avg = df.iloc[i-B_LOCAL_VOL-1:i-1]["volume"].mean()
vol_r = vol_p / vol_avg if vol_avg > 0 else 0
# 극단적 거래량 → 다음 봉 진입 대기 설정
if not extreme_pending and vol_r >= B_EXTREME_VOL:
extreme_pending = True
i += 1
continue
# 극단적 거래량 다음 봉 → 즉시 진입
if extreme_pending:
in_pos = True; buy_idx = i; buy_price = close
stop_pct = calc_atr(df, i, B_ATR_CANDLES)
entry_type = "극단"
extreme_pending = False
sig_i = sig_p = None
i += 1
continue
# 일반 vol-lead
close_2h = df.iloc[i - B_QUIET_CANDLES]["close"]
chg_2h = abs(close - close_2h) / close_2h * 100
quiet = chg_2h < B_QUIET_PCT
spike = vol_r >= B_VOL_MULT
if quiet and spike:
if sig_i is None: sig_i, sig_p = i, close
else:
if sig_i is not None and close < sig_p: sig_i = sig_p = None
if sig_i is not None and (i - sig_i) > B_SIGNAL_TO:
sig_i = sig_p = None
if sig_i is not None and (close - sig_p) / sig_p * 100 >= B_THRESH:
in_pos = True; buy_idx = i; buy_price = close
stop_pct = calc_atr(df, i, B_ATR_CANDLES)
entry_type = "일반"
sig_i = sig_p = None
i += 1
return trades
# ── 통계 계산 ─────────────────────────────────────────────
def calc_stats(trades):
if not trades:
return {"n": 0, "wr": 0.0, "cum": 0.0, "dd": 0.0}
wins = sum(1 for t in trades if t[0])
cum = peak = dd = 0.0
for t in sorted(trades, key=lambda x: x[2]):
cum += t[1]
peak = max(peak, cum)
dd = max(dd, peak - cum)
return {"n": len(trades), "wr": wins / len(trades) * 100, "cum": cum, "dd": dd}
# ── 메인 ─────────────────────────────────────────────────
def main():
top30 = pickle.load(open(TOP30_FILE, "rb"))
tickers = top30[:TOP_N]
print(f"{'='*60}")
print(f"10분봉 전략 시뮬 | 최근 {SIM_DAYS}일 | {TOP_N}종목")
print(f" A: 기존 1h봉 vol-lead")
print(f" B: 10분봉 vol-lead + 극단거래량({B_EXTREME_VOL}x) 즉시 진입")
print(f"{'='*60}\n")
print(f"데이터 로드 중...")
data = load_data(tickers)
v1h = [t for t in tickers if t in data["1h"] and len(data["1h"][t]) >= 50]
v10m = [t for t in tickers if t in data["10m"] and len(data["10m"][t]) >= 200]
print(f"유효: 1h={len(v1h)}종목 / 10m={len(v10m)}종목\n")
# 전략 A
a_all = []
for t in v1h:
a_all.extend(run_a(data["1h"][t]))
# 전략 B
b_all = []
for t in v10m:
b_all.extend(run_b(data["10m"][t]))
b_extreme = [t for t in b_all if t[4] == "극단"]
b_normal = [t for t in b_all if t[4] == "일반"]
sa = calc_stats(a_all)
sb = calc_stats(b_all)
sbe = calc_stats(b_extreme)
sbn = calc_stats(b_normal)
print(f"{'='*65}")
print(f"{'전략':18} {'거래수':>6} {'승률':>6} {'누적PnL%':>10} {'최대낙폭%':>10}")
print(f"{''*65}")
print(f"{'A (1h vol-lead)':18} {sa['n']:>6}{sa['wr']:>5.1f}% {sa['cum']:>+9.2f}% {-sa['dd']:>+9.2f}%")
print(f"{'B 전체 (10m)':18} {sb['n']:>6}{sb['wr']:>5.1f}% {sb['cum']:>+9.2f}% {-sb['dd']:>+9.2f}%")
print(f"{' └ 극단거래량':18} {sbe['n']:>6}{sbe['wr']:>5.1f}% {sbe['cum']:>+9.2f}% {-sbe['dd']:>+9.2f}%")
print(f"{' └ 일반vol-lead':18} {sbn['n']:>6}{sbn['wr']:>5.1f}% {sbn['cum']:>+9.2f}% {-sbn['dd']:>+9.2f}%")
print(f"{'='*65}")
# 극단 거래량 진입 상세
if b_extreme:
print(f"\n── 극단거래량 진입 상세 ({len(b_extreme)}건) ─────────────────────")
print(f" {'매수시각':<18} {'PnL%':>7} {'청산'}")
for t in sorted(b_extreme, key=lambda x: x[2])[:20]:
mark = "" if t[0] else ""
print(f" {str(t[2])[:16]:<18} {t[1]:>+6.1f}% {mark} {t[4]}")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,382 @@
"""10분봉 vol 감지 vs 40분봉 vol 감지 비교 시뮬레이션.
40분봉 집계 시 10분봉 spike가 희석되는 문제를 해결하기 위해
신호 감지를 10분봉 기준으로 실행하고 40분봉 전략과 노이즈/수익 비교.
비교 모드 (각 필터 조합 × 2개 봉 단위):
10분봉 detection:
A. 10m 필터없음
B. 10m F&G≥41 + BEAR차단N5
C. 10m vol≥5x 오버라이드 (F&G+레짐 무시)
40분봉 detection (기준선):
D. 40m 필터없음
E. 40m F&G≥41 + BEAR차단N5
F. 40m vol≥5x 오버라이드
데이터: data/sim1y_cache.pkl (10분봉 1년치)
"""
import os as _os, sys as _sys
_sys.path.insert(0, _os.path.dirname(_os.path.dirname(_os.path.abspath(__file__))))
import json
import pickle
from pathlib import Path
import pandas as pd
from dotenv import load_dotenv
load_dotenv(dotenv_path=Path(__file__).parent.parent / ".env")
CACHE_FILE = Path(__file__).parent.parent / "data" / "sim1y_cache.pkl"
FNG_FILE = Path(__file__).parent.parent / "data" / "fng_1y.json"
TOP_N = 20
BUDGET = 15_000_000
MIN_BUDGET = BUDGET * 3 // 10
MAX_POS = 3
FEE = 0.0005
ATR_MULT = 1.5
ATR_MIN = 0.010
ATR_MAX = 0.020
THRESH = 4.8
QUIET_PCT = 2.0
BEAR_THRESHOLD = -0.5
BULL_THRESHOLD = 1.5
FNG_MIN_ENTRY = 41
# ── 40분봉 파라미터 ────────────────────────────────
P40 = dict(
local_vol_n = 7,
quiet_n = 3,
signal_to_n = 12,
atr_n = 7,
ts_n = 12,
time_stop_pct = 3.0,
vol_mult = 2.0,
)
# ── 10분봉 파라미터 (벽시계 기준 동등) ───────────────
# LOCAL_VOL_N: 40m×7=280min → 10min×28
# QUIET_N: 40m×3=120min → 10min×12
# SIGNAL_TO_N: 40m×12=480min → 10min×48
# ATR_N: 40m×7=280min → 10min×28
# TS_N: 40m×12=480min → 10min×48
P10 = dict(
local_vol_n = 28,
quiet_n = 12,
signal_to_n = 48,
atr_n = 28,
ts_n = 48,
time_stop_pct = 3.0,
vol_mult = 2.0,
)
REGIME_N = 5 # 40분봉 기준
REGIME_WEIGHTS = {"KRW-BTC": 0.40, "KRW-ETH": 0.30,
"KRW-SOL": 0.15, "KRW-XRP": 0.15}
WF_WINDOW = 4
WF_MIN_WIN_RATE = 0.01
WF_SHADOW_WINS = 2
VOL_OVERRIDE_THRESH = 5.0
# ─────────────────────────────────────────────────────────────────────────────
def resample_40m(df):
return (df.resample("40min")
.agg({"open":"first","high":"max","low":"min",
"close":"last","volume":"sum"})
.dropna(subset=["close"]))
def build_regime_series(dfs40):
weighted = None
for ticker, w in REGIME_WEIGHTS.items():
if ticker not in dfs40: continue
pct = dfs40[ticker]["close"].pct_change(REGIME_N) * 100
weighted = pct * w if weighted is None else weighted.add(pct * w, fill_value=0.0)
return weighted if weighted is not None else pd.Series(dtype=float)
def regime_to_10m(regime_40m: pd.Series, df_10m: pd.DataFrame) -> pd.Series:
"""40분봉 레짐 시리즈를 10분봉 인덱스에 ffill 매핑."""
combined = regime_40m.reindex(
regime_40m.index.union(df_10m.index)
).ffill()
return combined.reindex(df_10m.index)
def calc_atr(df, buy_idx, atr_n):
sub = df.iloc[max(0, buy_idx - atr_n - 1):buy_idx]
if len(sub) < 3:
return ATR_MIN
try:
avg = ((sub["high"] - sub["low"]) / sub["low"]).iloc[-atr_n:].mean()
return float(max(ATR_MIN, min(ATR_MAX, avg * ATR_MULT)))
except Exception:
return ATR_MIN
def simulate_pos(df, buy_idx, buy_price, stop_pct, ts_n, time_stop_pct):
peak = buy_price
for i in range(buy_idx + 1, len(df)):
row = df.iloc[i]
if row["high"] > peak:
peak = row["high"]
if row["low"] <= peak * (1 - stop_pct):
sp = peak * (1 - stop_pct)
pnl = (sp*(1-FEE) - buy_price*(1+FEE)) / (buy_price*(1+FEE)) * 100
return pnl > 0, df.index[i], pnl
pnl_now = (row["close"] - buy_price) / buy_price * 100
if (i - buy_idx) >= ts_n and pnl_now < time_stop_pct:
pnl = (row["close"]*(1-FEE) - buy_price*(1+FEE)) / (buy_price*(1+FEE)) * 100
return pnl > 0, df.index[i], pnl
last = df.iloc[-1]["close"]
pnl = (last*(1-FEE) - buy_price*(1+FEE)) / (buy_price*(1+FEE)) * 100
return pnl > 0, df.index[-1], pnl
def run_strategy(df, ticker, regime_series, fng_map, p,
use_fng, use_regime, vol_override_thresh):
"""
공통 전략 함수. df = 봉 단위 OHLCV (10분봉 또는 40분봉).
regime_series: df 인덱스와 정렬된 레짐 시리즈.
우선순위:
① 포지션 청산
② 축적 신호 감지 (필터 무관, 항상 실행)
③ 진입: vol_strong → 모든 필터 skip; 아니면 F&G+레짐 체크
"""
local_vol_n = p["local_vol_n"]
quiet_n = p["quiet_n"]
signal_to_n = p["signal_to_n"]
atr_n = p["atr_n"]
ts_n = p["ts_n"]
time_stop_pct = p["time_stop_pct"]
vol_mult = p["vol_mult"]
trades = []
sig_i = sig_p = sig_vr = None
in_pos = False
buy_idx = buy_price = stop_pct = None
i = max(local_vol_n + 2, quiet_n + 1)
while i < len(df):
ts = df.index[i]
row = df.iloc[i]
cur = row["close"]
# ── ① 포지션 청산 ─────────────────────────────────
if in_pos:
is_win, sdt, pnl = simulate_pos(df, buy_idx, buy_price, stop_pct,
ts_n, time_stop_pct)
next_i = next((j for j in range(i, len(df)) if df.index[j] > sdt), len(df))
trades.append((is_win, pnl, df.index[buy_idx], sdt, ticker))
in_pos = False; sig_i = sig_p = sig_vr = None; i = next_i
continue
# 신호 만료 체크
if sig_i is not None and (i - sig_i) > signal_to_n:
sig_i = sig_p = sig_vr = None
# ── ② 축적 신호 감지 (항상 실행) ──────────────────────
if sig_i is None:
vol_p = df.iloc[i-1]["volume"]
vol_avg = df.iloc[i-1-local_vol_n:i-1]["volume"].mean()
vol_r = vol_p / vol_avg if vol_avg > 0 else 0
close_qh = df.iloc[i-quiet_n]["close"]
chg_qh = abs(cur - close_qh) / close_qh * 100 if close_qh > 0 else 999
if chg_qh < QUIET_PCT and vol_r >= vol_mult:
sig_i = i; sig_p = cur; sig_vr = vol_r
i += 1
continue
# 신호 이후 가격 하락 → 초기화
if cur < sig_p:
sig_i = sig_p = sig_vr = None
i += 1
continue
# ── ③ 진입 체크 ─────────────────────────────────────
vol_strong = (vol_override_thresh > 0
and sig_vr is not None
and sig_vr >= vol_override_thresh)
if not vol_strong:
# F&G 필터
if use_fng and fng_map:
fv = fng_map.get(ts.strftime("%Y-%m-%d"), 50)
if fv < FNG_MIN_ENTRY:
i += 1
continue
# 레짐 BEAR 차단
if use_regime and not regime_series.empty and ts in regime_series.index:
v = regime_series.loc[ts]
if not pd.isna(v) and float(v) < BEAR_THRESHOLD:
i += 1
continue
move_pct = (cur - sig_p) / sig_p * 100
if move_pct >= THRESH:
in_pos = True; buy_idx = i; buy_price = cur
stop_pct = calc_atr(df, i, atr_n)
sig_i = sig_p = sig_vr = None
i += 1
return trades
def apply_wf(trades):
history = []; shadow = 0; blocked = False; accepted = []; cnt = 0
for t in trades:
is_win = int(t[0])
if not blocked:
accepted.append(t); history.append(is_win)
if len(history) >= WF_WINDOW and sum(history[-WF_WINDOW:]) / WF_WINDOW < WF_MIN_WIN_RATE:
blocked = True; shadow = 0
else:
cnt += 1
if is_win:
shadow += 1
if shadow >= WF_SHADOW_WINS:
blocked = False; history = []; shadow = 0
else:
shadow = 0
return accepted, cnt
def apply_max_pos(trades):
open_exits = []; accepted = []; skipped = []
for t in trades:
buy_dt, sell_dt = t[2], t[3]
open_exits = [s for s in open_exits if s > buy_dt]
if len(open_exits) < MAX_POS:
open_exits.append(sell_dt); accepted.append(t)
else:
skipped.append(t)
return accepted, skipped
def run_compound(accepted):
portfolio = float(BUDGET); total_krw = 0.0
wins = 0; peak = BUDGET; max_dd = 0.0; pf = float(BUDGET)
for is_win, pnl, buy_dt, sell_dt, ticker in accepted:
pos_size = max(portfolio, MIN_BUDGET) / MAX_POS
krw_profit = pos_size * pnl / 100
portfolio = max(portfolio + krw_profit, MIN_BUDGET)
total_krw += krw_profit
wins += int(is_win)
pf = max(pf + max(pf, MIN_BUDGET) / MAX_POS * pnl / 100, MIN_BUDGET)
peak = max(peak, pf)
max_dd = max(max_dd, (peak - pf) / peak * 100)
return {
"portfolio": portfolio, "total_krw": total_krw,
"roi_pct": (portfolio - BUDGET) / BUDGET * 100,
"total": len(accepted), "wins": wins,
"wr": wins / len(accepted) * 100 if accepted else 0,
"max_dd": max_dd,
}
def sim_mode(dfs_per_ticker, regime_map, fng_map, p,
use_fng, use_regime, vol_override_thresh):
"""
dfs_per_ticker: {ticker: DataFrame (10m 또는 40m)}
regime_map: {ticker: regime Series (같은 인덱스로 정렬됨)}
"""
all_trades = []; wf_total = 0
for ticker, df in dfs_per_ticker.items():
rs = regime_map.get(ticker, pd.Series(dtype=float))
raw = run_strategy(df, ticker, rs, fng_map, p,
use_fng, use_regime, vol_override_thresh)
filtered, blocked = apply_wf(raw)
wf_total += blocked
all_trades.extend(filtered)
all_trades.sort(key=lambda x: x[2])
accepted, skipped = apply_max_pos(all_trades)
return run_compound(accepted), wf_total, len(skipped)
def fmt(r, wf, skip):
if r["total"] == 0:
return "진입없음"
return (f"{r['total']:>5}{r['wr']:>4.1f}% "
f"{r['roi_pct']:>+7.2f}% {r['total_krw']:>+13,.0f}"
f"-{r['max_dd']:>4.1f}% wf:{wf} skip:{skip}")
def main():
print("캐시 로드 중...")
cache = pickle.load(open(CACHE_FILE, "rb"))
fng_map = json.loads(FNG_FILE.read_text())
tickers = [t for t in list(cache["10m"].keys())[:TOP_N]
if len(cache["10m"][t]) > 500]
print(f" 종목: {len(tickers)}")
dfs10 = {t: cache["10m"][t] for t in tickers}
dfs40 = {t: resample_40m(df) for t, df in dfs10.items()}
# 레짐 (40분봉 기반)
regime_40m = build_regime_series(dfs40)
# 40분봉용 regime map
regime_map_40 = {t: regime_40m for t in tickers}
# 10분봉용 regime map (ffill 매핑)
regime_map_10 = {
t: regime_to_10m(regime_40m, dfs10[t])
for t in tickers
}
sample = next(iter(dfs10.values()))
start_dt = sample.index[0].strftime("%Y-%m-%d")
end_dt = sample.index[-1].strftime("%Y-%m-%d")
print(f"\n{'='*80}")
print(f" 10분봉 vs 40분봉 vol 감지 비교 | {start_dt} ~ {end_dt} | {len(tickers)}종목")
print(f" vol override: ≥{VOL_OVERRIDE_THRESH}x | F&G≥{FNG_MIN_ENTRY} | BEAR차단N{REGIME_N}")
print(f"{'='*80}")
print(f" {'모드':<32}{'진입':>5} {'승률':>5}{'수익률':>8} {'순수익(KRW)':>14} {'낙폭':>6}")
print(f" {''*76}")
# ── 10분봉 모드들 ─────────────────────────────────────────────────────
print(f"\n [10분봉 vol 감지 — local_vol_n={P10['local_vol_n']}봉({P10['local_vol_n']*10}분) quiet_n={P10['quiet_n']}봉({P10['quiet_n']*10}분)]")
modes_10 = [
("A. 10m 필터없음", False, False, 0.0),
("B. 10m F&G+BEAR차단", True, True, 0.0),
(f"C. 10m vol≥{VOL_OVERRIDE_THRESH}x 오버라이드", True, True, VOL_OVERRIDE_THRESH),
]
for label, uf, ur, vt in modes_10:
r, wf, skip = sim_mode(dfs10, regime_map_10, fng_map, P10, uf, ur, vt)
print(f" {label:<32}{fmt(r, wf, skip)}")
# ── 40분봉 모드들 (기준선) ──────────────────────────────────────────
print(f"\n [40분봉 vol 감지 — local_vol_n={P40['local_vol_n']}봉({P40['local_vol_n']*40}분) quiet_n={P40['quiet_n']}봉({P40['quiet_n']*40}분)]")
modes_40 = [
("D. 40m 필터없음", False, False, 0.0),
("E. 40m F&G+BEAR차단", True, True, 0.0),
(f"F. 40m vol≥{VOL_OVERRIDE_THRESH}x 오버라이드", True, True, VOL_OVERRIDE_THRESH),
]
for label, uf, ur, vt in modes_40:
r, wf, skip = sim_mode(dfs40, regime_map_40, fng_map, P40, uf, ur, vt)
print(f" {label:<32}{fmt(r, wf, skip)}")
print(f"\n{'='*80}")
# ── vol≥5x 신호 품질 비교 (10m vs 40m) ─────────────────────────────
print("\n [vol≥5x 오버라이드 신호 품질 비교 — 필터 없음 조건에서 override 효과]")
print(f" {'모드':<32}{'진입':>5} {'승률':>5}{'수익률':>8} {'낙폭':>6}")
for label, dfs, rmap, p in [
("10m vol≥5x (filter none)", dfs10, regime_map_10, P10),
("40m vol≥5x (filter none)", dfs40, regime_map_40, P40),
]:
r, wf, skip = sim_mode(dfs, rmap, fng_map, p, False, False, VOL_OVERRIDE_THRESH)
if r["total"]:
print(f" {label:<32}{r['total']:>5}{r['wr']:>4.1f}% "
f"{r['roi_pct']:>+7.2f}% -{r['max_dd']:>4.1f}%")
print(f"{'='*80}")
if __name__ == "__main__":
main()

229
archive/tests/sim_365.py Normal file
View File

@@ -0,0 +1,229 @@
"""365일 복리 KRW 시뮬레이션.
- 상위 20개 종목 × vol-lead +4.8% 전략
- MAX_POSITIONS=3, 복리 포지션 크기 (이득 시 증가 / 손실 시 차감)
- 최소 예산 = 초기 예산의 30%
- 데이터: Oracle ADB ohlcv_hourly 테이블
"""
import pickle
import sys
from pathlib import Path
from dotenv import load_dotenv
load_dotenv(dotenv_path=Path(__file__).parent / ".env")
sys.path.insert(0, str(Path(__file__).parent))
from ohlcv_db import load_from_db
from vol_lead_sim import run_vol_lead_thresh
# ── 파라미터 ───────────────────────────────────────────
BUDGET = 15_000_000
MIN_BUDGET = BUDGET * 3 // 10 # 하한 30% = 4,500,000원
MAX_POS = 3
THRESH = 4.8
FROM_DATE = "2025-03-02"
TOP30_FILE = Path("top30_tickers.pkl")
def load_data() -> dict:
top30 = pickle.load(open(TOP30_FILE, "rb"))
print(f"DB 로드 중... ({len(top30)}종목)")
data = load_from_db(top30, from_date=FROM_DATE)
valid = {t: df for t, df in data.items() if len(df) >= 500}
print(f"유효 종목: {len(valid)}개 로드 완료")
return valid
pickle.dump(data, open(CACHE_FILE, "wb"))
print(f"\n캐시 저장: {CACHE_FILE} ({len(data)}종목)\n")
return data
# ── WF 필터 (종목별 적용) ──────────────────────────────
WF_WINDOW = 4
WF_MIN_WIN_RATE = 0.01
WF_SHADOW_WINS = 2
def apply_wf(trades: list) -> tuple:
"""종목별 WF 필터: 2연패 시 차단, shadow 2연승 시 복귀."""
history = []
shadow_streak = 0
blocked = False
accepted = []
blocked_cnt = 0
for trade in trades:
is_win = int(trade[0])
if not blocked:
accepted.append(trade)
history.append(is_win)
if len(history) >= WF_WINDOW:
wr = sum(history[-WF_WINDOW:]) / WF_WINDOW
if wr < WF_MIN_WIN_RATE:
blocked = True
shadow_streak = 0
else:
blocked_cnt += 1
if is_win:
shadow_streak += 1
if shadow_streak >= WF_SHADOW_WINS:
blocked = False
history = []
shadow_streak = 0
else:
shadow_streak = 0
return accepted, blocked_cnt
# ── MAX_POSITIONS 필터 ─────────────────────────────────
def collect_trades(data: dict, tickers: list) -> list:
all_trades = []
wf_total_blocked = 0
for t in tickers:
if t not in data:
continue
raw = [(is_win, pnl, buy_dt, sell_dt, reason)
for is_win, pnl, buy_dt, sell_dt, reason
in run_vol_lead_thresh(data[t], THRESH)]
filtered, blocked = apply_wf(raw)
wf_total_blocked += blocked
for is_win, pnl, buy_dt, sell_dt, reason in filtered:
all_trades.append((buy_dt, sell_dt, t, is_win, pnl, reason))
print(f" WF 필터 차단: {wf_total_blocked}")
all_trades.sort(key=lambda x: x[0])
return all_trades
def apply_max_positions(all_trades: list) -> tuple:
open_exits, accepted, skipped = [], [], []
for trade in all_trades:
buy_dt, sell_dt = trade[0], trade[1]
open_exits = [s for s in open_exits if s > buy_dt]
if len(open_exits) < MAX_POS:
open_exits.append(sell_dt)
accepted.append(trade)
else:
skipped.append(trade)
return accepted, skipped
# ── 복리 시뮬레이션 ────────────────────────────────────
def simulate(accepted: list) -> dict:
portfolio = float(BUDGET)
total_krw = 0.0
monthly = {}
trade_log = []
for buy_dt, sell_dt, ticker, is_win, pnl, reason in accepted:
pos_size = max(portfolio, MIN_BUDGET) / MAX_POS
krw_profit = pos_size * pnl / 100
portfolio = max(portfolio + krw_profit, MIN_BUDGET)
total_krw += krw_profit
ym = buy_dt.strftime("%Y-%m")
if ym not in monthly:
monthly[ym] = {"trades": 0, "wins": 0, "pnl_krw": 0.0}
monthly[ym]["trades"] += 1
monthly[ym]["wins"] += int(is_win)
monthly[ym]["pnl_krw"] += krw_profit
trade_log.append({
"buy_dt": buy_dt,
"sell_dt": sell_dt,
"ticker": ticker,
"is_win": is_win,
"pnl_pct": pnl,
"pos_size": pos_size,
"krw_profit": krw_profit,
"portfolio": portfolio,
"reason": reason,
})
wins = sum(1 for t in accepted if t[3])
return {
"portfolio": portfolio,
"total_krw": total_krw,
"roi_pct": (portfolio - BUDGET) / BUDGET * 100,
"total": len(accepted),
"wins": wins,
"wr": wins / len(accepted) * 100 if accepted else 0,
"monthly": monthly,
"trade_log": trade_log,
}
# ── 메인 ──────────────────────────────────────────────
def main() -> None:
data = load_data()
top30 = pickle.load(open(TOP30_FILE, "rb"))
valid = [t for t in top30 if t in data and len(data[t]) >= 500]
use20 = valid[:20]
print(f"{'='*65}")
print(f"365일 복리 시뮬레이션 | vol-lead +{THRESH}% | {len(use20)}종목")
print(f"초기 예산: {BUDGET:,}원 | 최소 예산(하한): {MIN_BUDGET:,}")
print(f"기간: {FROM_DATE[:10]} ~ 2026-03-02")
print(f"{'='*65}")
all_trades = collect_trades(data, use20)
accepted, skipped = apply_max_positions(all_trades)
result = simulate(accepted)
print(f"\n── 전체 결과 ──────────────────────────────────────────")
print(f" 신호 발생: {len(all_trades):>4}")
print(f" 실제 진입: {result['total']:>4}건 ({len(skipped)}건 MAX_POS 스킵)")
print(f" 승/패: {result['wins']}{result['total']-result['wins']}패 (승률 {result['wr']:.0f}%)")
print(f" ─────────────────────────────────────────────────")
print(f" 초기 예산: {BUDGET:>14,}")
print(f" 최종 자산: {result['portfolio']:>14,.0f}")
print(f" 순수익: {result['total_krw']:>+14,.0f}")
print(f" 수익률: {result['roi_pct']:>+13.2f}%")
print(f" 연환산: {result['roi_pct']:>+13.2f}% (이미 1년)")
# 최대 낙폭
peak = BUDGET
max_dd = 0.0
for t in result["trade_log"]:
peak = max(peak, t["portfolio"])
dd = (peak - t["portfolio"]) / peak * 100
max_dd = max(max_dd, dd)
print(f" 최대 낙폭: {-max_dd:>+13.2f}% ({-max_dd/100*BUDGET:>+,.0f}원)")
# 월별
print(f"\n── 월별 수익 ──────────────────────────────────────────")
print(f" {'':^8}{'거래':>4} {'승률':>5}{'월수익(KRW)':>14} {'누적수익(KRW)':>15} {'예산':>14}")
print(f" {''*70}")
cum = 0.0
budget_now = float(BUDGET)
for ym, m in sorted(result["monthly"].items()):
wr = m["wins"] / m["trades"] * 100 if m["trades"] else 0
cum += m["pnl_krw"]
budget_now = max(BUDGET + cum, MIN_BUDGET)
print(f" {ym:^8}{m['trades']:>4}{wr:>4.0f}% │ "
f"{m['pnl_krw']:>+14,.0f}{cum:>+14,.0f}{budget_now:>13,.0f}")
# 종목별
print(f"\n── 종목별 기여 ({len(use20)}종목) ──────────────────────────")
print(f" {'종목':<14}{'거래':>4} {'승률':>5}{'KRW수익':>14} {'평균/건':>10}")
print(f" {''*58}")
stats: dict = {}
for t in result["trade_log"]:
k = t["ticker"]
if k not in stats:
stats[k] = {"n": 0, "wins": 0, "krw": 0.0}
stats[k]["n"] += 1
stats[k]["wins"] += int(t["is_win"])
stats[k]["krw"] += t["krw_profit"]
for t, s in sorted(stats.items(), key=lambda x: -x[1]["krw"]):
wr = s["wins"] / s["n"] * 100 if s["n"] else 0
avg = s["krw"] / s["n"] if s["n"] else 0
print(f" {t:<14}{s['n']:>4}{wr:>4.0f}% │ "
f"{s['krw']:>+14,.0f}{avg:>+9,.0f}원/건")
if __name__ == "__main__":
main()

314
archive/tests/sim_3bar.py Normal file
View File

@@ -0,0 +1,314 @@
"""3봉 vol가속 시그널 + 다양한 청산 전략 비교 시뮬 (30일)."""
import sys, os
from datetime import datetime, timedelta
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from dotenv import load_dotenv
load_dotenv(os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), '.env'))
import oracledb
LOOKBACK_DAYS = 30
VOL_LOOKBACK = 61
ATR_LOOKBACK = 28
VOL_MIN = 8.0
ATR_MULT = 1.0
ATR_MIN_R = 0.030
ATR_MAX_R = 0.050
MAX_TRAIL_BARS = 240
BUDGET = 15_000_000
MAX_POS = 3
PER_POS = BUDGET // MAX_POS
FEE = 0.0005
TICKERS = [
'KRW-XRP','KRW-BTC','KRW-ETH','KRW-SOL','KRW-DOGE',
'KRW-ADA','KRW-SUI','KRW-NEAR','KRW-KAVA','KRW-SXP',
'KRW-AKT','KRW-SONIC','KRW-IP','KRW-ORBS','KRW-VIRTUAL',
'KRW-BARD','KRW-XPL','KRW-KITE','KRW-ENSO','KRW-0G',
]
_TK = ",".join(f"'{t}'" for t in TICKERS)
def get_conn():
kwargs = dict(user=os.environ["ORACLE_USER"], password=os.environ["ORACLE_PASSWORD"],
dsn=os.environ["ORACLE_DSN"])
if w := os.environ.get("ORACLE_WALLET"):
kwargs["config_dir"] = w
return oracledb.connect(**kwargs)
# ── 3봉 시그널 SQL ─────────────────────────────────────────────────────────────
SIGNAL_SQL_3BAR = f"""
WITH base AS (
SELECT ticker, ts, open_p, close_p, high_p, low_p, volume_p,
LAG(close_p,1) OVER (PARTITION BY ticker ORDER BY ts) pc1,
LAG(open_p,1) OVER (PARTITION BY ticker ORDER BY ts) po1,
LAG(close_p,2) OVER (PARTITION BY ticker ORDER BY ts) pc2,
LAG(open_p,2) OVER (PARTITION BY ticker ORDER BY ts) po2,
GREATEST(high_p-low_p,
ABS(high_p-LAG(close_p,1) OVER (PARTITION BY ticker ORDER BY ts)),
ABS(low_p -LAG(close_p,1) OVER (PARTITION BY ticker ORDER BY ts))) tr
FROM backtest_ohlcv
WHERE interval_cd='minute1'
AND ts >= TO_TIMESTAMP(:ws,'YYYY-MM-DD HH24:MI:SS')
AND ticker IN ({_TK})
),
ind AS (
SELECT ticker, ts, open_p, close_p, high_p, low_p,
volume_p / NULLIF(AVG(volume_p) OVER (
PARTITION BY ticker ORDER BY ts ROWS BETWEEN {VOL_LOOKBACK} PRECEDING AND 2 PRECEDING),0) vr0,
LAG(volume_p,1) OVER (PARTITION BY ticker ORDER BY ts) / NULLIF(AVG(volume_p) OVER (
PARTITION BY ticker ORDER BY ts ROWS BETWEEN {VOL_LOOKBACK} PRECEDING AND 2 PRECEDING),0) vr1,
LAG(volume_p,2) OVER (PARTITION BY ticker ORDER BY ts) / NULLIF(AVG(volume_p) OVER (
PARTITION BY ticker ORDER BY ts ROWS BETWEEN {VOL_LOOKBACK} PRECEDING AND 2 PRECEDING),0) vr2,
pc1,po1,pc2,po2,
AVG(tr) OVER (PARTITION BY ticker ORDER BY ts
ROWS BETWEEN {ATR_LOOKBACK} PRECEDING AND 1 PRECEDING) / NULLIF(pc1,0) atr_raw
FROM base
)
SELECT ticker, ts, vr0, vr1, vr2, atr_raw
FROM ind
WHERE ts >= TO_TIMESTAMP(:cs,'YYYY-MM-DD HH24:MI:SS')
AND vr0 >= {VOL_MIN}
-- 3봉 연속 양봉
AND close_p>open_p AND pc1>po1 AND pc2>po2
-- 3봉 연속 가격 가속
AND close_p>pc1 AND pc1>pc2
-- 3봉 연속 볼륨 가속
AND vr0>vr1 AND vr1>vr2
ORDER BY ticker, ts
"""
# ── 4봉 시그널 SQL (비교용) ────────────────────────────────────────────────────
SIGNAL_SQL_4BAR = f"""
WITH base AS (
SELECT ticker, ts, open_p, close_p, high_p, low_p, volume_p,
LAG(close_p,1) OVER (PARTITION BY ticker ORDER BY ts) pc1,
LAG(open_p,1) OVER (PARTITION BY ticker ORDER BY ts) po1,
LAG(close_p,2) OVER (PARTITION BY ticker ORDER BY ts) pc2,
LAG(open_p,2) OVER (PARTITION BY ticker ORDER BY ts) po2,
LAG(close_p,3) OVER (PARTITION BY ticker ORDER BY ts) pc3,
LAG(open_p,3) OVER (PARTITION BY ticker ORDER BY ts) po3,
GREATEST(high_p-low_p,
ABS(high_p-LAG(close_p,1) OVER (PARTITION BY ticker ORDER BY ts)),
ABS(low_p -LAG(close_p,1) OVER (PARTITION BY ticker ORDER BY ts))) tr
FROM backtest_ohlcv
WHERE interval_cd='minute1'
AND ts >= TO_TIMESTAMP(:ws,'YYYY-MM-DD HH24:MI:SS')
AND ticker IN ({_TK})
),
ind AS (
SELECT ticker, ts, open_p, close_p, high_p, low_p,
volume_p / NULLIF(AVG(volume_p) OVER (
PARTITION BY ticker ORDER BY ts ROWS BETWEEN {VOL_LOOKBACK} PRECEDING AND 2 PRECEDING),0) vr0,
LAG(volume_p,1) OVER (PARTITION BY ticker ORDER BY ts) / NULLIF(AVG(volume_p) OVER (
PARTITION BY ticker ORDER BY ts ROWS BETWEEN {VOL_LOOKBACK} PRECEDING AND 2 PRECEDING),0) vr1,
LAG(volume_p,2) OVER (PARTITION BY ticker ORDER BY ts) / NULLIF(AVG(volume_p) OVER (
PARTITION BY ticker ORDER BY ts ROWS BETWEEN {VOL_LOOKBACK} PRECEDING AND 2 PRECEDING),0) vr2,
LAG(volume_p,3) OVER (PARTITION BY ticker ORDER BY ts) / NULLIF(AVG(volume_p) OVER (
PARTITION BY ticker ORDER BY ts ROWS BETWEEN {VOL_LOOKBACK} PRECEDING AND 2 PRECEDING),0) vr3,
pc1,po1,pc2,po2,pc3,po3,
AVG(tr) OVER (PARTITION BY ticker ORDER BY ts
ROWS BETWEEN {ATR_LOOKBACK} PRECEDING AND 1 PRECEDING) / NULLIF(pc1,0) atr_raw
FROM base
)
SELECT ticker, ts, vr0, vr1, vr2, atr_raw
FROM ind
WHERE ts >= TO_TIMESTAMP(:cs,'YYYY-MM-DD HH24:MI:SS')
AND vr0 >= {VOL_MIN}
AND close_p>open_p AND pc1>po1 AND pc2>po2 AND pc3>po3
AND close_p>pc1 AND pc1>pc2 AND pc2>pc3
AND vr0>vr1 AND vr1>vr2 AND vr2>vr3
ORDER BY ticker, ts
"""
def fetch_signals(cur, sql, warmup_since, check_since):
cur.execute(sql, {'ws': warmup_since, 'cs': check_since})
rows = cur.fetchall()
signals = []
for row in rows:
ticker, sig_ts, vr0, vr1, vr2, atr_raw = row[:6]
cur.execute(
"""SELECT close_p, ts FROM backtest_ohlcv
WHERE ticker=:t AND interval_cd='minute1'
AND ts > :sig AND ts <= :sig + INTERVAL '3' MINUTE
ORDER BY ts FETCH FIRST 1 ROWS ONLY""",
{'t': ticker, 'sig': sig_ts}
)
er = cur.fetchone()
if not er:
continue
ep, ets = float(er[0]), er[1]
cur.execute(
"""SELECT ts, close_p, high_p, low_p FROM backtest_ohlcv
WHERE ticker=:t AND interval_cd='minute1'
AND ts >= :entry ORDER BY ts FETCH FIRST :n ROWS ONLY""",
{'t': ticker, 'entry': ets, 'n': MAX_TRAIL_BARS + 1}
)
bars = [(r[0], float(r[1]), float(r[2]), float(r[3])) for r in cur.fetchall()]
if not bars:
continue
signals.append({
'ticker': ticker, 'entry_ts': ets, 'entry_price': ep,
'atr_raw': float(atr_raw) if atr_raw else 0.0,
'bars': bars,
})
signals.sort(key=lambda x: x['entry_ts'])
return signals
# ── 청산 전략 함수들 ───────────────────────────────────────────────────────────
def sim_trail(bars, ep, ar):
stop = max(ATR_MIN_R, min(ATR_MAX_R, ar * ATR_MULT)) if ar > 0 else ATR_MAX_R
peak = ep
for i, (ts, cp, hp, lp) in enumerate(bars):
peak = max(peak, cp)
if (peak - cp) / peak >= stop:
return dict(status='트레일손절', exit_ts=ts, exit_price=cp,
pnl=(cp - ep) / ep * 100, held=i + 1)
lts, lcp = bars[-1][0], bars[-1][1]
return dict(status='타임아웃' if len(bars) >= MAX_TRAIL_BARS else '진행중',
exit_ts=lts, exit_price=lcp, pnl=(lcp - ep) / ep * 100, held=len(bars))
def sim_tp_trail(bars, ep, ar, tp_r):
stop = max(ATR_MIN_R, min(ATR_MAX_R, ar * ATR_MULT)) if ar > 0 else ATR_MAX_R
tp = ep * (1 + tp_r)
peak = ep
for i, (ts, cp, hp, lp) in enumerate(bars):
if hp >= tp:
return dict(status=f'익절+{tp_r*100:.0f}%', exit_ts=ts, exit_price=tp,
pnl=tp_r * 100, held=i + 1)
peak = max(peak, cp)
if (peak - cp) / peak >= stop:
return dict(status='트레일손절', exit_ts=ts, exit_price=cp,
pnl=(cp - ep) / ep * 100, held=i + 1)
lts, lcp = bars[-1][0], bars[-1][1]
return dict(status='타임아웃' if len(bars) >= MAX_TRAIL_BARS else '진행중',
exit_ts=lts, exit_price=lcp, pnl=(lcp - ep) / ep * 100, held=len(bars))
def sim_tp_sl(bars, ep, tp_r, sl_r):
tp = ep * (1 + tp_r)
sl = ep * (1 - sl_r)
for i, (ts, cp, hp, lp) in enumerate(bars):
if lp <= sl:
return dict(status=f'손절-{sl_r*100:.0f}%', exit_ts=ts, exit_price=sl,
pnl=-sl_r * 100, held=i + 1)
if hp >= tp:
return dict(status=f'익절+{tp_r*100:.0f}%', exit_ts=ts, exit_price=tp,
pnl=tp_r * 100, held=i + 1)
lts, lcp = bars[-1][0], bars[-1][1]
return dict(status='타임아웃' if len(bars) >= MAX_TRAIL_BARS else '진행중',
exit_ts=lts, exit_price=lcp, pnl=(lcp - ep) / ep * 100, held=len(bars))
def pos_limit(sim):
opens, taken, skipped = [], [], []
for r in sim:
opens = [ex for ex in opens if ex > r['entry_ts']]
if len(opens) < MAX_POS:
opens.append(r['exit_ts'])
taken.append(r)
else:
skipped.append(r)
return taken, skipped
def run_strategies(signals, strategies):
results = {}
for label, mode, tp_r, sl_r in strategies:
sim = []
for s in signals:
if mode == 'trail':
r = sim_trail(s['bars'], s['entry_price'], s['atr_raw'])
elif mode == 'tp_trail':
r = sim_tp_trail(s['bars'], s['entry_price'], s['atr_raw'], tp_r)
else:
r = sim_tp_sl(s['bars'], s['entry_price'], tp_r, sl_r)
sim.append({**s, **r})
taken, _ = pos_limit(sim)
results[label] = taken
return results
def print_table(title, results, strategies):
print(f"\n{''*105}")
print(f" {title}")
print(f"{''*105}")
print(f" {'전략':32s} {'거래':>3s} {'승률':>4s} {'합산손익':>12s} {'수익률':>5s} {'평균보유':>5s} {'손익비':>5s}")
print(f"{''*105}")
for label, _, _, _ in strategies:
taken = results.get(label, [])
n = len(taken)
if n == 0:
print(f" {label:32s} 거래없음")
continue
wins = sum(1 for r in taken if r['pnl'] > 0)
losses = sum(1 for r in taken if r['pnl'] < 0)
total = sum(PER_POS * (r['pnl'] / 100) - PER_POS * FEE * 2 for r in taken)
avg_h = sum(r['held'] for r in taken) / n
ret = total / BUDGET * 100
avg_w = sum(r['pnl'] for r in taken if r['pnl'] > 0) / wins if wins else 0
avg_l = abs(sum(r['pnl'] for r in taken if r['pnl'] < 0) / losses) if losses else 1
rr = avg_w / avg_l
print(f" {label:32s} {n:>3d}{wins/n*100:>4.0f}% {total:>+12,.0f}"
f"{ret:>+5.2f}% {avg_h:>5.0f}{rr:>4.1f}:1")
print(f"{''*105}")
def print_detail(label, taken):
print(f"\n[{label} 건별 상세]")
print(f"{''*105}")
for i, r in enumerate(taken, 1):
krw = PER_POS * (r['pnl'] / 100) - PER_POS * FEE * 2
sign = '' if r['pnl'] > 0 else ''
print(f" #{i:02d} {r['ticker']:12s}[{sign}] {str(r['entry_ts'])[:16]} "
f"{r['status']:14s} {r['held']:3d}{r['pnl']:>+.2f}% ({krw:>+,.0f}원)")
def main():
now = datetime.now()
check_since = (now - timedelta(days=LOOKBACK_DAYS)).strftime('%Y-%m-%d 00:00:00')
warmup_since = (now - timedelta(days=LOOKBACK_DAYS + 1)).strftime('%Y-%m-%d 00:00:00')
conn = get_conn()
cur = conn.cursor()
cur.arraysize = 10000
print(f"=== 3봉 vs 4봉 진입 비교 시뮬 ===")
print(f"기간: {check_since[:10]} ~ {now.strftime('%Y-%m-%d')} (30일)")
print(f"VOL≥{VOL_MIN}x | 자본 {BUDGET//10000}만원 / 포지션 {PER_POS//10000}만원 / 동시 {MAX_POS}\n")
# 시그널 수집
print("3봉 시그널 수집 중...", flush=True)
sigs_3 = fetch_signals(cur, SIGNAL_SQL_3BAR, warmup_since, check_since)
print("4봉 시그널 수집 중...", flush=True)
sigs_4 = fetch_signals(cur, SIGNAL_SQL_4BAR, warmup_since, check_since)
print(f" → 3봉: {len(sigs_3)}건 / 4봉: {len(sigs_4)}\n")
strategies = [
('TP 3% + Trail Stop [3~5%]', 'tp_trail', 0.03, None ),
('TP 2% + Trail Stop [3~5%]', 'tp_trail', 0.02, None ),
('TP 2% + SL 2%', 'tp_sl', 0.02, 0.02 ),
('TP 2% + SL 3%', 'tp_sl', 0.02, 0.03 ),
('TP 3% + SL 2%', 'tp_sl', 0.03, 0.02 ),
('TP 3% + SL 3%', 'tp_sl', 0.03, 0.03 ),
]
res_3 = run_strategies(sigs_3, strategies)
res_4 = run_strategies(sigs_4, strategies)
print_table(f"【3봉 진입】 시그널 {len(sigs_3)}", res_3, strategies)
print_table(f"【4봉 진입】 시그널 {len(sigs_4)}", res_4, strategies)
# 3봉에서 가장 나은 전략 상세
best_label = max(res_3, key=lambda k: sum(
PER_POS * (r['pnl'] / 100) - PER_POS * FEE * 2 for r in res_3[k]))
print_detail(f"3봉 최적: {best_label}", res_3[best_label])
conn.close()
if __name__ == '__main__':
main()

296
archive/tests/sim_45m40.py Normal file
View File

@@ -0,0 +1,296 @@
"""45일 복리 KRW 시뮬레이션 — 40분봉.
sim10m_cache.pkl(10분봉)을 40분봉으로 리샘플링 후
sim_365.py 와 동일한 복리·WF·MAX_POSITIONS 로직 적용.
"""
import pickle
import sys
from pathlib import Path
import pandas as pd
from dotenv import load_dotenv
load_dotenv(dotenv_path=Path(__file__).parent / ".env")
sys.path.insert(0, str(Path(__file__).parent))
# ── 파라미터 ─────────────────────────────────────────────
CACHE_FILE = Path(__file__).parent.parent / "data" / "sim10m_cache.pkl"
TOP30_FILE = Path(__file__).parent.parent / "data" / "top30_tickers.pkl"
TOP_N = 20
BUDGET = 15_000_000
MIN_BUDGET = BUDGET * 3 // 10
MAX_POS = 3
FEE = 0.0005
TIME_STOP_MIN_PCT = 3.0
ATR_MULT = 1.5
ATR_MIN = 0.010
ATR_MAX = 0.020
VOL_MULT = 2.0
QUIET_PCT = 2.0
THRESH = 4.8
# 40분봉 기준 시간 파라미터 → 봉수 환산 (60/40 = 1.5봉/h)
LOCAL_VOL_N = 7 # 5h × 1.5
QUIET_N = 3 # 2h × 1.5
SIGNAL_TO_N = 12 # 8h × 1.5
ATR_N = 7 # 5h × 1.5
TS_N = 12 # 8h × 1.5
WF_WINDOW = 4
WF_MIN_WIN_RATE = 0.01
WF_SHADOW_WINS = 2
# ── 리샘플링 ─────────────────────────────────────────────
def resample_40m(df: pd.DataFrame) -> pd.DataFrame:
return (
df.resample("40min")
.agg({"open": "first", "high": "max", "low": "min",
"close": "last", "volume": "sum"})
.dropna(subset=["close"])
)
# ── ATR ──────────────────────────────────────────────────
def calc_atr(df: pd.DataFrame, buy_idx: int) -> float:
sub = df.iloc[max(0, buy_idx - ATR_N - 1):buy_idx]
if len(sub) < 3:
return ATR_MIN
try:
avg = ((sub["high"] - sub["low"]) / sub["low"]).iloc[-ATR_N:].mean()
return float(max(ATR_MIN, min(ATR_MAX, avg * ATR_MULT)))
except Exception:
return ATR_MIN
# ── 포지션 시뮬 ──────────────────────────────────────────
def simulate_pos(df: pd.DataFrame, buy_idx: int,
buy_price: float, stop_pct: float):
peak = buy_price
for i in range(buy_idx + 1, len(df)):
row = df.iloc[i]
ts = df.index[i]
if row["high"] > peak:
peak = row["high"]
if row["low"] <= peak * (1 - stop_pct):
sp = peak * (1 - stop_pct)
pnl = (sp * (1 - FEE) - buy_price * (1 + FEE)) / (buy_price * (1 + FEE)) * 100
return pnl > 0, ts, pnl
pnl_now = (row["close"] - buy_price) / buy_price * 100
if (i - buy_idx) >= TS_N and pnl_now < TIME_STOP_MIN_PCT:
pnl = (row["close"] * (1 - FEE) - buy_price * (1 + FEE)) / (buy_price * (1 + FEE)) * 100
return pnl > 0, ts, pnl
last = df.iloc[-1]["close"]
pnl = (last * (1 - FEE) - buy_price * (1 + FEE)) / (buy_price * (1 + FEE)) * 100
return pnl > 0, df.index[-1], pnl
# ── vol-lead 전략 ─────────────────────────────────────────
def run_vol_lead(df: pd.DataFrame, ticker: str) -> list:
trades = []
sig_i = sig_p = None
in_pos = False
buy_idx = buy_price = stop_pct = None
i = max(LOCAL_VOL_N + 2, QUIET_N + 1)
while i < len(df):
if in_pos:
is_win, sdt, pnl = simulate_pos(df, buy_idx, buy_price, stop_pct)
next_i = next((j for j in range(i, len(df)) if df.index[j] > sdt), len(df))
trades.append((is_win, pnl, df.index[buy_idx], sdt, ticker))
in_pos = False
sig_i = sig_p = None
i = next_i
continue
close = df.iloc[i]["close"]
vol_p = df.iloc[i - 1]["volume"]
vol_avg = df.iloc[i - LOCAL_VOL_N - 1:i - 1]["volume"].mean()
vol_r = vol_p / vol_avg if vol_avg > 0 else 0
close_qh = df.iloc[i - QUIET_N]["close"]
chg_qh = abs(close - close_qh) / close_qh * 100
quiet = chg_qh < QUIET_PCT
spike = vol_r >= VOL_MULT
if quiet and spike:
if sig_i is None:
sig_i, sig_p = i, close
else:
if sig_i is not None and close < sig_p:
sig_i = sig_p = None
if sig_i is not None and (i - sig_i) > SIGNAL_TO_N:
sig_i = sig_p = None
if sig_i is not None and (close - sig_p) / sig_p * 100 >= THRESH:
in_pos = True
buy_idx = i
buy_price = close
stop_pct = calc_atr(df, i)
sig_i = sig_p = None
i += 1
return trades
# ── WF 필터 ──────────────────────────────────────────────
def apply_wf(trades: list) -> tuple:
history = []
shadow_streak = 0
blocked = False
accepted = []
blocked_cnt = 0
for trade in trades:
is_win = int(trade[0])
if not blocked:
accepted.append(trade)
history.append(is_win)
if len(history) >= WF_WINDOW:
wr = sum(history[-WF_WINDOW:]) / WF_WINDOW
if wr < WF_MIN_WIN_RATE:
blocked = True
shadow_streak = 0
else:
blocked_cnt += 1
if is_win:
shadow_streak += 1
if shadow_streak >= WF_SHADOW_WINS:
blocked = False
history = []
shadow_streak = 0
else:
shadow_streak = 0
return accepted, blocked_cnt
# ── MAX_POSITIONS 필터 ────────────────────────────────────
def apply_max_positions(all_trades: list) -> tuple:
open_exits, accepted, skipped = [], [], []
for trade in all_trades:
buy_dt, sell_dt = trade[2], trade[3]
open_exits = [s for s in open_exits if s > buy_dt]
if len(open_exits) < MAX_POS:
open_exits.append(sell_dt)
accepted.append(trade)
else:
skipped.append(trade)
return accepted, skipped
# ── 복리 시뮬 ────────────────────────────────────────────
def simulate(accepted: list) -> dict:
portfolio = float(BUDGET)
total_krw = 0.0
monthly = {}
trade_log = []
for is_win, pnl, buy_dt, sell_dt, ticker in accepted:
pos_size = max(portfolio, MIN_BUDGET) / MAX_POS
krw_profit = pos_size * pnl / 100
portfolio = max(portfolio + krw_profit, MIN_BUDGET)
total_krw += krw_profit
ym = buy_dt.strftime("%Y-%m")
if ym not in monthly:
monthly[ym] = {"trades": 0, "wins": 0, "pnl_krw": 0.0}
monthly[ym]["trades"] += 1
monthly[ym]["wins"] += int(is_win)
monthly[ym]["pnl_krw"] += krw_profit
trade_log.append({
"buy_dt": buy_dt, "sell_dt": sell_dt, "ticker": ticker,
"is_win": is_win, "pnl_pct": pnl,
"pos_size": pos_size, "krw_profit": krw_profit,
"portfolio": portfolio,
})
wins = sum(1 for t in accepted if t[0])
return {
"portfolio": portfolio,
"total_krw": total_krw,
"roi_pct": (portfolio - BUDGET) / BUDGET * 100,
"total": len(accepted),
"wins": wins,
"wr": wins / len(accepted) * 100 if accepted else 0,
"monthly": monthly,
"trade_log": trade_log,
}
# ── 메인 ─────────────────────────────────────────────────
def main():
print("캐시 로드 중...")
cache = pickle.load(open(CACHE_FILE, "rb"))
top30 = pickle.load(open(TOP30_FILE, "rb"))
tickers = [t for t in top30[:TOP_N] if t in cache["10m"]]
print(f"유효 종목: {len(tickers)}\n")
# 리샘플링 + 전략 실행
all_trades = []
wf_total_blocked = 0
for t in tickers:
df40 = resample_40m(cache["10m"][t])
if len(df40) < 50:
continue
raw = run_vol_lead(df40, t)
filtered, blocked = apply_wf(raw)
wf_total_blocked += blocked
all_trades.extend(filtered)
all_trades.sort(key=lambda x: x[2])
accepted, skipped = apply_max_positions(all_trades)
result = simulate(accepted)
# 최대 낙폭
peak = BUDGET
max_dd = 0.0
for t in result["trade_log"]:
peak = max(peak, t["portfolio"])
dd = (peak - t["portfolio"]) / peak * 100
max_dd = max(max_dd, dd)
# 기간 추출
if result["trade_log"]:
start_dt = result["trade_log"][0]["buy_dt"].strftime("%Y-%m-%d")
end_dt = result["trade_log"][-1]["sell_dt"].strftime("%Y-%m-%d")
else:
start_dt = end_dt = "N/A"
print(f"{'='*60}")
print(f"45일 복리 시뮬 | 40분봉 vol-lead +{THRESH}% | {len(tickers)}종목")
print(f"기간: {start_dt} ~ {end_dt}")
print(f"{'='*60}")
print(f" 신호 발생: {len(all_trades) + wf_total_blocked:>4}건 (WF 차단: {wf_total_blocked}건)")
print(f" 실제 진입: {result['total']:>4}건 ({len(skipped)}건 MAX_POS 스킵)")
print(f" 승/패: {result['wins']}{result['total']-result['wins']}"
f" (승률 {result['wr']:.1f}%)")
print(f" {''*50}")
print(f" 초기 예산: {BUDGET:>14,}")
print(f" 최종 자산: {result['portfolio']:>14,.0f}")
print(f" 순수익: {result['total_krw']:>+14,.0f}")
print(f" 수익률: {result['roi_pct']:>+13.2f}%")
print(f" 최대 낙폭: {-max_dd:>+13.2f}%"
f" ({-max_dd / 100 * BUDGET:>+,.0f}원)")
monthly_krw = [m["pnl_krw"] for m in result["monthly"].values()]
avg_monthly = sum(monthly_krw) / len(monthly_krw) if monthly_krw else 0
print(f" 월평균 수익: {avg_monthly:>+13,.0f}")
print(f"\n── 월별 수익 {''*40}")
print(f" {'':^8}{'거래':>4} {'승률':>5}{'월수익(KRW)':>14} {'누적수익(KRW)':>15}")
cum = 0.0
for ym, m in sorted(result["monthly"].items()):
wr = m["wins"] / m["trades"] * 100 if m["trades"] else 0
cum += m["pnl_krw"]
print(f" {ym:^8}{m['trades']:>4}{wr:>4.0f}% │ "
f"{m['pnl_krw']:>+14,.0f}{cum:>+14,.0f}")
print(f"{'='*60}")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,281 @@
"""캐스케이드 limit 주문 전략 시뮬 (30일).
전략:
① bars[0:2] → 2봉, +2% limit (trail 없음)
② bars[2:5] → 3봉, +1% limit (trail 없음)
③ bars[5:5+last_n] → last_n봉, +0.5% limit (trail 없음)
④ bars[5+last_n:] → 기존전략 (TP2% + ATR Trail Stop)
"""
import sys, os
from datetime import datetime, timedelta
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from dotenv import load_dotenv
load_dotenv(os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), '.env'))
import oracledb
LOOKBACK_DAYS = 30
VOL_LOOKBACK = 61
ATR_LOOKBACK = 28
VOL_MIN = 8.0
ATR_MULT = 1.0
ATR_MIN_R = 0.030
ATR_MAX_R = 0.050
MAX_TRAIL_BARS = 240
BUDGET = 15_000_000
MAX_POS = 3
PER_POS = BUDGET // MAX_POS
FEE = 0.0005
TICKERS = [
'KRW-XRP','KRW-BTC','KRW-ETH','KRW-SOL','KRW-DOGE',
'KRW-ADA','KRW-SUI','KRW-NEAR','KRW-KAVA','KRW-SXP',
'KRW-AKT','KRW-SONIC','KRW-IP','KRW-ORBS','KRW-VIRTUAL',
'KRW-BARD','KRW-XPL','KRW-KITE','KRW-ENSO','KRW-0G',
]
_TK = ",".join(f"'{t}'" for t in TICKERS)
def get_conn():
kwargs = dict(user=os.environ["ORACLE_USER"], password=os.environ["ORACLE_PASSWORD"],
dsn=os.environ["ORACLE_DSN"])
if w := os.environ.get("ORACLE_WALLET"):
kwargs["config_dir"] = w
return oracledb.connect(**kwargs)
SIGNAL_SQL = f"""
WITH base AS (
SELECT ticker, ts, open_p, close_p, high_p, low_p, volume_p,
LAG(close_p,1) OVER (PARTITION BY ticker ORDER BY ts) pc1,
LAG(open_p,1) OVER (PARTITION BY ticker ORDER BY ts) po1,
LAG(close_p,2) OVER (PARTITION BY ticker ORDER BY ts) pc2,
LAG(open_p,2) OVER (PARTITION BY ticker ORDER BY ts) po2,
GREATEST(high_p-low_p,
ABS(high_p-LAG(close_p,1) OVER (PARTITION BY ticker ORDER BY ts)),
ABS(low_p -LAG(close_p,1) OVER (PARTITION BY ticker ORDER BY ts))) tr
FROM backtest_ohlcv
WHERE interval_cd='minute1'
AND ts >= TO_TIMESTAMP(:ws,'YYYY-MM-DD HH24:MI:SS')
AND ticker IN ({_TK})
),
ind AS (
SELECT ticker, ts, open_p, close_p, high_p, low_p,
volume_p / NULLIF(AVG(volume_p) OVER (
PARTITION BY ticker ORDER BY ts ROWS BETWEEN {VOL_LOOKBACK} PRECEDING AND 2 PRECEDING),0) vr0,
LAG(volume_p,1) OVER (PARTITION BY ticker ORDER BY ts) / NULLIF(AVG(volume_p) OVER (
PARTITION BY ticker ORDER BY ts ROWS BETWEEN {VOL_LOOKBACK} PRECEDING AND 2 PRECEDING),0) vr1,
LAG(volume_p,2) OVER (PARTITION BY ticker ORDER BY ts) / NULLIF(AVG(volume_p) OVER (
PARTITION BY ticker ORDER BY ts ROWS BETWEEN {VOL_LOOKBACK} PRECEDING AND 2 PRECEDING),0) vr2,
pc1,po1,pc2,po2,
AVG(tr) OVER (PARTITION BY ticker ORDER BY ts
ROWS BETWEEN {ATR_LOOKBACK} PRECEDING AND 1 PRECEDING) / NULLIF(pc1,0) atr_raw
FROM base
)
SELECT ticker, ts, vr0, vr1, vr2, atr_raw
FROM ind
WHERE ts >= TO_TIMESTAMP(:cs,'YYYY-MM-DD HH24:MI:SS')
AND vr0 >= {VOL_MIN}
AND close_p>open_p AND pc1>po1 AND pc2>po2
AND close_p>pc1 AND pc1>pc2
AND vr0>vr1 AND vr1>vr2
ORDER BY ticker, ts
"""
def fetch_signals(cur, warmup_since, check_since):
cur.execute(SIGNAL_SQL, {'ws': warmup_since, 'cs': check_since})
rows = cur.fetchall()
signals = []
for row in rows:
ticker, sig_ts, vr0, vr1, vr2, atr_raw = row
cur.execute(
"""SELECT close_p, ts FROM backtest_ohlcv
WHERE ticker=:t AND interval_cd='minute1'
AND ts > :sig AND ts <= :sig + INTERVAL '3' MINUTE
ORDER BY ts FETCH FIRST 1 ROWS ONLY""",
{'t': ticker, 'sig': sig_ts}
)
er = cur.fetchone()
if not er:
continue
ep, ets = float(er[0]), er[1]
cur.execute(
"""SELECT ts, close_p, high_p, low_p FROM backtest_ohlcv
WHERE ticker=:t AND interval_cd='minute1'
AND ts >= :entry ORDER BY ts FETCH FIRST :n ROWS ONLY""",
{'t': ticker, 'entry': ets, 'n': MAX_TRAIL_BARS + 1}
)
bars = [(r[0], float(r[1]), float(r[2]), float(r[3])) for r in cur.fetchall()]
if not bars:
continue
signals.append({
'ticker': ticker, 'entry_ts': ets, 'entry_price': ep,
'atr_raw': float(atr_raw) if atr_raw else 0.0,
'bars': bars,
})
signals.sort(key=lambda x: x['entry_ts'])
return signals
def sim_tp_trail(bars, ep, ar, tp_r=0.02):
"""기본 전략: TP + Trail Stop."""
stop = max(ATR_MIN_R, min(ATR_MAX_R, ar * ATR_MULT)) if ar > 0 else ATR_MAX_R
tp = ep * (1 + tp_r)
peak = ep
for i, (ts, cp, hp, lp) in enumerate(bars):
if hp >= tp:
return dict(status='TP2%', exit_ts=ts, exit_price=tp,
pnl=tp_r * 100, held=i + 1)
peak = max(peak, cp)
if (peak - cp) / peak >= stop:
return dict(status='트레일손절', exit_ts=ts, exit_price=cp,
pnl=(cp - ep) / ep * 100, held=i + 1)
lts, lcp = bars[-1][0], bars[-1][1]
return dict(status='타임아웃' if len(bars) >= MAX_TRAIL_BARS else '진행중',
exit_ts=lts, exit_price=lcp, pnl=(lcp - ep) / ep * 100, held=len(bars))
def sim_cascade(bars, ep, ar, last_n):
"""
① bars[0:2] → 2봉, +2% limit
② bars[2:5] → 3봉, +1% limit
③ bars[5:5+last_n] → last_n봉, +0.5% limit
④ bars[5+last_n:] → 기존전략 (TP2% + Trail Stop)
"""
stages = [
(0, 2, 0.020, f'①2봉2%'),
(2, 5, 0.010, f'②3봉1%'),
(5, 5 + last_n, 0.005, f'{last_n}봉0.5%'),
]
for start, end, lr, tag in stages:
lp = ep * (1 + lr)
for i, (ts, cp, hp, _) in enumerate(bars[start:end]):
if hp >= lp:
return dict(status=tag, exit_ts=ts, exit_price=lp,
pnl=lr * 100, held=start + i + 1)
offset = 5 + last_n
fb = sim_tp_trail(bars[offset:] or bars[-1:], ep, ar)
fb['held'] += offset
fb['status'] = '④기존→' + fb['status']
return fb
def sim_limit_then_trail(bars, ep, ar, n_bars=2, limit_r=0.005, tp_r=0.02):
"""단순 limit: N봉 내 체결 안되면 TP/Trail."""
lp = ep * (1 + limit_r)
for i, (ts, cp, hp, _) in enumerate(bars[:n_bars]):
if hp >= lp:
return dict(status=f'limit{limit_r*100:.1f}%', exit_ts=ts,
exit_price=lp, pnl=limit_r * 100, held=i + 1)
fb = sim_tp_trail(bars[n_bars:] or bars[-1:], ep, ar, tp_r)
fb['held'] += n_bars
fb['status'] = '미체결→' + fb['status']
return fb
def pos_limit(sim):
opens, taken, skipped = [], [], []
for r in sim:
opens = [ex for ex in opens if ex > r['entry_ts']]
if len(opens) < MAX_POS:
opens.append(r['exit_ts'])
taken.append(r)
else:
skipped.append(r)
return taken, skipped
def krw(r):
return PER_POS * (r['pnl'] / 100) - PER_POS * FEE * 2
def print_cascade_detail(taken, last_n, label):
stage_tags = ['①2봉2%', '②3봉1%', f'{last_n}봉0.5%']
stage_lr = [0.020, 0.010, 0.005]
print(f"\n{''*70}")
print(f" {label}")
print(f"{len(taken)}건 승률 {sum(1 for r in taken if r['pnl']>0)/len(taken)*100:.0f}% "
f"합산 {sum(krw(r) for r in taken):+,.0f}")
print(f"{''*70}")
for tag, lr in zip(stage_tags, stage_lr):
grp = [r for r in taken if r['status'] == tag]
if not grp:
continue
total = sum(krw(r) for r in grp)
avg = total / len(grp)
print(f" ┌─ {tag}: {len(grp):3d}건 avg {avg:+,.0f}원/건 소계 {total:+,.0f}")
# ④ 기존전략 하위 분류
fb_grp = [r for r in taken if r['status'].startswith('④기존→')]
if fb_grp:
print(f" └─ ④기존전략 (미체결 후): {len(fb_grp)}")
for sub in ['TP2%', '트레일손절', '타임아웃', '진행중']:
sub_grp = [r for r in fb_grp if r['status'].endswith(sub)]
if not sub_grp:
continue
total = sum(krw(r) for r in sub_grp)
avg = total / len(sub_grp)
print(f" {'' if total>0 else ''} {sub:8s}: {len(sub_grp):3d}"
f"avg {avg:+,.0f}원/건 소계 {total:+,.0f}")
print()
def main():
now = datetime.now()
check_since = (now - timedelta(days=LOOKBACK_DAYS)).strftime('%Y-%m-%d 00:00:00')
warmup_since = (now - timedelta(days=LOOKBACK_DAYS + 1)).strftime('%Y-%m-%d 00:00:00')
conn = get_conn()
cur = conn.cursor()
cur.arraysize = 10000
print(f"=== 캐스케이드 limit 전략 시뮬 ===")
print(f"기간: {check_since[:10]} ~ {now.strftime('%Y-%m-%d')} (30일)\n")
signals = fetch_signals(cur, warmup_since, check_since)
print(f"시그널 {len(signals)}\n")
# ── 기준선: 현재전략 ─────────────────────────────────────────────────────
base_sim = []
for s in signals:
r = sim_tp_trail(s['bars'], s['entry_price'], s['atr_raw'])
base_sim.append({**s, **r})
base_taken, _ = pos_limit(base_sim)
base_total = sum(krw(r) for r in base_taken)
base_wr = sum(1 for r in base_taken if r['pnl'] > 0) / len(base_taken) * 100
print(f"{''*70}")
print(f" [기준] 현재전략 TP2%+Trail: {len(base_taken)}"
f"승률 {base_wr:.0f}% 합산 {base_total:+,.0f}")
# ── 비교: limit 0.5%/2봉 → TP/Trail ─────────────────────────────────────
lim_sim = []
for s in signals:
r = sim_limit_then_trail(s['bars'], s['entry_price'], s['atr_raw'])
lim_sim.append({**s, **r})
lim_taken, _ = pos_limit(lim_sim)
lim_total = sum(krw(r) for r in lim_taken)
lim_wr = sum(1 for r in lim_taken if r['pnl'] > 0) / len(lim_taken) * 100
print(f" [비교] limit 0.5%/2봉→TP/Trail: {len(lim_taken)}"
f"승률 {lim_wr:.0f}% 합산 {lim_total:+,.0f}")
print(f"{''*70}\n")
# ── 캐스케이드 (15봉 / 30봉) ─────────────────────────────────────────────
for last_n in [15, 30]:
label = f"cascade ①2봉+2% → ②3봉+1% → ③{last_n}봉+0.5% → ④기존전략"
csim = []
for s in signals:
r = sim_cascade(s['bars'], s['entry_price'], s['atr_raw'], last_n)
csim.append({**s, **r})
taken, _ = pos_limit(csim)
print_cascade_detail(taken, last_n, label)
conn.close()
if __name__ == '__main__':
main()

View File

@@ -0,0 +1,448 @@
"""현재 전략 기준 45일 복리 시뮬레이션 — 40분봉.
sim_45m40.py의 검증된 코어 로직을 기반으로
현재 전략 추가사항만 반영:
+ F&G 필터 (FNG_MIN_ENTRY=41)
+ 시장 레짐 필터 (BEAR < -0.5% → 차단, BULL ≥ 1.5% → vol_mult 완화)
+ 신호 강도별 진입 임계값 티어 (5x→1%, 3.5x→2%, 2.5x→3%, 기본→5%)
+ 속도 기반 조기 진입 (0.10%/분)
"""
import json
import os
import pickle
import sys
import urllib.request
import datetime
from pathlib import Path
import pandas as pd
from dotenv import load_dotenv
load_dotenv(dotenv_path=Path(__file__).parent.parent / ".env")
sys.path.insert(0, str(Path(__file__).parent.parent))
# ── 파라미터 ─────────────────────────────────────────────
CACHE_FILE = Path(__file__).parent.parent / "data" / "sim10m_cache.pkl"
TOP_N = 20
BUDGET = 15_000_000
MIN_BUDGET = BUDGET * 3 // 10
MAX_POS = 3
FEE = 0.0005
TIME_STOP_MIN_PCT= 3.0
ATR_MULT = 1.5
ATR_MIN = 0.010
ATR_MAX = 0.020
VOL_MULT_NEUTRAL = 2.0 # NEUTRAL 레짐
VOL_MULT_BULL = 1.5 # BULL 레짐
QUIET_PCT = 2.0
THRESH_BASE = 5.0 # 기본 진입 임계값 (TREND_AFTER_VOL)
# 신호 강도별 임계값 티어
ENTRY_TIERS = [(5.0, 1.0), (3.5, 2.0), (2.5, 3.0)]
# 속도 진입
VELOCITY_THRESHOLD = 0.10 # %/분
VELOCITY_MIN_MOVE = 0.5 # 최소 이동 %
VELOCITY_MIN_AGE_M = 5.0 # 최소 경과 분
# F&G
FNG_MIN_ENTRY = int(os.getenv("FNG_MIN_ENTRY", "41"))
# 레짐
BEAR_THRESHOLD = -0.5
BULL_THRESHOLD = 1.5
REGIME_WEIGHTS = {"KRW-BTC": 0.40, "KRW-ETH": 0.30,
"KRW-SOL": 0.15, "KRW-XRP": 0.15}
# 40분봉 봉수 환산
LOCAL_VOL_N = 7 # 5h
QUIET_N = 3 # 2h
SIGNAL_TO_N = 12 # 8h
ATR_N = 7
TS_N = 12 # 8h (타임스탑)
REGIME_N = 3 # 2h (레짐 추세)
# ── F&G 히스토리 ─────────────────────────────────────────
def load_fng_history() -> dict:
try:
url = "https://api.alternative.me/fng/?limit=90&format=json"
with urllib.request.urlopen(url, timeout=10) as r:
data = json.loads(r.read())
result = {}
for e in data["data"]:
dt = datetime.datetime.fromtimestamp(int(e["timestamp"]))
result[dt.strftime("%Y-%m-%d")] = int(e["value"])
return result
except Exception as ex:
print(f" [경고] F&G 로드 실패: {ex} → 필터 비활성화")
return {}
# ── 리샘플링 ─────────────────────────────────────────────
def resample_40m(df: pd.DataFrame) -> pd.DataFrame:
return (
df.resample("40min")
.agg({"open": "first", "high": "max", "low": "min",
"close": "last", "volume": "sum"})
.dropna(subset=["close"])
)
# ── 레짐 시리즈 ──────────────────────────────────────────
def build_regime_series(dfs: dict) -> pd.Series:
weighted = None
for ticker, w in REGIME_WEIGHTS.items():
if ticker not in dfs:
continue
pct = dfs[ticker]["close"].pct_change(REGIME_N) * 100
weighted = pct * w if weighted is None else weighted.add(pct * w, fill_value=0.0)
return weighted if weighted is not None else pd.Series(dtype=float)
# ── 임계값 ───────────────────────────────────────────────
def calc_entry_threshold(vol_ratio: float) -> float:
for min_r, thr in ENTRY_TIERS:
if vol_ratio >= min_r:
return thr
return THRESH_BASE
# ── ATR ──────────────────────────────────────────────────
def calc_atr(df: pd.DataFrame, buy_idx: int) -> float:
sub = df.iloc[max(0, buy_idx - ATR_N - 1):buy_idx]
if len(sub) < 3:
return ATR_MIN
try:
avg = ((sub["high"] - sub["low"]) / sub["low"]).iloc[-ATR_N:].mean()
return float(max(ATR_MIN, min(ATR_MAX, avg * ATR_MULT)))
except Exception:
return ATR_MIN
# ── 포지션 시뮬 (기존 sim_45m40.py와 동일) ───────────────
def simulate_pos(df: pd.DataFrame, buy_idx: int,
buy_price: float, stop_pct: float):
peak = buy_price
for i in range(buy_idx + 1, len(df)):
row = df.iloc[i]
ts = df.index[i]
if row["high"] > peak:
peak = row["high"]
if row["low"] <= peak * (1 - stop_pct):
sp = peak * (1 - stop_pct)
pnl = (sp * (1 - FEE) - buy_price * (1 + FEE)) / (buy_price * (1 + FEE)) * 100
return pnl > 0, ts, pnl, "trailing_stop"
pnl_now = (row["close"] - buy_price) / buy_price * 100
if (i - buy_idx) >= TS_N and pnl_now < TIME_STOP_MIN_PCT:
pnl = (row["close"] * (1 - FEE) - buy_price * (1 + FEE)) / (buy_price * (1 + FEE)) * 100
return pnl > 0, ts, pnl, "time_stop"
last = df.iloc[-1]["close"]
pnl = (last * (1 - FEE) - buy_price * (1 + FEE)) / (buy_price * (1 + FEE)) * 100
return pnl > 0, df.index[-1], pnl, "end_of_data"
# ── vol-lead 전략 (현재 전략 파라미터 전체 반영) ──────────
def run_vol_lead(df: pd.DataFrame, ticker: str,
fng_map: dict, regime_series: pd.Series) -> list:
trades = []
sig_i = None # 신호 봉 인덱스
sig_p = None # 신호가
sig_vr = 0.0 # 신호 vol_ratio
in_pos = False
buy_idx = buy_price = stop_pct = None
i = max(LOCAL_VOL_N + 2, QUIET_N + 1)
while i < len(df):
ts = df.index[i]
row = df.iloc[i]
cur = row["close"]
# ── 포지션 보유 중: 청산 체크 ─────────────────────
if in_pos:
is_win, sdt, pnl, reason = simulate_pos(df, buy_idx, buy_price, stop_pct)
next_i = next((j for j in range(i, len(df)) if df.index[j] > sdt), len(df))
trades.append((is_win, pnl, df.index[buy_idx], sdt, ticker, reason))
in_pos = False
sig_i = sig_p = None
i = next_i
continue
# ── F&G 필터 ──────────────────────────────────────
date_str = ts.strftime("%Y-%m-%d")
if fng_map:
fv = fng_map.get(date_str, 50)
if fv < FNG_MIN_ENTRY:
sig_i = sig_p = None # 신호 초기화
i += 1
continue
# ── 레짐 필터 ─────────────────────────────────────
score = 0.0
if not regime_series.empty and ts in regime_series.index:
v = regime_series.loc[ts]
score = float(v) if not pd.isna(v) else 0.0
if score < BEAR_THRESHOLD:
sig_i = sig_p = None
i += 1
continue
vol_mult = VOL_MULT_BULL if score >= BULL_THRESHOLD else VOL_MULT_NEUTRAL
# ── 신호 타임아웃 ──────────────────────────────────
if sig_i is not None and (i - sig_i) > SIGNAL_TO_N:
sig_i = sig_p = None
# ── 신호 있음: 진입 체크 ──────────────────────────
if sig_i is not None:
move_pct = (cur - sig_p) / sig_p * 100
age_min = (i - sig_i) * 40 # 봉수 → 분
entry_thr = calc_entry_threshold(sig_vr)
if cur < sig_p:
# 신호가 이하 하락 → 초기화
sig_i = sig_p = None
elif move_pct >= entry_thr:
# 거리 기반 진입
in_pos = True
buy_idx = i
buy_price = cur
stop_pct = calc_atr(df, i)
sig_i = sig_p = None
elif age_min >= VELOCITY_MIN_AGE_M and move_pct >= VELOCITY_MIN_MOVE:
velocity = move_pct / age_min
if velocity >= VELOCITY_THRESHOLD:
# 속도 기반 조기 진입
in_pos = True
buy_idx = i
buy_price = cur
stop_pct = calc_atr(df, i)
sig_i = sig_p = None
i += 1
continue
# ── 신호 없음: 축적 조건 체크 ────────────────────
vol_p = df.iloc[i - 1]["volume"]
vol_avg = df.iloc[i - 1 - LOCAL_VOL_N:i - 1]["volume"].mean()
vol_r = vol_p / vol_avg if vol_avg > 0 else 0
close_qh = df.iloc[i - QUIET_N]["close"]
chg_qh = abs(cur - close_qh) / close_qh * 100 if close_qh > 0 else 999
quiet = chg_qh < QUIET_PCT
spike = vol_r >= vol_mult
if quiet and spike:
if sig_i is None:
sig_i = i
sig_p = cur
sig_vr = vol_r
else:
if sig_i is not None and cur < sig_p:
sig_i = sig_p = None
i += 1
return trades
# ── WF 필터 (기존 동일) ──────────────────────────────────
def apply_wf(trades: list) -> tuple:
history = []
shadow_streak = 0
blocked = False
accepted = []
blocked_cnt = 0
for trade in trades:
is_win = int(trade[0])
if not blocked:
accepted.append(trade)
history.append(is_win)
if len(history) >= WF_WINDOW and sum(history[-WF_WINDOW:]) / WF_WINDOW < WF_MIN_WIN_RATE:
blocked = True
shadow_streak = 0
else:
blocked_cnt += 1
if is_win:
shadow_streak += 1
if shadow_streak >= WF_SHADOW_WINS:
blocked = False
history = []
shadow_streak = 0
else:
shadow_streak = 0
return accepted, blocked_cnt
WF_WINDOW = 4
WF_MIN_WIN_RATE = 0.01
WF_SHADOW_WINS = 2
# ── MAX_POSITIONS (기존 동일) ────────────────────────────
def apply_max_positions(all_trades: list) -> tuple:
open_exits, accepted, skipped = [], [], []
for trade in all_trades:
buy_dt, sell_dt = trade[2], trade[3]
open_exits = [s for s in open_exits if s > buy_dt]
if len(open_exits) < MAX_POS:
open_exits.append(sell_dt)
accepted.append(trade)
else:
skipped.append(trade)
return accepted, skipped
# ── 복리 시뮬 (기존 동일) ───────────────────────────────
def simulate(accepted: list) -> dict:
portfolio = float(BUDGET)
total_krw = 0.0
monthly = {}
trade_log = []
reason_cnt = {}
for trade in accepted:
is_win, pnl, buy_dt, sell_dt, ticker, reason = trade
pos_size = max(portfolio, MIN_BUDGET) / MAX_POS
krw_profit = pos_size * pnl / 100
portfolio = max(portfolio + krw_profit, MIN_BUDGET)
total_krw += krw_profit
reason_cnt[reason] = reason_cnt.get(reason, 0) + 1
ym = buy_dt.strftime("%Y-%m")
if ym not in monthly:
monthly[ym] = {"trades": 0, "wins": 0, "pnl_krw": 0.0}
monthly[ym]["trades"] += 1
monthly[ym]["wins"] += int(is_win)
monthly[ym]["pnl_krw"] += krw_profit
trade_log.append({
"buy_dt": buy_dt, "sell_dt": sell_dt, "ticker": ticker,
"is_win": is_win, "pnl_pct": pnl, "reason": reason,
"pos_size": pos_size, "krw_profit": krw_profit,
"portfolio": portfolio,
})
wins = sum(1 for t in accepted if t[0])
return {
"portfolio": portfolio, "total_krw": total_krw,
"roi_pct": (portfolio - BUDGET) / BUDGET * 100,
"total": len(accepted), "wins": wins,
"wr": wins / len(accepted) * 100 if accepted else 0,
"monthly": monthly, "trade_log": trade_log,
"reason_cnt": reason_cnt,
}
# ── 메인 ─────────────────────────────────────────────────
def main():
print("=" * 62)
print("현재 전략 기준 시뮬 (F&G + 레짐 + 티어임계 + 속도진입)")
print("=" * 62)
print("F&G 히스토리 로드...")
fng_map = load_fng_history()
if fng_map:
vals = sorted(fng_map.items())
print(f" {vals[0][0]} ~ {vals[-1][0]} ({len(fng_map)}일)")
else:
print(" F&G 데이터 없음 — 필터 비활성화")
print("캐시 로드...")
cache = pickle.load(open(CACHE_FILE, "rb"))
tickers = [t for t in list(cache["10m"].keys())[:TOP_N]
if len(cache["10m"][t]) > 200]
print(f" 종목: {len(tickers)}\n")
dfs_40m = {t: resample_40m(cache["10m"][t]) for t in tickers}
print("레짐 시리즈 계산...")
regime_series = build_regime_series(dfs_40m)
sample_df = next(iter(dfs_40m.values()))
start_date = sample_df.index[0].strftime("%Y-%m-%d")
end_date = sample_df.index[-1].strftime("%Y-%m-%d")
print(f" 기간: {start_date} ~ {end_date}\n")
# F&G 차단 일수
if fng_map:
period_dates = [d for d in fng_map if start_date <= d <= end_date]
fng_blocked = sum(1 for d in period_dates if fng_map.get(d, 50) < FNG_MIN_ENTRY)
fng_allowed = len(period_dates) - fng_blocked
else:
fng_blocked = fng_allowed = 0
all_trades = []
wf_blocked = 0
for ticker in tickers:
df40 = dfs_40m[ticker]
raw = run_vol_lead(df40, ticker, fng_map, regime_series)
filtered, blocked = apply_wf(raw)
wf_blocked += blocked
all_trades.extend(filtered)
all_trades.sort(key=lambda x: x[2]) # buy_dt 기준 정렬
accepted, skipped = apply_max_positions(all_trades)
result = simulate(accepted)
# 최대 낙폭
peak = BUDGET
max_dd = 0.0
for t in result["trade_log"]:
peak = max(peak, t["portfolio"])
dd = (peak - t["portfolio"]) / peak * 100
max_dd = max(max_dd, dd)
total = result["total"]
wins = result["wins"]
print(f"{'='*62}")
print(f" 기간: {start_date} ~ {end_date} ({len(tickers)}종목 / 40분봉)")
print(f" F&G 차단: {fng_blocked}일 / 허용: {fng_allowed}일 (기준 FNG≥{FNG_MIN_ENTRY})")
print(f"{'='*62}")
print(f" 신호 발생: {len(all_trades)+wf_blocked:>4}건 (WF 차단: {wf_blocked}건)")
print(f" 실제 진입: {total:>4}건 ({len(skipped)}건 MAX_POS 스킵)")
print(f" 승 / 패: {wins}{total-wins}패 (승률 {result['wr']:.1f}%)"
if total else " 진입 없음")
print(f" {''*52}")
print(f" 초기 예산: {BUDGET:>15,}")
print(f" 최종 자산: {result['portfolio']:>15,.0f}")
print(f" 순수익: {result['total_krw']:>+15,.0f}")
print(f" 수익률: {result['roi_pct']:>+14.2f}%")
print(f" 최대 낙폭: {-max_dd:>+14.2f}%"
f" ({-max_dd/100*BUDGET:>+,.0f}원)")
monthly_krw = [m["pnl_krw"] for m in result["monthly"].values()]
avg_m = sum(monthly_krw) / len(monthly_krw) if monthly_krw else 0
print(f" 월평균 수익: {avg_m:>+13,.0f}")
print(f"\n── 청산 사유 {''*44}")
label_map = {"trailing_stop": "트레일링스탑", "time_stop": "타임스탑",
"end_of_data": "데이터종료"}
for r, cnt in sorted(result["reason_cnt"].items(), key=lambda x: -x[1]):
print(f" {label_map.get(r, r):12}: {cnt:>3}")
print(f"\n── 월별 수익 {''*44}")
print(f" {'':^8}{'거래':>4} {'승률':>5}"
f" {'월수익(KRW)':>13} {'누적수익(KRW)':>14}")
cum = 0.0
for ym, m in sorted(result["monthly"].items()):
wr = m["wins"] / m["trades"] * 100 if m["trades"] else 0
cum += m["pnl_krw"]
print(f" {ym:^8}{m['trades']:>4}{wr:>4.0f}% │"
f" {m['pnl_krw']:>+13,.0f}{cum:>+14,.0f}")
print(f"\n── 파라미터 {''*46}")
print(f" F&G≥{FNG_MIN_ENTRY} 레짐BEAR<{BEAR_THRESHOLD}% BULL≥{BULL_THRESHOLD}%")
print(f" VOL: {VOL_MULT_NEUTRAL}x(중립)/{VOL_MULT_BULL}x(강세) 횡보<{QUIET_PCT}%")
print(f" 임계: 5x→1% / 3.5x→2% / 2.5x→3% / 기본→{THRESH_BASE}%")
print(f" 속도: ≥{VELOCITY_THRESHOLD}%/분 (≥{VELOCITY_MIN_MOVE}% / ≥{VELOCITY_MIN_AGE_M}분)")
print(f" ATR: ×{ATR_MULT} ({ATR_MIN*100:.0f}~{ATR_MAX*100:.0f}%) 타임스탑: 8h/{TIME_STOP_MIN_PCT}%")
print(f"{'='*62}")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,265 @@
"""진입 즉시 limit 매도 주문 → N봉 내 미체결 시 TP/Trail 전환 시뮬 (30일).
전략:
1. 3봉 vol가속 시그널 → 진입
2. 즉시 limit_price = entry_price × (1 + limit_r) 에 limit 매도 주문
3. N봉 안에 high_p >= limit_price → 체결 (limit_price에 청산)
4. N봉 안에 미체결 → TP 2% + Trail Stop 으로 전환
"""
import sys, os
from datetime import datetime, timedelta
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from dotenv import load_dotenv
load_dotenv(os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), '.env'))
import oracledb
LOOKBACK_DAYS = 30
VOL_LOOKBACK = 61
ATR_LOOKBACK = 28
VOL_MIN = 8.0
ATR_MULT = 1.0
ATR_MIN_R = 0.030
ATR_MAX_R = 0.050
MAX_TRAIL_BARS = 240
BUDGET = 15_000_000
MAX_POS = 3
PER_POS = BUDGET // MAX_POS
FEE = 0.0005
TICKERS = [
'KRW-XRP','KRW-BTC','KRW-ETH','KRW-SOL','KRW-DOGE',
'KRW-ADA','KRW-SUI','KRW-NEAR','KRW-KAVA','KRW-SXP',
'KRW-AKT','KRW-SONIC','KRW-IP','KRW-ORBS','KRW-VIRTUAL',
'KRW-BARD','KRW-XPL','KRW-KITE','KRW-ENSO','KRW-0G',
]
_TK = ",".join(f"'{t}'" for t in TICKERS)
def get_conn():
kwargs = dict(user=os.environ["ORACLE_USER"], password=os.environ["ORACLE_PASSWORD"],
dsn=os.environ["ORACLE_DSN"])
if w := os.environ.get("ORACLE_WALLET"):
kwargs["config_dir"] = w
return oracledb.connect(**kwargs)
SIGNAL_SQL = f"""
WITH base AS (
SELECT ticker, ts, open_p, close_p, high_p, low_p, volume_p,
LAG(close_p,1) OVER (PARTITION BY ticker ORDER BY ts) pc1,
LAG(open_p,1) OVER (PARTITION BY ticker ORDER BY ts) po1,
LAG(close_p,2) OVER (PARTITION BY ticker ORDER BY ts) pc2,
LAG(open_p,2) OVER (PARTITION BY ticker ORDER BY ts) po2,
GREATEST(high_p-low_p,
ABS(high_p-LAG(close_p,1) OVER (PARTITION BY ticker ORDER BY ts)),
ABS(low_p -LAG(close_p,1) OVER (PARTITION BY ticker ORDER BY ts))) tr
FROM backtest_ohlcv
WHERE interval_cd='minute1'
AND ts >= TO_TIMESTAMP(:ws,'YYYY-MM-DD HH24:MI:SS')
AND ticker IN ({_TK})
),
ind AS (
SELECT ticker, ts, open_p, close_p, high_p, low_p,
volume_p / NULLIF(AVG(volume_p) OVER (
PARTITION BY ticker ORDER BY ts ROWS BETWEEN {VOL_LOOKBACK} PRECEDING AND 2 PRECEDING),0) vr0,
LAG(volume_p,1) OVER (PARTITION BY ticker ORDER BY ts) / NULLIF(AVG(volume_p) OVER (
PARTITION BY ticker ORDER BY ts ROWS BETWEEN {VOL_LOOKBACK} PRECEDING AND 2 PRECEDING),0) vr1,
LAG(volume_p,2) OVER (PARTITION BY ticker ORDER BY ts) / NULLIF(AVG(volume_p) OVER (
PARTITION BY ticker ORDER BY ts ROWS BETWEEN {VOL_LOOKBACK} PRECEDING AND 2 PRECEDING),0) vr2,
pc1,po1,pc2,po2,
AVG(tr) OVER (PARTITION BY ticker ORDER BY ts
ROWS BETWEEN {ATR_LOOKBACK} PRECEDING AND 1 PRECEDING) / NULLIF(pc1,0) atr_raw
FROM base
)
SELECT ticker, ts, vr0, vr1, vr2, atr_raw
FROM ind
WHERE ts >= TO_TIMESTAMP(:cs,'YYYY-MM-DD HH24:MI:SS')
AND vr0 >= {VOL_MIN}
AND close_p>open_p AND pc1>po1 AND pc2>po2
AND close_p>pc1 AND pc1>pc2
AND vr0>vr1 AND vr1>vr2
ORDER BY ticker, ts
"""
def fetch_signals(cur, warmup_since, check_since):
cur.execute(SIGNAL_SQL, {'ws': warmup_since, 'cs': check_since})
rows = cur.fetchall()
signals = []
for row in rows:
ticker, sig_ts, vr0, vr1, vr2, atr_raw = row
cur.execute(
"""SELECT close_p, ts FROM backtest_ohlcv
WHERE ticker=:t AND interval_cd='minute1'
AND ts > :sig AND ts <= :sig + INTERVAL '3' MINUTE
ORDER BY ts FETCH FIRST 1 ROWS ONLY""",
{'t': ticker, 'sig': sig_ts}
)
er = cur.fetchone()
if not er:
continue
ep, ets = float(er[0]), er[1]
cur.execute(
"""SELECT ts, close_p, high_p, low_p FROM backtest_ohlcv
WHERE ticker=:t AND interval_cd='minute1'
AND ts >= :entry ORDER BY ts FETCH FIRST :n ROWS ONLY""",
{'t': ticker, 'entry': ets, 'n': MAX_TRAIL_BARS + 1}
)
bars = [(r[0], float(r[1]), float(r[2]), float(r[3])) for r in cur.fetchall()]
if not bars:
continue
signals.append({
'ticker': ticker, 'entry_ts': ets, 'entry_price': ep,
'atr_raw': float(atr_raw) if atr_raw else 0.0,
'bars': bars,
})
signals.sort(key=lambda x: x['entry_ts'])
return signals
# ── 청산 전략 ─────────────────────────────────────────────────────────────────
def sim_tp_trail(bars, ep, ar, tp_r=0.02):
"""기본 전략: TP + Trail Stop."""
stop = max(ATR_MIN_R, min(ATR_MAX_R, ar * ATR_MULT)) if ar > 0 else ATR_MAX_R
tp = ep * (1 + tp_r)
peak = ep
for i, (ts, cp, hp, lp) in enumerate(bars):
if hp >= tp:
return dict(status=f'TP{tp_r*100:.0f}%', exit_ts=ts, exit_price=tp,
pnl=tp_r * 100, held=i + 1)
peak = max(peak, cp)
if (peak - cp) / peak >= stop:
return dict(status='트레일', exit_ts=ts, exit_price=cp,
pnl=(cp - ep) / ep * 100, held=i + 1)
lts, lcp = bars[-1][0], bars[-1][1]
return dict(status='타임아웃' if len(bars) >= MAX_TRAIL_BARS else '진행중',
exit_ts=lts, exit_price=lcp, pnl=(lcp - ep) / ep * 100, held=len(bars))
def sim_limit_then_trail(bars, ep, ar, n_bars, limit_r, tp_r=0.02):
"""진입 즉시 limit_price에 매도 주문 → N봉 내 체결 안되면 TP/Trail 전환.
체결 조건: high_p >= limit_price → limit_price에 청산 (실현 가능한 가격)
"""
limit_price = ep * (1 + limit_r)
window = bars[:n_bars]
for i, (ts, cp, hp, lp) in enumerate(window):
if hp >= limit_price:
pnl = (limit_price - ep) / ep * 100
return dict(status=f'limit체결({n_bars}봉)', exit_ts=ts,
exit_price=limit_price, pnl=pnl, held=i + 1)
# N봉 내 미체결 → TP/Trail 전환
fallback = sim_tp_trail(bars[n_bars:] or bars[-1:], ep, ar, tp_r)
fallback['status'] = f'미체결→{fallback["status"]}'
fallback['held'] += n_bars
return fallback
def pos_limit(sim):
opens, taken, skipped = [], [], []
for r in sim:
opens = [ex for ex in opens if ex > r['entry_ts']]
if len(opens) < MAX_POS:
opens.append(r['exit_ts'])
taken.append(r)
else:
skipped.append(r)
return taken, skipped
def stats(taken):
n = len(taken)
if n == 0:
return None
wins = sum(1 for r in taken if r['pnl'] > 0)
losses = sum(1 for r in taken if r['pnl'] < 0)
total = sum(PER_POS * (r['pnl'] / 100) - PER_POS * FEE * 2 for r in taken)
avg_h = sum(r['held'] for r in taken) / n
ret = total / BUDGET * 100
avg_w = sum(r['pnl'] for r in taken if r['pnl'] > 0) / wins if wins else 0
avg_l = abs(sum(r['pnl'] for r in taken if r['pnl'] < 0) / losses) if losses else 1
fill_n = sum(1 for r in taken if 'limit체결' in r['status'])
return dict(n=n, wins=wins, wr=wins/n*100, total=total, ret=ret, avg_h=avg_h,
avg_w=avg_w, avg_l=avg_l, rr=avg_w/avg_l if avg_l else 0,
fill_r=fill_n/n*100)
def main():
now = datetime.now()
check_since = (now - timedelta(days=LOOKBACK_DAYS)).strftime('%Y-%m-%d 00:00:00')
warmup_since = (now - timedelta(days=LOOKBACK_DAYS + 1)).strftime('%Y-%m-%d 00:00:00')
conn = get_conn()
cur = conn.cursor()
cur.arraysize = 10000
print(f"=== Limit 주문 전략 시뮬 (3봉 진입) ===")
print(f"기간: {check_since[:10]} ~ {now.strftime('%Y-%m-%d')} (30일)\n")
signals = fetch_signals(cur, warmup_since, check_since)
print(f"시그널 {len(signals)}\n")
# 비교 전략 목록: (label, limit_r, n_bars)
strategies = [
('현재전략: TP 2% + Trail', None, None, None),
('limit 0.5% / 2봉, 미체결→TP/Trail', 0.005, 2, 0.02),
('limit 0.5% / 3봉, 미체결→TP/Trail', 0.005, 3, 0.02),
('limit 0.5% / 5봉, 미체결→TP/Trail', 0.005, 5, 0.02),
('limit 1.0% / 2봉, 미체결→TP/Trail', 0.010, 2, 0.02),
('limit 1.0% / 3봉, 미체결→TP/Trail', 0.010, 3, 0.02),
('limit 1.0% / 5봉, 미체결→TP/Trail', 0.010, 5, 0.02),
('limit 1.5% / 2봉, 미체결→TP/Trail', 0.015, 2, 0.02),
('limit 1.5% / 3봉, 미체결→TP/Trail', 0.015, 3, 0.02),
('limit 1.5% / 5봉, 미체결→TP/Trail', 0.015, 5, 0.02),
('limit 2.0% / 3봉, 미체결→TP/Trail', 0.020, 3, 0.02),
('limit 2.0% / 5봉, 미체결→TP/Trail', 0.020, 5, 0.02),
]
results = {}
for label, limit_r, n_bars, tp_r in strategies:
sim = []
for s in signals:
if limit_r is None:
r = sim_tp_trail(s['bars'], s['entry_price'], s['atr_raw'])
else:
r = sim_limit_then_trail(s['bars'], s['entry_price'], s['atr_raw'],
n_bars, limit_r, tp_r)
sim.append({**s, **r})
taken, _ = pos_limit(sim)
results[label] = taken
# ── 요약표 ──────────────────────────────────────────────────────────────────
print(f"{''*120}")
print(f" {'전략':40s} {'거래':>3s} {'승률':>4s} {'합산손익':>12s} {'수익률':>5s} "
f"{'평균보유':>5s} {'체결률':>5s} {'평균수익':>6s} {'평균손실':>6s}")
print(f"{''*120}")
for label, limit_r, n_bars, tp_r in strategies:
taken = results[label]
s = stats(taken)
if not s:
continue
print(f" {label:40s} {s['n']:>3d}{s['wr']:>4.0f}% {s['total']:>+12,.0f}"
f"{s['ret']:>+5.2f}% {s['avg_h']:>5.1f}{s['fill_r']:>4.0f}% "
f"{s['avg_w']:>+5.2f}% {s['avg_l']:>+5.2f}%")
print(f"{''*120}")
# ── 최고 전략 상세 ──────────────────────────────────────────────────────────
best_label = max(results, key=lambda k: sum(PER_POS * (r['pnl']/100) - PER_POS*FEE*2
for r in results[k]))
print(f"\n[최고 전략: {best_label} 건별 상세]")
print(f"{''*110}")
for i, r in enumerate(results[best_label], 1):
krw = PER_POS * (r['pnl'] / 100) - PER_POS * FEE * 2
sign = '' if r['pnl'] > 0 else ''
print(f" #{i:02d} {r['ticker']:12s}[{sign}] {str(r['entry_ts'])[:16]} "
f"진입 {r['entry_price']:>10,.0f}"
f"{r['status']:22s} {r['held']:3d}{r['pnl']:>+.2f}% ({krw:>+,.0f}원)")
conn.close()
if __name__ == '__main__':
main()

View File

@@ -0,0 +1,253 @@
"""진입 후 N봉 내 최고가 청산 전략 시뮬 (30일).
전략: 3봉 vol가속 진입 → N봉 내 최고 고가에서 매도
비교: 현재 전략 (TP 2% + Trail Stop)
"""
import sys, os
from datetime import datetime, timedelta
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from dotenv import load_dotenv
load_dotenv(os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), '.env'))
import oracledb
LOOKBACK_DAYS = 30
VOL_LOOKBACK = 61
ATR_LOOKBACK = 28
VOL_MIN = 8.0
ATR_MULT = 1.0
ATR_MIN_R = 0.030
ATR_MAX_R = 0.050
MAX_TRAIL_BARS = 240
BUDGET = 15_000_000
MAX_POS = 3
PER_POS = BUDGET // MAX_POS
FEE = 0.0005
TICKERS = [
'KRW-XRP','KRW-BTC','KRW-ETH','KRW-SOL','KRW-DOGE',
'KRW-ADA','KRW-SUI','KRW-NEAR','KRW-KAVA','KRW-SXP',
'KRW-AKT','KRW-SONIC','KRW-IP','KRW-ORBS','KRW-VIRTUAL',
'KRW-BARD','KRW-XPL','KRW-KITE','KRW-ENSO','KRW-0G',
]
_TK = ",".join(f"'{t}'" for t in TICKERS)
def get_conn():
kwargs = dict(user=os.environ["ORACLE_USER"], password=os.environ["ORACLE_PASSWORD"],
dsn=os.environ["ORACLE_DSN"])
if w := os.environ.get("ORACLE_WALLET"):
kwargs["config_dir"] = w
return oracledb.connect(**kwargs)
SIGNAL_SQL = f"""
WITH base AS (
SELECT ticker, ts, open_p, close_p, high_p, low_p, volume_p,
LAG(close_p,1) OVER (PARTITION BY ticker ORDER BY ts) pc1,
LAG(open_p,1) OVER (PARTITION BY ticker ORDER BY ts) po1,
LAG(close_p,2) OVER (PARTITION BY ticker ORDER BY ts) pc2,
LAG(open_p,2) OVER (PARTITION BY ticker ORDER BY ts) po2,
GREATEST(high_p-low_p,
ABS(high_p-LAG(close_p,1) OVER (PARTITION BY ticker ORDER BY ts)),
ABS(low_p -LAG(close_p,1) OVER (PARTITION BY ticker ORDER BY ts))) tr
FROM backtest_ohlcv
WHERE interval_cd='minute1'
AND ts >= TO_TIMESTAMP(:ws,'YYYY-MM-DD HH24:MI:SS')
AND ticker IN ({_TK})
),
ind AS (
SELECT ticker, ts, open_p, close_p, high_p, low_p,
volume_p / NULLIF(AVG(volume_p) OVER (
PARTITION BY ticker ORDER BY ts ROWS BETWEEN {VOL_LOOKBACK} PRECEDING AND 2 PRECEDING),0) vr0,
LAG(volume_p,1) OVER (PARTITION BY ticker ORDER BY ts) / NULLIF(AVG(volume_p) OVER (
PARTITION BY ticker ORDER BY ts ROWS BETWEEN {VOL_LOOKBACK} PRECEDING AND 2 PRECEDING),0) vr1,
LAG(volume_p,2) OVER (PARTITION BY ticker ORDER BY ts) / NULLIF(AVG(volume_p) OVER (
PARTITION BY ticker ORDER BY ts ROWS BETWEEN {VOL_LOOKBACK} PRECEDING AND 2 PRECEDING),0) vr2,
pc1,po1,pc2,po2,
AVG(tr) OVER (PARTITION BY ticker ORDER BY ts
ROWS BETWEEN {ATR_LOOKBACK} PRECEDING AND 1 PRECEDING) / NULLIF(pc1,0) atr_raw
FROM base
)
SELECT ticker, ts, vr0, vr1, vr2, atr_raw
FROM ind
WHERE ts >= TO_TIMESTAMP(:cs,'YYYY-MM-DD HH24:MI:SS')
AND vr0 >= {VOL_MIN}
AND close_p>open_p AND pc1>po1 AND pc2>po2
AND close_p>pc1 AND pc1>pc2
AND vr0>vr1 AND vr1>vr2
ORDER BY ticker, ts
"""
def fetch_signals(cur, warmup_since, check_since):
cur.execute(SIGNAL_SQL, {'ws': warmup_since, 'cs': check_since})
rows = cur.fetchall()
signals = []
for row in rows:
ticker, sig_ts, vr0, vr1, vr2, atr_raw = row
cur.execute(
"""SELECT close_p, ts FROM backtest_ohlcv
WHERE ticker=:t AND interval_cd='minute1'
AND ts > :sig AND ts <= :sig + INTERVAL '3' MINUTE
ORDER BY ts FETCH FIRST 1 ROWS ONLY""",
{'t': ticker, 'sig': sig_ts}
)
er = cur.fetchone()
if not er:
continue
ep, ets = float(er[0]), er[1]
# 최대 10봉만 필요 (peak exit용) + trail stop용 241봉
cur.execute(
"""SELECT ts, close_p, high_p, low_p FROM backtest_ohlcv
WHERE ticker=:t AND interval_cd='minute1'
AND ts >= :entry ORDER BY ts FETCH FIRST :n ROWS ONLY""",
{'t': ticker, 'entry': ets, 'n': MAX_TRAIL_BARS + 1}
)
bars = [(r[0], float(r[1]), float(r[2]), float(r[3])) for r in cur.fetchall()]
if not bars:
continue
signals.append({
'ticker': ticker, 'entry_ts': ets, 'entry_price': ep,
'atr_raw': float(atr_raw) if atr_raw else 0.0,
'bars': bars,
})
signals.sort(key=lambda x: x['entry_ts'])
return signals
# ── 청산 전략 ─────────────────────────────────────────────────────────────────
def sim_tp_trail(bars, ep, ar, tp_r=0.02):
"""기본 전략: TP 2% + Trail Stop."""
stop = max(ATR_MIN_R, min(ATR_MAX_R, ar * ATR_MULT)) if ar > 0 else ATR_MAX_R
tp = ep * (1 + tp_r)
peak = ep
for i, (ts, cp, hp, lp) in enumerate(bars):
if hp >= tp:
return dict(status='익절+2%', exit_ts=ts, exit_price=tp, pnl=tp_r * 100, held=i + 1)
peak = max(peak, cp)
if (peak - cp) / peak >= stop:
return dict(status='트레일손절', exit_ts=ts, exit_price=cp,
pnl=(cp - ep) / ep * 100, held=i + 1)
lts, lcp = bars[-1][0], bars[-1][1]
return dict(status='타임아웃' if len(bars) >= MAX_TRAIL_BARS else '진행중',
exit_ts=lts, exit_price=lcp, pnl=(lcp - ep) / ep * 100, held=len(bars))
def sim_peak_then_trail(bars, ep, ar, n_bars, tp_r=0.02):
"""진입 후 n_bars 봉 내 이익 구간이 나오면 고가 청산.
이익 없으면 TP 2% + Trail Stop으로 계속 운영.
이익 판단: n_bars 내 어느 봉이든 high_p > entry_price 이면
그 구간의 최고 고가에서 청산.
"""
window = bars[:n_bars]
# n봉 내 이익 구간 탐색
best_high = max((hp for _, _, hp, _ in window), default=ep)
if best_high > ep:
# 이익 나는 최고 고가에서 청산
best_ts = next(ts for ts, cp, hp, lp in window if hp == best_high)
held = next(i + 1 for i, (ts, cp, hp, lp) in enumerate(window) if hp == best_high)
pnl = (best_high - ep) / ep * 100
return dict(status=f'피크청산({n_bars}봉)', exit_ts=best_ts, exit_price=best_high,
pnl=pnl, held=held)
# n봉 내 이익 없음 → TP 2% + Trail Stop으로 전환
return sim_tp_trail(bars[n_bars:] or bars[-1:], ep, ar, tp_r)
def pos_limit(sim):
opens, taken, skipped = [], [], []
for r in sim:
opens = [ex for ex in opens if ex > r['entry_ts']]
if len(opens) < MAX_POS:
opens.append(r['exit_ts'])
taken.append(r)
else:
skipped.append(r)
return taken, skipped
def stats(taken):
n = len(taken)
if n == 0:
return None
wins = sum(1 for r in taken if r['pnl'] > 0)
losses = sum(1 for r in taken if r['pnl'] < 0)
total = sum(PER_POS * (r['pnl'] / 100) - PER_POS * FEE * 2 for r in taken)
avg_h = sum(r['held'] for r in taken) / n
ret = total / BUDGET * 100
avg_w = sum(r['pnl'] for r in taken if r['pnl'] > 0) / wins if wins else 0
avg_l = abs(sum(r['pnl'] for r in taken if r['pnl'] < 0) / losses) if losses else 1
return dict(n=n, wins=wins, wr=wins/n*100, total=total, ret=ret, avg_h=avg_h,
avg_w=avg_w, avg_l=avg_l, rr=avg_w/avg_l if avg_l else 0)
def main():
now = datetime.now()
check_since = (now - timedelta(days=LOOKBACK_DAYS)).strftime('%Y-%m-%d 00:00:00')
warmup_since = (now - timedelta(days=LOOKBACK_DAYS + 1)).strftime('%Y-%m-%d 00:00:00')
conn = get_conn()
cur = conn.cursor()
cur.arraysize = 10000
print(f"=== 3봉 진입 후 N봉 피크청산 전략 시뮬 ===")
print(f"기간: {check_since[:10]} ~ {now.strftime('%Y-%m-%d')} (30일)\n")
signals = fetch_signals(cur, warmup_since, check_since)
print(f"시그널 {len(signals)}\n")
strategies = [
('현재전략: TP 2% + Trail Stop', 'tp_trail', None),
('2봉 이익시 피크청산, 아니면 TP/Trail', 'peak', 2 ),
('3봉 이익시 피크청산, 아니면 TP/Trail', 'peak', 3 ),
('5봉 이익시 피크청산, 아니면 TP/Trail', 'peak', 5 ),
]
results = {}
for label, mode, param in strategies:
sim = []
for s in signals:
if mode == 'tp_trail':
r = sim_tp_trail(s['bars'], s['entry_price'], s['atr_raw'])
else:
r = sim_peak_then_trail(s['bars'], s['entry_price'], s['atr_raw'], param)
sim.append({**s, **r})
taken, _ = pos_limit(sim)
results[label] = taken
# ── 요약표 ──────────────────────────────────────────────────────────────────
print(f"{''*105}")
print(f" {'전략':35s} {'거래':>3s} {'승률':>4s} {'합산손익':>12s} {'수익률':>5s} "
f"{'평균보유':>5s} {'평균수익':>6s} {'평균손실':>6s}")
print(f"{''*105}")
for label, mode, param in strategies:
taken = results[label]
s = stats(taken)
if not s:
continue
print(f" {label:35s} {s['n']:>3d}{s['wr']:>4.0f}% {s['total']:>+12,.0f}"
f"{s['ret']:>+5.2f}% {s['avg_h']:>5.1f}"
f"{s['avg_w']:>+5.2f}% {s['avg_l']:>+5.2f}%")
print(f"{''*105}")
# ── 3봉 피크청산 상세 ────────────────────────────────────────────────────────
label_3 = '3봉 이익시 피크청산, 아니면 TP/Trail'
print(f"\n[{label_3} 건별 상세]")
print(f"{''*100}")
for i, r in enumerate(results[label_3], 1):
krw = PER_POS * (r['pnl'] / 100) - PER_POS * FEE * 2
sign = '' if r['pnl'] > 0 else ''
print(f" #{i:02d} {r['ticker']:12s}[{sign}] {str(r['entry_ts'])[:16]} "
f"진입 {r['entry_price']:>10,.0f}"
f"고가청산 {r['exit_price']:>10,.0f}"
f"{r['pnl']:>+.2f}% ({krw:>+,.0f}원)")
conn.close()
if __name__ == '__main__':
main()

View File

@@ -0,0 +1,363 @@
"""DB 기반 하이브리드 시뮬레이션 (신호=10분봉 / 스탑=1분봉).
실행 흐름:
1. pyupbit에서 10분봉(300봉) + 1분봉(2880봉) fetch → BACKTEST_OHLCV upsert
2. BACKTEST_OHLCV에서 데이터 로드
3. 하이브리드 시뮬 실행
- 10분봉 타임인덱스로 신호 감지 / 진입
- 각 10분봉 구간 내 1분봉으로 트레일링스탑 체크
4. 결과 출력
"""
import sys
import os
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from dotenv import load_dotenv
load_dotenv(os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), '.env'))
import time
import pyupbit
import pandas as pd
import numpy as np
import oracledb
# ── Oracle 연결 ─────────────────────────────────────────────────────────────
def _get_conn():
kwargs = dict(
user=os.environ["ORACLE_USER"],
password=os.environ["ORACLE_PASSWORD"],
dsn=os.environ["ORACLE_DSN"],
)
wallet = os.environ.get("ORACLE_WALLET")
if wallet:
kwargs["config_dir"] = wallet
return oracledb.connect(**kwargs)
# ── 전략 파라미터 ────────────────────────────────────────────────────────────
LOCAL_VOL_N = 28
QUIET_N = 12
QUIET_PCT = 2.0
THRESH = 4.8
SIGNAL_TO = 48
VOL_THRESH = 6.0
FNG = 14
ATR_N = 28
ATR_MULT = 1.5
ATR_MIN = 0.010
ATR_MAX = 0.020
TS_N = 48
TIME_STOP_PCT = 3.0
BUDGET = 15_000_000
MAX_POS = 3
FEE = 0.0005
PER_POS = BUDGET // MAX_POS
TICKERS = [
'KRW-XRP','KRW-BTC','KRW-ETH','KRW-SOL','KRW-DOGE',
'KRW-ADA','KRW-SUI','KRW-NEAR','KRW-KAVA','KRW-SXP',
'KRW-AKT','KRW-SONIC','KRW-IP','KRW-ORBS','KRW-VIRTUAL',
'KRW-BARD','KRW-XPL','KRW-KITE','KRW-ENSO','KRW-0G',
]
SIM_START = '2026-03-03 00:00:00'
SIM_END = '2026-03-04 13:30:00'
# ── BACKTEST_OHLCV 공통 함수 ─────────────────────────────────────────────────
def upsert_ohlcv(conn, ticker: str, interval_cd: str, df: pd.DataFrame) -> int:
"""DataFrame을 BACKTEST_OHLCV에 저장 (기존 TS 스킵). 반환: 신규 삽입 건수."""
cur = conn.cursor()
min_ts = df.index.min().to_pydatetime()
cur.execute(
"SELECT ts FROM backtest_ohlcv WHERE ticker=:t AND interval_cd=:iv AND ts >= :since",
{"t": ticker, "iv": interval_cd, "since": min_ts}
)
existing = {r[0] for r in cur.fetchall()}
new_rows = [
(ticker, interval_cd, ts_idx.to_pydatetime(),
float(row["open"]), float(row["high"]), float(row["low"]),
float(row["close"]), float(row["volume"]))
for ts_idx, row in df.iterrows()
if ts_idx.to_pydatetime() not in existing
]
if not new_rows:
return 0
cur.executemany(
"INSERT INTO backtest_ohlcv (ticker, interval_cd, ts, open_p, high_p, low_p, close_p, volume_p) "
"VALUES (:1, :2, :3, :4, :5, :6, :7, :8)",
new_rows
)
conn.commit()
return len(new_rows)
def load_from_db(conn, ticker: str, interval_cd: str, since: str):
"""BACKTEST_OHLCV에서 ticker의 OHLCV 로드."""
cur = conn.cursor()
cur.execute(
"SELECT ts, open_p, high_p, low_p, close_p, volume_p "
"FROM backtest_ohlcv "
"WHERE ticker=:ticker AND interval_cd=:iv "
"AND ts >= TO_TIMESTAMP(:since, 'YYYY-MM-DD HH24:MI:SS') "
"ORDER BY ts",
{"ticker": ticker, "iv": interval_cd, "since": since}
)
rows = cur.fetchall()
if not rows:
return None
df = pd.DataFrame(rows, columns=["ts","open","high","low","close","volume"])
df["ts"] = pd.to_datetime(df["ts"])
df.set_index("ts", inplace=True)
return df.astype(float)
# ── 1단계: DB 업데이트 ───────────────────────────────────────────────────────
def refresh_db(conn) -> None:
"""10분봉(300봉) + 1분봉(2880봉) fetch → BACKTEST_OHLCV upsert."""
print("BACKTEST_OHLCV 업데이트 중...", flush=True)
total10, total1 = 0, 0
for tk in TICKERS:
try:
# 10분봉
df10 = pyupbit.get_ohlcv(tk, interval='minute10', count=300)
if df10 is not None and len(df10) >= 10:
total10 += upsert_ohlcv(conn, tk, 'minute10', df10)
time.sleep(0.12)
# 1분봉 (2일치 ≈ 2880봉, 내부 자동 페이지네이션)
df1 = pyupbit.get_ohlcv(tk, interval='minute1', count=2880)
if df1 is not None and len(df1) >= 60:
total1 += upsert_ohlcv(conn, tk, 'minute1', df1)
time.sleep(0.15)
print(f" {tk}: 10m={len(df10) if df10 is not None else 0}봉 / "
f"1m={len(df1) if df1 is not None else 0}", flush=True)
except Exception as e:
print(f" {tk} 오류: {e}")
print(f"DB 업데이트 완료: 10분봉 신규 {total10}행 / 1분봉 신규 {total1}\n", flush=True)
# ── ATR 계산 ────────────────────────────────────────────────────────────────
def calc_atr(df, i, n=28):
start = max(0, i - n)
sub = df.iloc[start:i]
if len(sub) < 5:
return ATR_MIN
hi = sub['high'].values
lo = sub['low'].values
cl = sub['close'].values
tr = [max(hi[j]-lo[j], abs(hi[j]-cl[j-1]) if j>0 else 0,
abs(lo[j]-cl[j-1]) if j>0 else 0) for j in range(len(sub))]
atr_pct = np.mean(tr) / cl[-1] if cl[-1] > 0 else ATR_MIN
return max(ATR_MIN, min(ATR_MAX, atr_pct * ATR_MULT))
# ── 하이브리드 시뮬 ──────────────────────────────────────────────────────────
def run_sim(raw10: dict, raw1: dict) -> None:
"""신호=10분봉 / 스탑=1분봉 하이브리드 시뮬."""
# 10분봉 공통 타임인덱스
all_idx10 = None
for df in raw10.values():
all_idx10 = df.index if all_idx10 is None else all_idx10.union(df.index)
all_idx10 = all_idx10.sort_values()
mask = (all_idx10 >= SIM_START) & (all_idx10 <= SIM_END)
sim_idx10 = all_idx10[mask]
if len(sim_idx10) == 0:
print("시뮬 구간 데이터 없음")
return
print(f"시뮬 구간(10분봉): {sim_idx10[0]} ~ {sim_idx10[-1]} ({len(sim_idx10)}봉)")
# 1분봉 커버리지 확인
n1m = sum(
len(df[(df.index >= SIM_START) & (df.index <= SIM_END)])
for df in raw1.values() if len(raw1) > 0
)
print(f"1분봉 총 {n1m}봉 (스탑 체크용)\n")
positions = {} # ticker → {buy_price, peak, entry_i, invested, atr_stop}
signals = {} # ticker → {price, vol_r, sig_i}
trades = []
for i, ts10 in enumerate(sim_idx10):
ts10_prev = sim_idx10[i - 1] if i > 0 else ts10
# ── 1) 1분봉으로 스탑 체크 ────────────────────────────────────────
for tk in list(positions.keys()):
pos = positions[tk]
# 진입 캔들 종료 전엔 체크 금지
# 10분봉 ts=X 는 [X, X+10min) 구간이므로 실제 진입은 X+9:59
# → X+10min 이후 1분봉부터 체크
entry_candle_end = sim_idx10[pos['entry_i']] + pd.Timedelta(minutes=10)
if tk not in raw1:
# 1분봉 없으면 10분봉 종가로 fallback (단, 진입 다음 봉부터)
if ts10 <= sim_idx10[pos['entry_i']]:
continue
if tk not in raw10 or ts10 not in raw10[tk].index:
continue
current = float(raw10[tk].loc[ts10, 'close'])
_check_stop(pos, tk, current, ts10, i, sim_idx10, trades, positions)
continue
df1 = raw1[tk]
# 진입 캔들 종료(entry_candle_end) 이후 + 현재 10분봉 구간 이내
mask1 = (df1.index >= entry_candle_end) & (df1.index > ts10_prev) & (df1.index <= ts10)
sub1 = df1[mask1]
for ts1m, row1m in sub1.iterrows():
current = float(row1m['close'])
if _check_stop(pos, tk, current, ts1m, i, sim_idx10, trades, positions):
break # 이미 청산됨
# ── 2) 신호 만료 ────────────────────────────────────────────────
for tk in list(signals.keys()):
if i - signals[tk]['sig_i'] > SIGNAL_TO:
del signals[tk]
# ── 3) 신호 감지 + 진입 (10분봉 기준) ──────────────────────────
for tk in TICKERS:
if tk in positions: continue
if len(positions) >= MAX_POS: break
if tk not in raw10: continue
df10 = raw10[tk]
if ts10 not in df10.index: continue
loc = df10.index.get_loc(ts10)
if loc < LOCAL_VOL_N + QUIET_N + 2: continue
vol_prev = float(df10['volume'].iloc[loc - 1])
vol_avg = float(df10['volume'].iloc[loc - LOCAL_VOL_N - 1:loc - 1].mean())
vol_r = vol_prev / vol_avg if vol_avg > 0 else 0.0
current = float(df10['close'].iloc[loc])
close_qn = float(df10['close'].iloc[loc - QUIET_N])
chg = abs(current - close_qn) / close_qn * 100 if close_qn > 0 else 999.0
if vol_r >= VOL_THRESH and chg < QUIET_PCT:
if tk not in signals or vol_r > signals[tk]['vol_r']:
signals[tk] = {'price': current, 'vol_r': vol_r, 'sig_i': i}
if tk in signals:
sig_p = signals[tk]['price']
move = (current - sig_p) / sig_p * 100
if move >= THRESH:
atr_stop = calc_atr(df10, loc, ATR_N)
positions[tk] = {
'buy_price': current, 'peak': current,
'entry_i': i, 'invested': PER_POS,
'atr_stop': atr_stop,
}
del signals[tk]
# ── 미청산 강제 청산 ─────────────────────────────────────────────────────
last_ts10 = sim_idx10[-1]
for tk, pos in list(positions.items()):
if tk not in raw10: continue
df10 = raw10[tk]
current = float(df10.loc[last_ts10, 'close']) if last_ts10 in df10.index else pos['buy_price']
pnl = (current - pos['buy_price']) / pos['buy_price'] * 100
fee = pos['invested'] * FEE + current * (pos['invested']/pos['buy_price']) * FEE
krw = current * (pos['invested']/pos['buy_price']) - pos['invested'] - fee
trades.append({
'date': last_ts10.date(), 'ticker': tk,
'buy_price': pos['buy_price'], 'sell_price': current,
'pnl_pct': pnl, 'krw': krw, 'reason': '미청산(현재가)',
'entry_ts': sim_idx10[pos['entry_i']], 'exit_ts': last_ts10,
})
# ── 결과 출력 ────────────────────────────────────────────────────────────
if not trades:
print("거래 없음")
return
import collections
by_date = collections.defaultdict(list)
for t in trades:
by_date[t['date']].append(t)
total_krw, total_wins = 0, 0
for date in sorted(by_date.keys()):
day_trades = by_date[date]
day_krw = sum(t['krw'] for t in day_trades)
day_wins = sum(1 for t in day_trades if t['pnl_pct'] > 0)
total_krw += day_krw
total_wins += day_wins
print(f"\n{'='*62}")
print(f"{date}{len(day_trades)}건 | 승률={day_wins/len(day_trades)*100:.0f}% | 일손익={day_krw:+,.0f}")
print(f"{'='*62}")
for t in sorted(day_trades, key=lambda x: x['entry_ts']):
e = '' if t['pnl_pct'] > 0 else ''
print(f" {e} {t['ticker']:12s} "
f"매수={t['buy_price']:,.0f} @ {str(t['entry_ts'])[5:16]}"
f"매도={t['sell_price']:,.0f} @ {str(t['exit_ts'])[5:16]} "
f"| {t['pnl_pct']:+.1f}% ({t['krw']:+,.0f}원) [{t['reason']}]")
print(f"\n{'='*62}")
print(f"【2일 합계】 {len(trades)}건 | "
f"승률={total_wins/len(trades)*100:.0f}% | 총손익={total_krw:+,.0f}")
print(f" [1분봉 스탑 시뮬] VOL≥{VOL_THRESH}x + 횡보<{QUIET_PCT}% → +{THRESH}% 진입 (F&G={FNG})")
def _check_stop(pos, tk, current, ts, i, sim_idx10, trades, positions) -> bool:
"""스탑 체크. 청산 시 True 반환."""
if current > pos['peak']:
pos['peak'] = current
age = i - pos['entry_i']
pnl = (current - pos['buy_price']) / pos['buy_price'] * 100
peak_drop = (pos['peak'] - current) / pos['peak'] * 100
atr_stop = pos['atr_stop']
reason = None
if peak_drop >= atr_stop * 100:
reason = f"트레일링스탑({atr_stop*100:.1f}%)"
elif age >= TS_N and pnl < TIME_STOP_PCT:
reason = f"타임스탑(+{pnl:.1f}%<{TIME_STOP_PCT}%)"
if reason:
fee = pos['invested'] * FEE + current * (pos['invested']/pos['buy_price']) * FEE
krw = current * (pos['invested']/pos['buy_price']) - pos['invested'] - fee
trades.append({
'date': ts.date() if hasattr(ts, 'date') else ts,
'ticker': tk,
'buy_price': pos['buy_price'], 'sell_price': current,
'pnl_pct': pnl, 'krw': krw, 'reason': reason,
'entry_ts': sim_idx10[pos['entry_i']], 'exit_ts': ts,
})
del positions[tk]
return True
return False
# ── main ─────────────────────────────────────────────────────────────────────
conn = _get_conn()
# 1) DB 업데이트
refresh_db(conn)
# 2) DB에서 데이터 로드
print("DB에서 OHLCV 로드 중...", flush=True)
LOAD_SINCE = '2026-03-01 00:00:00'
raw10, raw1 = {}, {}
for tk in TICKERS:
df10 = load_from_db(conn, tk, 'minute10', LOAD_SINCE)
if df10 is not None and len(df10) > LOCAL_VOL_N + QUIET_N:
raw10[tk] = df10
df1 = load_from_db(conn, tk, 'minute1', LOAD_SINCE)
if df1 is not None and len(df1) > 60:
raw1[tk] = df1
print(f"로드 완료: 10분봉 {len(raw10)}종목 / 1분봉 {len(raw1)}종목\n")
conn.close()
# 3) 시뮬 실행
print("="*62)
print(f"하이브리드 시뮬 (신호=10분봉 / 스탑=1분봉) | 2026-03-03 ~ 03-04")
print("="*62)
run_sim(raw10, raw1)

View File

@@ -0,0 +1,474 @@
"""레짐 기반 1년 시뮬레이션 — BULL 진입 vs Bear차단 vs 필터없음.
sim_45m40.py 검증된 코어 로직 기반.
데이터: data/sim1y_cache.pkl (10분봉 1년치)
data/fng_1y.json (F&G 1년치)
비교 구성:
1. 필터 없음 — 레짐/F&G 무관 진입
2. BEAR 차단 — 레짐 score < -0.5% 이면 차단 (현재 전략)
3. BULL 진입만 — 레짐 score ≥ 1.5% 일 때만 진입 ← 사용자 제안
4. BULL 진입 + F&G≥41 — BULL 조건에 F&G 필터 추가
"""
import os as _os, sys as _sys
_sys.path.insert(0, _os.path.dirname(_os.path.dirname(_os.path.abspath(__file__))))
import json
import pickle
from pathlib import Path
import pandas as pd
from dotenv import load_dotenv
load_dotenv(dotenv_path=Path(__file__).parent.parent / ".env")
# ── 데이터 경로 ───────────────────────────────────────────
CACHE_FILE = Path(__file__).parent.parent / "data" / "sim1y_cache.pkl"
FNG_FILE = Path(__file__).parent.parent / "data" / "fng_1y.json"
TOP_N = 20
# ── 전략 파라미터 (sim_45m40.py 동일) ────────────────────
BUDGET = 15_000_000
MIN_BUDGET = BUDGET * 3 // 10
MAX_POS = 3
FEE = 0.0005
TIME_STOP_MIN_PCT = 3.0
ATR_MULT = 1.5
ATR_MIN = 0.010
ATR_MAX = 0.020
VOL_MULT_DEFAULT = 2.0 # 기본 (NEUTRAL / 필터없음)
VOL_MULT_BULL = 1.5 # BULL 레짐 완화
QUIET_PCT = 2.0
THRESH = 4.8 # sim_45m40.py 기준값
# 40분봉 봉수 환산
LOCAL_VOL_N = 7 # 5h
QUIET_N = 3 # 2h
SIGNAL_TO_N = 12 # 8h
ATR_N = 7
TS_N = 12 # 8h (타임스탑)
REGIME_N = 3 # 2h (레짐 추세)
# 레짐 임계
BEAR_THRESHOLD = -0.5
BULL_THRESHOLD = 1.5
# 레짐 계산 가중치
REGIME_WEIGHTS = {"KRW-BTC": 0.40, "KRW-ETH": 0.30,
"KRW-SOL": 0.15, "KRW-XRP": 0.15}
# WF 파라미터
WF_WINDOW = 4
WF_MIN_WIN_RATE = 0.01
WF_SHADOW_WINS = 2
# ── 리샘플링 ─────────────────────────────────────────────
def resample_40m(df: pd.DataFrame) -> pd.DataFrame:
return (
df.resample("40min")
.agg({"open": "first", "high": "max", "low": "min",
"close": "last", "volume": "sum"})
.dropna(subset=["close"])
)
# ── 레짐 시리즈 ──────────────────────────────────────────
def build_regime_series(dfs40: dict) -> pd.Series:
weighted = None
for ticker, w in REGIME_WEIGHTS.items():
if ticker not in dfs40:
continue
pct = dfs40[ticker]["close"].pct_change(REGIME_N) * 100
weighted = pct * w if weighted is None else weighted.add(pct * w, fill_value=0.0)
return weighted if weighted is not None else pd.Series(dtype=float)
# ── ATR ──────────────────────────────────────────────────
def calc_atr(df: pd.DataFrame, buy_idx: int) -> float:
sub = df.iloc[max(0, buy_idx - ATR_N - 1):buy_idx]
if len(sub) < 3:
return ATR_MIN
try:
avg = ((sub["high"] - sub["low"]) / sub["low"]).iloc[-ATR_N:].mean()
return float(max(ATR_MIN, min(ATR_MAX, avg * ATR_MULT)))
except Exception:
return ATR_MIN
# ── 포지션 시뮬 ──────────────────────────────────────────
def simulate_pos(df: pd.DataFrame, buy_idx: int,
buy_price: float, stop_pct: float):
peak = buy_price
for i in range(buy_idx + 1, len(df)):
row = df.iloc[i]
ts = df.index[i]
if row["high"] > peak:
peak = row["high"]
if row["low"] <= peak * (1 - stop_pct):
sp = peak * (1 - stop_pct)
pnl = (sp * (1 - FEE) - buy_price * (1 + FEE)) / (buy_price * (1 + FEE)) * 100
return pnl > 0, ts, pnl
pnl_now = (row["close"] - buy_price) / buy_price * 100
if (i - buy_idx) >= TS_N and pnl_now < TIME_STOP_MIN_PCT:
pnl = (row["close"] * (1 - FEE) - buy_price * (1 + FEE)) / (buy_price * (1 + FEE)) * 100
return pnl > 0, ts, pnl
last = df.iloc[-1]["close"]
pnl = (last * (1 - FEE) - buy_price * (1 + FEE)) / (buy_price * (1 + FEE)) * 100
return pnl > 0, df.index[-1], pnl
# ── 전략 실행 ─────────────────────────────────────────────
def run_strategy(df: pd.DataFrame, ticker: str,
regime_series: pd.Series, fng_map: dict,
mode: str) -> list:
"""
mode:
'none' — 레짐/F&G 필터 없음
'bear_off' — BEAR 차단만 (score < BEAR_THRESHOLD 시 스킵)
'bull_only'— BULL 진입만 (score >= BULL_THRESHOLD 일 때만)
'bull_fng' — BULL + F&G≥41
"""
trades = []
sig_i = sig_p = None
in_pos = False
buy_idx = buy_price = stop_pct = None
i = max(LOCAL_VOL_N + 2, QUIET_N + 1)
while i < len(df):
ts = df.index[i]
row = df.iloc[i]
cur = row["close"]
# ── 포지션 보유 중 ────────────────────────────────
if in_pos:
is_win, sdt, pnl = simulate_pos(df, buy_idx, buy_price, stop_pct)
next_i = next((j for j in range(i, len(df)) if df.index[j] > sdt), len(df))
trades.append((is_win, pnl, df.index[buy_idx], sdt, ticker))
in_pos = False
sig_i = sig_p = None
i = next_i
continue
# ── 레짐 스코어 계산 ─────────────────────────────
score = 0.0
if not regime_series.empty and ts in regime_series.index:
v = regime_series.loc[ts]
score = float(v) if not pd.isna(v) else 0.0
# ── 모드별 진입 필터 ─────────────────────────────
if mode == "bear_off":
if score < BEAR_THRESHOLD:
sig_i = sig_p = None
i += 1
continue
vol_mult = VOL_MULT_BULL if score >= BULL_THRESHOLD else VOL_MULT_DEFAULT
elif mode == "bull_only":
if score < BULL_THRESHOLD:
sig_i = sig_p = None
i += 1
continue
vol_mult = VOL_MULT_BULL
elif mode == "bull_fng":
if score < BULL_THRESHOLD:
sig_i = sig_p = None
i += 1
continue
date_str = ts.strftime("%Y-%m-%d")
fv = fng_map.get(date_str, 50) if fng_map else 50
if fv < 41:
sig_i = sig_p = None
i += 1
continue
vol_mult = VOL_MULT_BULL
else: # 'none'
vol_mult = VOL_MULT_DEFAULT
# ── 신호 타임아웃 ─────────────────────────────────
if sig_i is not None and (i - sig_i) > SIGNAL_TO_N:
sig_i = sig_p = None
# ── 신호 있음: 진입 체크 ──────────────────────────
if sig_i is not None:
move_pct = (cur - sig_p) / sig_p * 100
if cur < sig_p:
sig_i = sig_p = None
elif move_pct >= THRESH:
in_pos = True
buy_idx = i
buy_price = cur
stop_pct = calc_atr(df, i)
sig_i = sig_p = None
i += 1
continue
# ── 신호 없음: 축적 조건 체크 ────────────────────
vol_p = df.iloc[i - 1]["volume"]
vol_avg = df.iloc[i - 1 - LOCAL_VOL_N:i - 1]["volume"].mean()
vol_r = vol_p / vol_avg if vol_avg > 0 else 0
close_qh = df.iloc[i - QUIET_N]["close"]
chg_qh = abs(cur - close_qh) / close_qh * 100 if close_qh > 0 else 999
quiet = chg_qh < QUIET_PCT
spike = vol_r >= vol_mult
if quiet and spike:
if sig_i is None:
sig_i = i
sig_p = cur
else:
if sig_i is not None and cur < sig_p:
sig_i = sig_p = None
i += 1
return trades
# ── WF 필터 ──────────────────────────────────────────────
def apply_wf(trades: list) -> tuple:
history = []
shadow_streak = 0
blocked = False
accepted = []
blocked_cnt = 0
for trade in trades:
is_win = int(trade[0])
if not blocked:
accepted.append(trade)
history.append(is_win)
if len(history) >= WF_WINDOW:
wr = sum(history[-WF_WINDOW:]) / WF_WINDOW
if wr < WF_MIN_WIN_RATE:
blocked = True
shadow_streak = 0
else:
blocked_cnt += 1
if is_win:
shadow_streak += 1
if shadow_streak >= WF_SHADOW_WINS:
blocked = False
history = []
shadow_streak = 0
else:
shadow_streak = 0
return accepted, blocked_cnt
# ── MAX_POSITIONS ────────────────────────────────────────
def apply_max_positions(all_trades: list) -> tuple:
open_exits, accepted, skipped = [], [], []
for trade in all_trades:
buy_dt, sell_dt = trade[2], trade[3]
open_exits = [s for s in open_exits if s > buy_dt]
if len(open_exits) < MAX_POS:
open_exits.append(sell_dt)
accepted.append(trade)
else:
skipped.append(trade)
return accepted, skipped
# ── 복리 시뮬 ────────────────────────────────────────────
def run_compound(accepted: list) -> dict:
portfolio = float(BUDGET)
total_krw = 0.0
monthly = {}
trade_log = []
for is_win, pnl, buy_dt, sell_dt, ticker in accepted:
pos_size = max(portfolio, MIN_BUDGET) / MAX_POS
krw_profit = pos_size * pnl / 100
portfolio = max(portfolio + krw_profit, MIN_BUDGET)
total_krw += krw_profit
ym = buy_dt.strftime("%Y-%m")
if ym not in monthly:
monthly[ym] = {"trades": 0, "wins": 0, "pnl_krw": 0.0}
monthly[ym]["trades"] += 1
monthly[ym]["wins"] += int(is_win)
monthly[ym]["pnl_krw"] += krw_profit
trade_log.append({"buy_dt": buy_dt, "sell_dt": sell_dt,
"ticker": ticker, "is_win": is_win,
"pnl_pct": pnl, "portfolio": portfolio})
wins = sum(1 for t in accepted if t[0])
return {
"portfolio": portfolio,
"total_krw": total_krw,
"roi_pct": (portfolio - BUDGET) / BUDGET * 100,
"total": len(accepted),
"wins": wins,
"wr": wins / len(accepted) * 100 if accepted else 0,
"monthly": monthly,
"trade_log": trade_log,
}
# ── 결과 출력 ────────────────────────────────────────────
def print_result(label: str, result: dict, skipped: int, wf_blocked: int):
r = result
peak = BUDGET
max_dd = 0.0
for t in r["trade_log"]:
peak = max(peak, t["portfolio"])
dd = (peak - t["portfolio"]) / peak * 100
max_dd = max(max_dd, dd)
monthly_krw = [m["pnl_krw"] for m in r["monthly"].values()]
avg_m = sum(monthly_krw) / len(monthly_krw) if monthly_krw else 0
total = r["total"]
wins = r["wins"]
print(f"\n{''*60}")
print(f" [{label}]")
print(f" 진입: {total}건 (WF차단: {wf_blocked} / MAX_POS스킵: {skipped})")
if total:
print(f" 승패: {wins}{total-wins}패 (승률 {r['wr']:.1f}%)")
print(f" 초기 예산: {BUDGET:>15,}")
print(f" 최종 자산: {r['portfolio']:>15,.0f}")
print(f" 순수익: {r['total_krw']:>+15,.0f}")
print(f" 수익률: {r['roi_pct']:>+14.2f}%")
print(f" 최대 낙폭: {-max_dd:>+14.2f}%")
print(f" 월평균 수익: {avg_m:>+13,.0f}")
def print_monthly(result: dict):
print(f" {'':^8}{'거래':>4} {'승률':>5}{'월수익(KRW)':>13} {'누적수익(KRW)':>14}")
cum = 0.0
for ym, m in sorted(result["monthly"].items()):
wr = m["wins"] / m["trades"] * 100 if m["trades"] else 0
cum += m["pnl_krw"]
print(f" {ym:^8}{m['trades']:>4}{wr:>4.0f}% │"
f" {m['pnl_krw']:>+13,.0f}{cum:>+14,.0f}")
# ── 메인 ─────────────────────────────────────────────────
def main():
# ── 데이터 로드 ───────────────────────────────────────
if not CACHE_FILE.exists():
print(f"[오류] 캐시 없음: {CACHE_FILE}")
print(" 먼저 tests/collect_1y_data.py 를 실행하세요.")
return
print("캐시 로드 중...")
cache = pickle.load(open(CACHE_FILE, "rb"))
all_tickers = list(cache["10m"].keys())[:TOP_N]
tickers = [t for t in all_tickers if len(cache["10m"][t]) > 500]
print(f" 유효 종목: {len(tickers)}")
# F&G 로드
fng_map: dict = {}
if FNG_FILE.exists():
fng_map = json.load(open(FNG_FILE))
fng_dates = sorted(fng_map.keys())
print(f" F&G: {fng_dates[0]} ~ {fng_dates[-1]} ({len(fng_map)}일)")
else:
print(" [경고] F&G 데이터 없음")
# 리샘플링
dfs40 = {t: resample_40m(cache["10m"][t]) for t in tickers}
# 레짐 시리즈
regime_series = build_regime_series(dfs40)
# 기간 정보
sample = next(iter(dfs40.values()))
start_dt = sample.index[0].strftime("%Y-%m-%d")
end_dt = sample.index[-1].strftime("%Y-%m-%d")
# 레짐 분포 계산
if not regime_series.empty:
valid = regime_series.dropna()
bull_cnt = (valid >= BULL_THRESHOLD).sum()
bear_cnt = (valid < BEAR_THRESHOLD).sum()
neut_cnt = len(valid) - bull_cnt - bear_cnt
total_cnt = len(valid)
print(f"\n 레짐 분포 ({total_cnt}봉 기준):")
print(f" BULL (≥{BULL_THRESHOLD}%) : {bull_cnt:>6}봉 ({bull_cnt/total_cnt*100:.1f}%)")
print(f" NEUTRAL : {neut_cnt:>6}봉 ({neut_cnt/total_cnt*100:.1f}%)")
print(f" BEAR (<{BEAR_THRESHOLD}%) : {bear_cnt:>6}봉 ({bear_cnt/total_cnt*100:.1f}%)")
# F&G 분포 (해당 기간)
if fng_map:
period_fng = {k: v for k, v in fng_map.items() if start_dt <= k <= end_dt}
zones = {"극공포(≤25)": 0, "공포(26~40)": 0, "중립+(≥41)": 0}
for v in period_fng.values():
if v <= 25: zones["극공포(≤25)"] += 1
elif v <= 40: zones["공포(26~40)"] += 1
else: zones["중립+(≥41)"] += 1
tot = sum(zones.values())
print(f"\n F&G 분포 (동 기간 {tot}일):")
for name, cnt in zones.items():
print(f" {name:12} {cnt:>3}일 ({cnt/tot*100:.1f}%)")
print(f"\n{'='*60}")
print(f" 레짐 BULL 진입 시뮬 | 1년 | {len(tickers)}종목 | 40분봉")
print(f" 기간: {start_dt} ~ {end_dt}")
print(f"{'='*60}")
# ── 4가지 시뮬 실행 ───────────────────────────────────
CONFIGS = [
("none", "필터 없음"),
("bear_off", "BEAR 차단 (현재)"),
("bull_only","BULL 진입만"),
("bull_fng", "BULL + F&G≥41"),
]
results = {}
for mode, label in CONFIGS:
all_trades = []
wf_total = 0
for ticker in tickers:
df40 = dfs40[ticker]
raw = run_strategy(df40, ticker, regime_series, fng_map, mode)
filtered, blocked = apply_wf(raw)
wf_total += blocked
all_trades.extend(filtered)
all_trades.sort(key=lambda x: x[2])
accepted, skipped = apply_max_positions(all_trades)
result = run_compound(accepted)
results[label] = result
print_result(label, result, len(skipped), wf_total)
# ── 요약 비교 ─────────────────────────────────────────
print(f"\n{'='*60}")
print(f" 요약 비교")
print(f"{'='*60}")
print(f" {'구성':<22} {'진입':>5} {'승률':>6} {'수익률':>8} {'순수익(KRW)':>14} {'낙폭':>7}")
print(f" {''*58}")
for mode, label in CONFIGS:
r = results[label]
total = r["total"]
if total == 0:
print(f" {label:<22} {'진입없음':>34}")
continue
peak = BUDGET
max_dd = 0.0
for t in r["trade_log"]:
peak = max(peak, t["portfolio"])
dd = (peak - t["portfolio"]) / peak * 100
max_dd = max(max_dd, dd)
print(
f" {label:<22} {total:>5}{r['wr']:>5.1f}% "
f"{r['roi_pct']:>+7.2f}% {r['total_krw']:>+13,.0f}원 -{max_dd:.1f}%"
)
# ── 월별 상세 (BULL 진입만) ───────────────────────────
print(f"\n{'='*60}")
print(f" 월별 상세 — BULL 진입만")
print(f"{'='*60}")
print_monthly(results["BULL 진입만"])
print(f"\n 월별 상세 — BEAR 차단 (현재)")
print_monthly(results["BEAR 차단 (현재)"])
print(f"{'='*60}")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,322 @@
"""레짐 REGIME_N 스윕 — BULL 진입 기준 봉수 최적화.
REGIME_N (pct_change 봉수) 를 1~8봉(40분~320분) 으로 변화시키며
BULL 진입만 / BEAR 차단 / 필터없음 비교.
데이터: data/sim1y_cache.pkl (10분봉 1년치)
"""
import os as _os, sys as _sys
_sys.path.insert(0, _os.path.dirname(_os.path.dirname(_os.path.abspath(__file__))))
import json
import pickle
from pathlib import Path
import pandas as pd
from dotenv import load_dotenv
load_dotenv(dotenv_path=Path(__file__).parent.parent / ".env")
CACHE_FILE = Path(__file__).parent.parent / "data" / "sim1y_cache.pkl"
FNG_FILE = Path(__file__).parent.parent / "data" / "fng_1y.json"
TOP_N = 20
BUDGET = 15_000_000
MIN_BUDGET = BUDGET * 3 // 10
MAX_POS = 3
FEE = 0.0005
TIME_STOP_MIN_PCT = 3.0
ATR_MULT = 1.5
ATR_MIN = 0.010
ATR_MAX = 0.020
VOL_MULT_DEFAULT = 2.0
VOL_MULT_BULL = 1.5
QUIET_PCT = 2.0
THRESH = 4.8
LOCAL_VOL_N = 7
QUIET_N = 3
SIGNAL_TO_N = 12
ATR_N = 7
TS_N = 12
BEAR_THRESHOLD = -0.5
BULL_THRESHOLD = 1.5
WF_WINDOW = 4
WF_MIN_WIN_RATE = 0.01
WF_SHADOW_WINS = 2
REGIME_WEIGHTS = {"KRW-BTC": 0.40, "KRW-ETH": 0.30,
"KRW-SOL": 0.15, "KRW-XRP": 0.15}
def resample_40m(df):
return (df.resample("40min")
.agg({"open":"first","high":"max","low":"min",
"close":"last","volume":"sum"})
.dropna(subset=["close"]))
def build_regime_series(dfs40, regime_n):
weighted = None
for ticker, w in REGIME_WEIGHTS.items():
if ticker not in dfs40:
continue
pct = dfs40[ticker]["close"].pct_change(regime_n) * 100
weighted = pct * w if weighted is None else weighted.add(pct * w, fill_value=0.0)
return weighted if weighted is not None else pd.Series(dtype=float)
def calc_atr(df, buy_idx):
sub = df.iloc[max(0, buy_idx - ATR_N - 1):buy_idx]
if len(sub) < 3:
return ATR_MIN
try:
avg = ((sub["high"] - sub["low"]) / sub["low"]).iloc[-ATR_N:].mean()
return float(max(ATR_MIN, min(ATR_MAX, avg * ATR_MULT)))
except Exception:
return ATR_MIN
def simulate_pos(df, buy_idx, buy_price, stop_pct):
peak = buy_price
for i in range(buy_idx + 1, len(df)):
row = df.iloc[i]
if row["high"] > peak:
peak = row["high"]
if row["low"] <= peak * (1 - stop_pct):
sp = peak * (1 - stop_pct)
pnl = (sp*(1-FEE) - buy_price*(1+FEE)) / (buy_price*(1+FEE)) * 100
return pnl > 0, df.index[i], pnl
pnl_now = (row["close"] - buy_price) / buy_price * 100
if (i - buy_idx) >= TS_N and pnl_now < TIME_STOP_MIN_PCT:
pnl = (row["close"]*(1-FEE) - buy_price*(1+FEE)) / (buy_price*(1+FEE)) * 100
return pnl > 0, df.index[i], pnl
last = df.iloc[-1]["close"]
pnl = (last*(1-FEE) - buy_price*(1+FEE)) / (buy_price*(1+FEE)) * 100
return pnl > 0, df.index[-1], pnl
def run_strategy(df, ticker, regime_series, mode):
trades = []
sig_i = sig_p = None
in_pos = False
buy_idx = buy_price = stop_pct = None
i = max(LOCAL_VOL_N + 2, QUIET_N + 1)
while i < len(df):
ts = df.index[i]
row = df.iloc[i]
cur = row["close"]
if in_pos:
is_win, sdt, pnl = simulate_pos(df, buy_idx, buy_price, stop_pct)
next_i = next((j for j in range(i, len(df)) if df.index[j] > sdt), len(df))
trades.append((is_win, pnl, df.index[buy_idx], sdt, ticker))
in_pos = False; sig_i = sig_p = None; i = next_i
continue
score = 0.0
if not regime_series.empty and ts in regime_series.index:
v = regime_series.loc[ts]
score = float(v) if not pd.isna(v) else 0.0
if mode == "bear_off":
if score < BEAR_THRESHOLD:
sig_i = sig_p = None; i += 1; continue
vol_mult = VOL_MULT_BULL if score >= BULL_THRESHOLD else VOL_MULT_DEFAULT
elif mode == "bull_only":
if score < BULL_THRESHOLD:
sig_i = sig_p = None; i += 1; continue
vol_mult = VOL_MULT_BULL
else:
vol_mult = VOL_MULT_DEFAULT
if sig_i is not None and (i - sig_i) > SIGNAL_TO_N:
sig_i = sig_p = None
if sig_i is not None:
move_pct = (cur - sig_p) / sig_p * 100
if cur < sig_p:
sig_i = sig_p = None
elif move_pct >= THRESH:
in_pos = True; buy_idx = i; buy_price = cur
stop_pct = calc_atr(df, i); sig_i = sig_p = None
i += 1; continue
vol_p = df.iloc[i-1]["volume"]
vol_avg = df.iloc[i-1-LOCAL_VOL_N:i-1]["volume"].mean()
vol_r = vol_p / vol_avg if vol_avg > 0 else 0
close_qh = df.iloc[i-QUIET_N]["close"]
chg_qh = abs(cur - close_qh) / close_qh * 100 if close_qh > 0 else 999
if chg_qh < QUIET_PCT and vol_r >= vol_mult:
if sig_i is None:
sig_i = i; sig_p = cur
else:
if sig_i is not None and cur < sig_p:
sig_i = sig_p = None
i += 1
return trades
def apply_wf(trades):
history = []; shadow = 0; blocked = False; accepted = []; cnt = 0
for t in trades:
is_win = int(t[0])
if not blocked:
accepted.append(t); history.append(is_win)
if len(history) >= WF_WINDOW and sum(history[-WF_WINDOW:]) / WF_WINDOW < WF_MIN_WIN_RATE:
blocked = True; shadow = 0
else:
cnt += 1
if is_win:
shadow += 1
if shadow >= WF_SHADOW_WINS:
blocked = False; history = []; shadow = 0
else:
shadow = 0
return accepted, cnt
def apply_max_pos(trades):
open_exits = []; accepted = []; skipped = []
for t in trades:
buy_dt, sell_dt = t[2], t[3]
open_exits = [s for s in open_exits if s > buy_dt]
if len(open_exits) < MAX_POS:
open_exits.append(sell_dt); accepted.append(t)
else:
skipped.append(t)
return accepted, skipped
def run_compound(accepted):
portfolio = float(BUDGET); total_krw = 0.0; monthly = {}
for is_win, pnl, buy_dt, sell_dt, ticker in accepted:
pos_size = max(portfolio, MIN_BUDGET) / MAX_POS
krw_profit = pos_size * pnl / 100
portfolio = max(portfolio + krw_profit, MIN_BUDGET)
total_krw += krw_profit
ym = buy_dt.strftime("%Y-%m")
if ym not in monthly:
monthly[ym] = {"trades": 0, "wins": 0, "pnl_krw": 0.0}
monthly[ym]["trades"] += 1
monthly[ym]["wins"] += int(is_win)
monthly[ym]["pnl_krw"] += krw_profit
wins = sum(1 for t in accepted if t[0])
peak = BUDGET; max_dd = 0.0
pf = float(BUDGET)
for is_win, pnl, buy_dt, sell_dt, ticker in accepted:
pf = max(pf + max(pf, MIN_BUDGET) / MAX_POS * pnl / 100, MIN_BUDGET)
peak = max(peak, pf); max_dd = max(max_dd, (peak-pf)/peak*100)
return {
"portfolio": portfolio, "total_krw": total_krw,
"roi_pct": (portfolio-BUDGET)/BUDGET*100,
"total": len(accepted), "wins": wins,
"wr": wins/len(accepted)*100 if accepted else 0,
"monthly": monthly, "max_dd": max_dd,
}
def sim_one(dfs40, regime_n, mode):
rs = build_regime_series(dfs40, regime_n)
all_trades = []; wf_total = 0
for ticker, df40 in dfs40.items():
raw = run_strategy(df40, ticker, rs, mode)
filtered, blocked = apply_wf(raw)
wf_total += blocked
all_trades.extend(filtered)
all_trades.sort(key=lambda x: x[2])
accepted, skipped = apply_max_pos(all_trades)
result = run_compound(accepted)
# BULL 비율
if not rs.empty:
valid = rs.dropna()
bull_pct = (valid >= BULL_THRESHOLD).sum() / len(valid) * 100 if len(valid) else 0
bear_pct = (valid < BEAR_THRESHOLD).sum() / len(valid) * 100 if len(valid) else 0
else:
bull_pct = bear_pct = 0
return result, bull_pct, bear_pct, wf_total, len(skipped)
def main():
print("캐시 로드 중...")
cache = pickle.load(open(CACHE_FILE, "rb"))
tickers = [t for t in list(cache["10m"].keys())[:TOP_N]
if len(cache["10m"][t]) > 500]
print(f" 종목: {len(tickers)}\n")
dfs40 = {t: resample_40m(cache["10m"][t]) for t in tickers}
sample = next(iter(dfs40.values()))
start_dt = sample.index[0].strftime("%Y-%m-%d")
end_dt = sample.index[-1].strftime("%Y-%m-%d")
SWEEP_N = [1, 2, 3, 4, 5, 6, 8, 10] # 40분 ~ 400분 (6.7h)
# ── BULL 진입만 스윕 ──────────────────────────────────
print(f"{'='*72}")
print(f" REGIME_N 스윕 (40분봉 × N봉 변화율 기준 | BULL≥{BULL_THRESHOLD}%)")
print(f" 기간: {start_dt} ~ {end_dt} / {len(tickers)}종목")
print(f"{'='*72}")
print(f" {'N봉':>4} {'시간':>5}{'BULL%':>6} {'BEAR%':>6}"
f"{'진입':>5} {'승률':>5}{'수익률':>8} {'순수익(KRW)':>14} {'낙폭':>7}")
print(f" {''*68}")
bull_results = {}
for n in SWEEP_N:
r, bull_pct, bear_pct, wf_b, skip = sim_one(dfs40, n, "bull_only")
bull_results[n] = r
mins = n * 40
h = mins // 60
m = mins % 60
time_label = f"{h}h{m:02d}m" if m else f"{h}h"
if r["total"] == 0:
print(f" {n:>4}{time_label:>5}{bull_pct:>5.1f}% {bear_pct:>5.1f}% │ "
f"{'진입없음':>34}")
else:
print(f" {n:>4}{time_label:>5}{bull_pct:>5.1f}% {bear_pct:>5.1f}% │ "
f"{r['total']:>5}{r['wr']:>4.1f}% │ "
f"{r['roi_pct']:>+7.2f}% {r['total_krw']:>+13,.0f}원 -{r['max_dd']:>4.1f}%")
# ── BEAR 차단 스윕 ────────────────────────────────────
print(f"\n{'='*72}")
print(f" REGIME_N 스윕 (BEAR 차단 모드 | BEAR<{BEAR_THRESHOLD}%)")
print(f"{'='*72}")
print(f" {'N봉':>4} {'시간':>5}{'BULL%':>6} {'BEAR%':>6}"
f"{'진입':>5} {'승률':>5}{'수익률':>8} {'순수익(KRW)':>14} {'낙폭':>7}")
print(f" {''*68}")
bear_results = {}
for n in SWEEP_N:
r, bull_pct, bear_pct, wf_b, skip = sim_one(dfs40, n, "bear_off")
bear_results[n] = r
mins = n * 40
h = mins // 60; m = mins % 60
time_label = f"{h}h{m:02d}m" if m else f"{h}h"
print(f" {n:>4}{time_label:>5}{bull_pct:>5.1f}% {bear_pct:>5.1f}% │ "
f"{r['total']:>5}{r['wr']:>4.1f}% │ "
f"{r['roi_pct']:>+7.2f}% {r['total_krw']:>+13,.0f}원 -{r['max_dd']:>4.1f}%")
# ── 베이스라인 (필터없음) ─────────────────────────────
r_none, _, _, _, _ = sim_one(dfs40, 1, "none")
print(f"\n 베이스라인 (필터없음): {r_none['total']}{r_none['wr']:.1f}% "
f"{r_none['roi_pct']:+.2f}% {r_none['total_krw']:+,.0f}원 -{r_none['max_dd']:.1f}%")
# ── 최적 BULL 구간 ────────────────────────────────────
valid_bull = {n: r for n, r in bull_results.items() if r["total"] >= 5}
if valid_bull:
best_n = max(valid_bull, key=lambda n: valid_bull[n]["roi_pct"])
best_r = valid_bull[best_n]
print(f"\n ★ BULL 진입 최적 N: {best_n}봉({best_n*40}분) "
f"수익률 {best_r['roi_pct']:+.2f}% 진입 {best_r['total']}"
f"승률 {best_r['wr']:.1f}%")
valid_bear = {n: r for n, r in bear_results.items() if r["total"] >= 5}
if valid_bear:
best_n = max(valid_bear, key=lambda n: valid_bear[n]["roi_pct"])
best_r = valid_bear[best_n]
print(f" ★ BEAR 차단 최적 N: {best_n}봉({best_n*40}분) "
f"수익률 {best_r['roi_pct']:+.2f}% 진입 {best_r['total']}"
f"승률 {best_r['wr']:.1f}%")
print(f"{'='*72}")
if __name__ == "__main__":
main()

244
archive/tests/sim_tp_sl.py Normal file
View File

@@ -0,0 +1,244 @@
"""TP + 고정 손절 비율 비교 시뮬 (30일)."""
import sys, os
from datetime import datetime, timedelta
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from dotenv import load_dotenv
load_dotenv(os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), '.env'))
import oracledb
LOOKBACK_DAYS = 30
VOL_LOOKBACK = 61
ATR_LOOKBACK = 28
VOL_MIN = 8.0
ATR_MULT = 1.0
ATR_MIN_R = 0.030
ATR_MAX_R = 0.050
MAX_TRAIL_BARS = 240
BUDGET = 15_000_000
MAX_POS = 3
PER_POS = BUDGET // MAX_POS
FEE = 0.0005
TICKERS = [
'KRW-XRP','KRW-BTC','KRW-ETH','KRW-SOL','KRW-DOGE',
'KRW-ADA','KRW-SUI','KRW-NEAR','KRW-KAVA','KRW-SXP',
'KRW-AKT','KRW-SONIC','KRW-IP','KRW-ORBS','KRW-VIRTUAL',
'KRW-BARD','KRW-XPL','KRW-KITE','KRW-ENSO','KRW-0G',
]
_TK = ",".join(f"'{t}'" for t in TICKERS)
def get_conn():
kwargs = dict(user=os.environ["ORACLE_USER"], password=os.environ["ORACLE_PASSWORD"],
dsn=os.environ["ORACLE_DSN"])
if w := os.environ.get("ORACLE_WALLET"):
kwargs["config_dir"] = w
return oracledb.connect(**kwargs)
SIGNAL_SQL = f"""
WITH base AS (
SELECT ticker, ts, open_p, close_p, high_p, low_p, volume_p,
LAG(close_p,1) OVER (PARTITION BY ticker ORDER BY ts) pc1,
LAG(open_p,1) OVER (PARTITION BY ticker ORDER BY ts) po1,
LAG(close_p,2) OVER (PARTITION BY ticker ORDER BY ts) pc2,
LAG(open_p,2) OVER (PARTITION BY ticker ORDER BY ts) po2,
LAG(close_p,3) OVER (PARTITION BY ticker ORDER BY ts) pc3,
LAG(open_p,3) OVER (PARTITION BY ticker ORDER BY ts) po3,
GREATEST(high_p-low_p,
ABS(high_p-LAG(close_p,1) OVER (PARTITION BY ticker ORDER BY ts)),
ABS(low_p -LAG(close_p,1) OVER (PARTITION BY ticker ORDER BY ts))) tr
FROM backtest_ohlcv
WHERE interval_cd='minute1'
AND ts >= TO_TIMESTAMP(:ws,'YYYY-MM-DD HH24:MI:SS')
AND ticker IN ({_TK})
),
ind AS (
SELECT ticker, ts, open_p, close_p, high_p, low_p,
volume_p / NULLIF(AVG(volume_p) OVER (
PARTITION BY ticker ORDER BY ts ROWS BETWEEN {VOL_LOOKBACK} PRECEDING AND 2 PRECEDING),0) vr0,
LAG(volume_p,1) OVER (PARTITION BY ticker ORDER BY ts) / NULLIF(AVG(volume_p) OVER (
PARTITION BY ticker ORDER BY ts ROWS BETWEEN {VOL_LOOKBACK} PRECEDING AND 2 PRECEDING),0) vr1,
LAG(volume_p,2) OVER (PARTITION BY ticker ORDER BY ts) / NULLIF(AVG(volume_p) OVER (
PARTITION BY ticker ORDER BY ts ROWS BETWEEN {VOL_LOOKBACK} PRECEDING AND 2 PRECEDING),0) vr2,
LAG(volume_p,3) OVER (PARTITION BY ticker ORDER BY ts) / NULLIF(AVG(volume_p) OVER (
PARTITION BY ticker ORDER BY ts ROWS BETWEEN {VOL_LOOKBACK} PRECEDING AND 2 PRECEDING),0) vr3,
pc1,po1,pc2,po2,pc3,po3,
AVG(tr) OVER (PARTITION BY ticker ORDER BY ts
ROWS BETWEEN {ATR_LOOKBACK} PRECEDING AND 1 PRECEDING) / NULLIF(pc1,0) atr_raw
FROM base
)
SELECT ticker, ts, vr0,vr1,vr2,vr3, atr_raw
FROM ind
WHERE ts >= TO_TIMESTAMP(:cs,'YYYY-MM-DD HH24:MI:SS')
AND vr0 >= {VOL_MIN}
AND close_p>open_p AND pc1>po1 AND pc2>po2 AND pc3>po3
AND close_p>pc1 AND pc1>pc2 AND pc2>pc3
AND vr0>vr1 AND vr1>vr2 AND vr2>vr3
ORDER BY ticker, ts
"""
def sim_trail(bars, ep, ar):
stop = max(ATR_MIN_R, min(ATR_MAX_R, ar * ATR_MULT)) if ar > 0 else ATR_MAX_R
peak = ep
for i, (ts, cp, hp, lp) in enumerate(bars):
peak = max(peak, cp)
if (peak - cp) / peak >= stop:
return dict(status='트레일손절', exit_ts=ts, exit_price=cp,
pnl=(cp - ep) / ep * 100, held=i + 1)
lts, lcp = bars[-1][0], bars[-1][1]
return dict(status='타임아웃' if len(bars) >= MAX_TRAIL_BARS else '진행중',
exit_ts=lts, exit_price=lcp, pnl=(lcp - ep) / ep * 100, held=len(bars))
def sim_tp_trail(bars, ep, ar, tp_r):
stop = max(ATR_MIN_R, min(ATR_MAX_R, ar * ATR_MULT)) if ar > 0 else ATR_MAX_R
tp = ep * (1 + tp_r)
peak = ep
for i, (ts, cp, hp, lp) in enumerate(bars):
if hp >= tp:
return dict(status=f'익절+{tp_r*100:.0f}%', exit_ts=ts, exit_price=tp,
pnl=tp_r * 100, held=i + 1)
peak = max(peak, cp)
if (peak - cp) / peak >= stop:
return dict(status='트레일손절', exit_ts=ts, exit_price=cp,
pnl=(cp - ep) / ep * 100, held=i + 1)
lts, lcp = bars[-1][0], bars[-1][1]
return dict(status='타임아웃' if len(bars) >= MAX_TRAIL_BARS else '진행중',
exit_ts=lts, exit_price=lcp, pnl=(lcp - ep) / ep * 100, held=len(bars))
def sim_tp_sl(bars, ep, tp_r, sl_r):
"""고정 익절 + 고정 손절. 같은 봉에서 둘 다 터치하면 손절 우선."""
tp = ep * (1 + tp_r)
sl = ep * (1 - sl_r)
for i, (ts, cp, hp, lp) in enumerate(bars):
hit_sl = lp <= sl
hit_tp = hp >= tp
if hit_sl:
return dict(status=f'손절-{sl_r*100:.0f}%', exit_ts=ts, exit_price=sl,
pnl=-sl_r * 100, held=i + 1)
if hit_tp:
return dict(status=f'익절+{tp_r*100:.0f}%', exit_ts=ts, exit_price=tp,
pnl=tp_r * 100, held=i + 1)
lts, lcp = bars[-1][0], bars[-1][1]
return dict(status='타임아웃' if len(bars) >= MAX_TRAIL_BARS else '진행중',
exit_ts=lts, exit_price=lcp, pnl=(lcp - ep) / ep * 100, held=len(bars))
def pos_limit(sim):
opens, taken, skipped = [], [], []
for r in sim:
opens = [ex for ex in opens if ex > r['entry_ts']]
if len(opens) < MAX_POS:
opens.append(r['exit_ts'])
taken.append(r)
else:
skipped.append(r)
return taken, skipped
def main():
now = datetime.now()
check_since = (now - timedelta(days=LOOKBACK_DAYS)).strftime('%Y-%m-%d 00:00:00')
warmup_since = (now - timedelta(days=LOOKBACK_DAYS + 1)).strftime('%Y-%m-%d 00:00:00')
conn = get_conn()
cur = conn.cursor()
cur.arraysize = 10000
cur.execute(SIGNAL_SQL, {'ws': warmup_since, 'cs': check_since})
rows = cur.fetchall()
signals = []
for row in rows:
ticker, sig_ts, vr0, vr1, vr2, vr3, atr_raw = row
cur.execute(
"""SELECT close_p, ts FROM backtest_ohlcv
WHERE ticker=:t AND interval_cd='minute1'
AND ts > :sig AND ts <= :sig + INTERVAL '3' MINUTE
ORDER BY ts FETCH FIRST 1 ROWS ONLY""",
{'t': ticker, 'sig': sig_ts}
)
er = cur.fetchone()
if not er:
continue
ep, ets = float(er[0]), er[1]
cur.execute(
"""SELECT ts, close_p, high_p, low_p FROM backtest_ohlcv
WHERE ticker=:t AND interval_cd='minute1'
AND ts >= :entry ORDER BY ts FETCH FIRST :n ROWS ONLY""",
{'t': ticker, 'entry': ets, 'n': MAX_TRAIL_BARS + 1}
)
bars = [(r[0], float(r[1]), float(r[2]), float(r[3])) for r in cur.fetchall()]
if not bars:
continue
signals.append({
'ticker': ticker, 'entry_ts': ets, 'entry_price': ep,
'atr_raw': float(atr_raw) if atr_raw else 0.0,
'bars': bars,
})
signals.sort(key=lambda x: x['entry_ts'])
print(f"=== TP / 손절 비율 비교 시뮬 ===")
print(f"기간: {check_since[:10]} ~ {now.strftime('%Y-%m-%d')} | 시그널 {len(signals)}\n")
strategies = [
('A. Trail Stop [3~5%]', 'trail', None, None ),
('B. TP 3% + Trail Stop', 'tp_trail', 0.03, None ),
('C. TP 2% + SL 2%', 'tp_sl', 0.02, 0.02 ),
('D. TP 2% + SL 1.5%', 'tp_sl', 0.02, 0.015),
('E. TP 2% + SL 1%', 'tp_sl', 0.02, 0.010),
('F. TP 3% + SL 2%', 'tp_sl', 0.03, 0.02 ),
('G. TP 3% + SL 3%', 'tp_sl', 0.03, 0.03 ),
]
print(f"{''*105}")
print(f" {'전략':32s} {'거래':>3s} {'승률':>4s} {'합산손익':>12s} {'수익률':>5s} {'평균보유':>5s} {'손익비':>5s}")
print(f"{''*105}")
all_results = {}
for label, mode, tp_r, sl_r in strategies:
sim = []
for s in signals:
if mode == 'trail':
r = sim_trail(s['bars'], s['entry_price'], s['atr_raw'])
elif mode == 'tp_trail':
r = sim_tp_trail(s['bars'], s['entry_price'], s['atr_raw'], tp_r)
else:
r = sim_tp_sl(s['bars'], s['entry_price'], tp_r, sl_r)
sim.append({**s, **r})
taken, _ = pos_limit(sim)
all_results[label] = taken
n = len(taken)
if n == 0:
print(f" {label:32s} 거래없음")
continue
wins = sum(1 for r in taken if r['pnl'] > 0)
losses = sum(1 for r in taken if r['pnl'] < 0)
total = sum(PER_POS * (r['pnl'] / 100) - PER_POS * FEE * 2 for r in taken)
avg_h = sum(r['held'] for r in taken) / n
ret = total / BUDGET * 100
avg_w = sum(r['pnl'] for r in taken if r['pnl'] > 0) / wins if wins else 0
avg_l = abs(sum(r['pnl'] for r in taken if r['pnl'] < 0) / losses) if losses else 1
rr = avg_w / avg_l
print(f" {label:32s} {n:>3d}{wins/n*100:>4.0f}% {total:>+12,.0f}"
f"{ret:>+5.2f}% {avg_h:>5.0f}{rr:>4.1f}:1")
print(f"{''*105}")
# ── C 상세 (TP2%+SL2%) ────────────────────────────────────────────────────
label_c = 'C. TP 2% + SL 2%'
print(f"\n[{label_c} 건별 상세]")
print(f"{''*100}")
for i, r in enumerate(all_results[label_c], 1):
krw = PER_POS * (r['pnl'] / 100) - PER_POS * FEE * 2
sign = '' if r['pnl'] > 0 else ''
print(f" #{i:02d} {r['ticker']:12s}[{sign}] {str(r['entry_ts'])[:16]} "
f"{r['status']:14s} {r['held']:3d}{r['pnl']:>+.2f}% ({krw:>+,.0f}원)")
conn.close()
if __name__ == '__main__':
main()

View File

@@ -0,0 +1,353 @@
"""볼륨 강도 기반 레짐+F&G 오버라이드 시뮬 — 1년치.
우선순위 로직:
1순위: vol_ratio ≥ VOL_OVERRIDE_THRESH → 레짐/F&G 무관 즉시 진입 허용
2순위: F&G < FNG_MIN_ENTRY → 차단
3순위: 레짐 BEAR → 차단
4순위: 일반 vol-lead 로직
비교 구성:
1. 필터 없음
2. F&G≥41 + BEAR차단N5 (현재 전략 레짐 적용)
3. F&G≥41 + BEAR차단N5 + vol≥5x 오버라이드 (레짐+F&G 동시 오버라이드)
4. F&G≥41 + BEAR차단N5 + vol≥4x 오버라이드
5. F&G≥41 + BEAR차단N5 + vol≥3x 오버라이드
데이터: data/sim1y_cache.pkl / data/fng_1y.json
"""
import os as _os, sys as _sys
_sys.path.insert(0, _os.path.dirname(_os.path.dirname(_os.path.abspath(__file__))))
import json
import pickle
from pathlib import Path
import pandas as pd
from dotenv import load_dotenv
load_dotenv(dotenv_path=Path(__file__).parent.parent / ".env")
CACHE_FILE = Path(__file__).parent.parent / "data" / "sim1y_cache.pkl"
FNG_FILE = Path(__file__).parent.parent / "data" / "fng_1y.json"
TOP_N = 20
BUDGET = 15_000_000
MIN_BUDGET = BUDGET * 3 // 10
MAX_POS = 3
FEE = 0.0005
TIME_STOP_MIN_PCT = 3.0
ATR_MULT = 1.5
ATR_MIN = 0.010
ATR_MAX = 0.020
VOL_MULT_DEFAULT = 2.0
VOL_MULT_BULL = 1.5
QUIET_PCT = 2.0
THRESH = 4.8
LOCAL_VOL_N = 7
QUIET_N = 3
SIGNAL_TO_N = 12
ATR_N = 7
TS_N = 12
BEAR_THRESHOLD = -0.5
BULL_THRESHOLD = 1.5
REGIME_N = 5
FNG_MIN_ENTRY = 41
WF_WINDOW = 4
WF_MIN_WIN_RATE = 0.01
WF_SHADOW_WINS = 2
REGIME_WEIGHTS = {"KRW-BTC": 0.40, "KRW-ETH": 0.30,
"KRW-SOL": 0.15, "KRW-XRP": 0.15}
def resample_40m(df):
return (df.resample("40min")
.agg({"open":"first","high":"max","low":"min",
"close":"last","volume":"sum"})
.dropna(subset=["close"]))
def build_regime_series(dfs40):
weighted = None
for ticker, w in REGIME_WEIGHTS.items():
if ticker not in dfs40: continue
pct = dfs40[ticker]["close"].pct_change(REGIME_N) * 100
weighted = pct * w if weighted is None else weighted.add(pct * w, fill_value=0.0)
return weighted if weighted is not None else pd.Series(dtype=float)
def calc_atr(df, buy_idx):
sub = df.iloc[max(0, buy_idx - ATR_N - 1):buy_idx]
if len(sub) < 3: return ATR_MIN
try:
avg = ((sub["high"] - sub["low"]) / sub["low"]).iloc[-ATR_N:].mean()
return float(max(ATR_MIN, min(ATR_MAX, avg * ATR_MULT)))
except Exception:
return ATR_MIN
def simulate_pos(df, buy_idx, buy_price, stop_pct):
peak = buy_price
for i in range(buy_idx + 1, len(df)):
row = df.iloc[i]
if row["high"] > peak: peak = row["high"]
if row["low"] <= peak * (1 - stop_pct):
sp = peak * (1 - stop_pct)
pnl = (sp*(1-FEE) - buy_price*(1+FEE)) / (buy_price*(1+FEE)) * 100
return pnl > 0, df.index[i], pnl
pnl_now = (row["close"] - buy_price) / buy_price * 100
if (i - buy_idx) >= TS_N and pnl_now < TIME_STOP_MIN_PCT:
pnl = (row["close"]*(1-FEE) - buy_price*(1+FEE)) / (buy_price*(1+FEE)) * 100
return pnl > 0, df.index[i], pnl
last = df.iloc[-1]["close"]
pnl = (last*(1-FEE) - buy_price*(1+FEE)) / (buy_price*(1+FEE)) * 100
return pnl > 0, df.index[-1], pnl
def run_strategy(df, ticker, regime_series, fng_map,
use_fng, use_regime, vol_override_thresh):
"""
우선순위 구조:
① 포지션 청산 체크
② 볼륨 스파이크 감지 → 신호 기록 (F&G/레짐 무관, 항상 실행)
③ 진입 시점에서:
vol_strong(sig_vr≥thresh) → F&G+레짐 필터 전부 건너뜀
아니면 → F&G≥41 AND 레짐 BEAR 아닐 때만 진입 허용
"""
trades = []
sig_i = sig_p = sig_vr = None
in_pos = False
buy_idx = buy_price = stop_pct = None
i = max(LOCAL_VOL_N + 2, QUIET_N + 1)
while i < len(df):
ts = df.index[i]
row = df.iloc[i]
cur = row["close"]
# ── ① 포지션 청산 ────────────────────────────────
if in_pos:
is_win, sdt, pnl = simulate_pos(df, buy_idx, buy_price, stop_pct)
next_i = next((j for j in range(i, len(df)) if df.index[j] > sdt), len(df))
trades.append((is_win, pnl, df.index[buy_idx], sdt, ticker))
in_pos = False; sig_i = sig_p = sig_vr = None; i = next_i
continue
# 신호 타임아웃
if sig_i is not None and (i - sig_i) > SIGNAL_TO_N:
sig_i = sig_p = sig_vr = None
# ── ② 신호 없을 때: 축적 감지 (필터 무관, 항상) ──
# F&G=14 극공포여도 vol 스파이크면 신호 기록 → ③에서 override 결정
if sig_i is None:
vol_p = df.iloc[i-1]["volume"]
vol_avg = df.iloc[i-1-LOCAL_VOL_N:i-1]["volume"].mean()
vol_r = vol_p / vol_avg if vol_avg > 0 else 0
close_qh = df.iloc[i-QUIET_N]["close"]
chg_qh = abs(cur - close_qh) / close_qh * 100 if close_qh > 0 else 999
if chg_qh < QUIET_PCT and vol_r >= VOL_MULT_DEFAULT:
sig_i = i; sig_p = cur; sig_vr = vol_r
i += 1
continue
# 신호가 이하 하락 → 초기화
if cur < sig_p:
sig_i = sig_p = sig_vr = None
i += 1
continue
# ── ③ 진입 체크 — vol_strong이면 필터 전부 스킵 ──
vol_strong = (vol_override_thresh > 0
and sig_vr is not None
and sig_vr >= vol_override_thresh)
if not vol_strong:
# F&G 필터 (신호 유지, 진입만 보류)
if use_fng and fng_map:
fv = fng_map.get(ts.strftime("%Y-%m-%d"), 50)
if fv < FNG_MIN_ENTRY:
i += 1; continue
# 레짐 필터 (신호 유지, 진입만 보류)
if use_regime and not regime_series.empty and ts in regime_series.index:
v = regime_series.loc[ts]
score = float(v) if not pd.isna(v) else 0.0
if score < BEAR_THRESHOLD:
i += 1; continue
move_pct = (cur - sig_p) / sig_p * 100
if move_pct >= THRESH:
in_pos = True; buy_idx = i; buy_price = cur
stop_pct = calc_atr(df, i); sig_i = sig_p = sig_vr = None
i += 1
return trades
def apply_wf(trades):
history = []; shadow = 0; blocked = False; accepted = []; cnt = 0
for t in trades:
is_win = int(t[0])
if not blocked:
accepted.append(t); history.append(is_win)
if len(history) >= WF_WINDOW and sum(history[-WF_WINDOW:]) / WF_WINDOW < WF_MIN_WIN_RATE:
blocked = True; shadow = 0
else:
cnt += 1
if is_win:
shadow += 1
if shadow >= WF_SHADOW_WINS: blocked = False; history = []; shadow = 0
else:
shadow = 0
return accepted, cnt
def apply_max_pos(trades):
open_exits = []; accepted = []; skipped = []
for t in trades:
buy_dt, sell_dt = t[2], t[3]
open_exits = [s for s in open_exits if s > buy_dt]
if len(open_exits) < MAX_POS:
open_exits.append(sell_dt); accepted.append(t)
else:
skipped.append(t)
return accepted, skipped
def run_compound(accepted):
portfolio = float(BUDGET); total_krw = 0.0; monthly = {}; trade_log = []
for is_win, pnl, buy_dt, sell_dt, ticker in accepted:
pos_size = max(portfolio, MIN_BUDGET) / MAX_POS
krw_profit = pos_size * pnl / 100
portfolio = max(portfolio + krw_profit, MIN_BUDGET)
total_krw += krw_profit
ym = buy_dt.strftime("%Y-%m")
if ym not in monthly:
monthly[ym] = {"trades": 0, "wins": 0, "pnl_krw": 0.0}
monthly[ym]["trades"] += 1; monthly[ym]["wins"] += int(is_win)
monthly[ym]["pnl_krw"] += krw_profit
trade_log.append({"portfolio": portfolio})
wins = sum(1 for t in accepted if t[0])
peak = BUDGET; max_dd = 0.0
for t in trade_log:
peak = max(peak, t["portfolio"])
max_dd = max(max_dd, (peak - t["portfolio"]) / peak * 100)
return {"portfolio": portfolio, "total_krw": total_krw,
"roi_pct": (portfolio-BUDGET)/BUDGET*100,
"total": len(accepted), "wins": wins,
"wr": wins/len(accepted)*100 if accepted else 0,
"monthly": monthly, "max_dd": max_dd}
def sim_one(dfs40, regime_series, fng_map, use_fng, use_regime, vol_override):
all_trades = []; wf_total = 0
for ticker, df40 in dfs40.items():
raw = run_strategy(df40, ticker, regime_series, fng_map,
use_fng, use_regime, vol_override)
filtered, blocked = apply_wf(raw)
wf_total += blocked; all_trades.extend(filtered)
all_trades.sort(key=lambda x: x[2])
accepted, skipped = apply_max_pos(all_trades)
return run_compound(accepted), wf_total, len(skipped)
def print_monthly(result, label):
print(f"\n ── 월별 상세: {label}")
print(f" {'':^8}{'거래':>4} {'승률':>5}{'월수익(KRW)':>13} {'누적(KRW)':>14}")
cum = 0.0
for ym, m in sorted(result["monthly"].items()):
wr = m["wins"] / m["trades"] * 100 if m["trades"] else 0
cum += m["pnl_krw"]
flag = "" if m["pnl_krw"] > 0 else ""
print(f" {ym:^8}{m['trades']:>4}{wr:>4.0f}% │"
f" {m['pnl_krw']:>+13,.0f}{cum:>+13,.0f}{flag}")
def main():
print("캐시 로드 중...")
cache = pickle.load(open(CACHE_FILE, "rb"))
tickers = [t for t in list(cache["10m"].keys())[:TOP_N]
if len(cache["10m"][t]) > 500]
print(f" 종목: {len(tickers)}")
fng_map = {}
if FNG_FILE.exists():
fng_map = json.load(open(FNG_FILE))
dates = sorted(fng_map.keys())
print(f" F&G: {dates[0]} ~ {dates[-1]} ({len(fng_map)}일)")
dfs40 = {t: resample_40m(cache["10m"][t]) for t in tickers}
regime_series = build_regime_series(dfs40)
sample = next(iter(dfs40.values()))
start_dt = sample.index[0].strftime("%Y-%m-%d")
end_dt = sample.index[-1].strftime("%Y-%m-%d")
# 필터 적용 일수 통계
if fng_map:
period_fng = {k: v for k, v in fng_map.items() if start_dt <= k <= end_dt}
fng_blocked = sum(1 for v in period_fng.values() if v < FNG_MIN_ENTRY)
fng_allowed = len(period_fng) - fng_blocked
print(f" F&G 차단: {fng_blocked}일 / 허용: {fng_allowed}일 (기준 ≥{FNG_MIN_ENTRY})")
valid = regime_series.dropna()
bear_pct = (valid < BEAR_THRESHOLD).sum() / len(valid) * 100
print(f" 레짐 BEAR: {bear_pct:.1f}%봉 (REGIME_N={REGIME_N}봉={REGIME_N*40}분)\n")
# ── 시뮬 구성 ─────────────────────────────────────────
CONFIGS = [
# (use_fng, use_regime, vol_override, label)
(False, False, 0, "① 필터 없음"),
(True, True, 0, f"② F&G≥{FNG_MIN_ENTRY} + BEAR차단N{REGIME_N}"),
(True, True, 5.0, f"③ [1순위:vol≥5x] F&G≥{FNG_MIN_ENTRY} + BEAR차단N{REGIME_N}"),
(True, True, 4.0, f"④ [1순위:vol≥4x] F&G≥{FNG_MIN_ENTRY} + BEAR차단N{REGIME_N}"),
(True, True, 3.0, f"⑤ [1순위:vol≥3x] F&G≥{FNG_MIN_ENTRY} + BEAR차단N{REGIME_N}"),
]
print(f"{'='*72}")
print(f" vol 오버라이드 (레짐+F&G 동시) 시뮬 | 1년 | {len(tickers)}종목")
print(f" 기간: {start_dt} ~ {end_dt}")
print(f" 우선순위: vol≥Nx(오버라이드) > F&G필터 > 레짐필터 > vol-lead 로직")
print(f"{'='*72}")
print(f" {'구성':<48} {'진입':>5} {'승률':>5} {'수익률':>8} {'순수익':>12} {'낙폭':>6}")
print(f" {''*70}")
results = {}
for use_fng, use_regime, vol_ov, label in CONFIGS:
r, wf_b, skip = sim_one(dfs40, regime_series, fng_map,
use_fng, use_regime, vol_ov)
results[label] = r
n = r["total"]
print(f" {label:<48} {n:>5}{r['wr']:>4.1f}%"
f" {r['roi_pct']:>+7.2f}% {r['total_krw']:>+11,.0f}원 -{r['max_dd']:.1f}%")
# ── 월별 상세 ─────────────────────────────────────────
print(f"\n{'='*72}")
for use_fng, use_regime, vol_ov, label in CONFIGS:
if label in results:
print_monthly(results[label], label)
# ── 비교 요약 ─────────────────────────────────────────
print(f"\n{'='*72}")
base_label = f"② F&G≥{FNG_MIN_ENTRY} + BEAR차단N{REGIME_N}"
base_r = results.get(base_label)
if base_r:
print(f" 오버라이드 효과 (vs {base_label}):")
for _, _, vol_ov, label in CONFIGS[2:]:
r = results.get(label)
if r and r["total"] > 0:
d_roi = r["roi_pct"] - base_r["roi_pct"]
d_n = r["total"] - base_r["total"]
d_wr = r["wr"] - base_r["wr"]
d_dd = r["max_dd"] - base_r["max_dd"]
print(f" vol≥{vol_ov:.0f}x: 수익률 {d_roi:>+.2f}%p "
f"진입 {d_n:>+d}건 승률 {d_wr:>+.1f}%p 낙폭 {d_dd:>+.1f}%p")
best_label = max(results, key=lambda k: results[k]["roi_pct"])
best = results[best_label]
print(f"\n ★ 최고 수익률: {best_label}")
print(f" 수익률 {best['roi_pct']:+.2f}% / 순수익 {best['total_krw']:+,.0f}"
f"/ 낙폭 -{best['max_dd']:.1f}%")
print(f"{'='*72}")
if __name__ == "__main__":
main()

260
archive/tests/stop_sweep.py Normal file
View File

@@ -0,0 +1,260 @@
"""트레일링 스탑 크기 스윕 백테스트
현행 모멘텀 진입 전략에서
트레일링 스탑 크기(1% ~ 6%)를 바꿔가며 성과 비교.
핵심 질문: "2% 스탑이 너무 좁아서 정상 노이즈에 스탑이 걸리는 건 아닌가?"
"""
import os as _os, sys as _sys
_sys.path.insert(0, _os.path.dirname(_os.path.dirname(_os.path.abspath(__file__))))
from __future__ import annotations
import pyupbit, time, sys
from dataclasses import dataclass, field
from collections import defaultdict
TICKERS = [
"KRW-BTC", "KRW-ETH", "KRW-XRP", "KRW-SOL", "KRW-DOGE",
"KRW-ADA", "KRW-DOT", "KRW-NEAR", "KRW-AVAX", "KRW-LINK",
"KRW-SUI", "KRW-HBAR", "KRW-TRX", "KRW-ATOM",
"KRW-VIRTUAL", "KRW-SXP", "KRW-CFG", "KRW-HOLO", "KRW-ENSO",
"KRW-KAVA", "KRW-KNC", "KRW-OM", "KRW-STEEM", "KRW-ORBS",
]
# 공통
VOL_MULT = 2.0
QUIET_2H = 2.0
SIGNAL_TO_H = 8
MOMENTUM_THR = 3.0
SIGNAL_CANCEL = 3.0
TIME_STOP_H = 24
TIME_STOP_MIN = 3.0 # 24h 후 +3% 미달 시 청산
# 테스트할 스탑 크기 목록
STOP_SIZES = [1.0, 1.5, 2.0, 2.5, 3.0, 4.0, 5.0, 6.0]
@dataclass
class Trade:
pnl: float
h: int
exit: str # trail | time
def simulate(df, trail_stop_pct: float) -> list[Trade]:
closes = df["close"].values
vols = df["volume"].values
trail = trail_stop_pct / 100.0
trades: list[Trade] = []
sig_px = sig_i = None
pos_buy = pos_peak = pos_i = None
for i in range(7, len(closes) - max(TIME_STOP_H + 4, 10)):
# ── 포지션 관리 ──────────────────────────────────────
if pos_buy is not None:
cur = closes[i]
if cur > pos_peak:
pos_peak = cur
# 트레일링 스탑
if (pos_peak - cur) / pos_peak >= trail:
pnl = (cur - pos_buy) / pos_buy * 100
trades.append(Trade(pnl, i - pos_i, "trail"))
pos_buy = pos_peak = pos_i = sig_px = sig_i = None
continue
# 타임 스탑
if i - pos_i >= TIME_STOP_H:
pnl = (cur - pos_buy) / pos_buy * 100
if pnl < TIME_STOP_MIN:
trades.append(Trade(pnl, i - pos_i, "time"))
pos_buy = pos_peak = pos_i = sig_px = sig_i = None
continue
continue
# ── 신호 유효성 ──────────────────────────────────────
if sig_px is not None:
if i - sig_i > SIGNAL_TO_H:
sig_px = sig_i = None
elif (closes[i] - sig_px) / sig_px * 100 < -SIGNAL_CANCEL:
sig_px = sig_i = None
# ── 축적 감지 ─────────────────────────────────────────
if sig_px is None:
vol_avg = vols[i-6:i-1].mean()
if vol_avg <= 0:
continue
if vols[i-1] / vol_avg >= VOL_MULT:
if abs(closes[i] - closes[i-2]) / closes[i-2] * 100 < QUIET_2H:
sig_px = closes[i]
sig_i = i
continue
# ── 모멘텀 진입 ───────────────────────────────────────
if (closes[i] - sig_px) / sig_px * 100 >= MOMENTUM_THR:
pos_buy = pos_peak = closes[i]
pos_i = i
sig_px = sig_i = None
return trades
def stats(trades: list[Trade]) -> dict:
if not trades:
return dict(n=0, wins=0, wr=0, avg_pnl=0, avg_win=0, avg_loss=0,
rr=0, total_pnl=0, avg_h=0, max_dd=0, trail_pct=0, time_pct=0)
wins = [t for t in trades if t.pnl > 0]
losses = [t for t in trades if t.pnl <= 0]
avg_win = sum(t.pnl for t in wins) / len(wins) if wins else 0
avg_loss = sum(t.pnl for t in losses) / len(losses) if losses else 0
rr = abs(avg_win / avg_loss) if avg_loss else 0
# 누적 수익 흐름으로 Max Drawdown 계산
cum = 0.0; peak_cum = 0.0; max_dd = 0.0
for t in trades:
cum += t.pnl
if cum > peak_cum:
peak_cum = cum
dd = peak_cum - cum
if dd > max_dd:
max_dd = dd
trail_n = sum(1 for t in trades if t.exit == "trail")
time_n = sum(1 for t in trades if t.exit == "time")
return dict(
n = len(trades),
wins = len(wins),
wr = len(wins) / len(trades) * 100,
avg_pnl = sum(t.pnl for t in trades) / len(trades),
avg_win = avg_win,
avg_loss = avg_loss,
rr = rr,
total_pnl = sum(t.pnl for t in trades),
avg_h = sum(t.h for t in trades) / len(trades),
max_dd = max_dd,
trail_pct = trail_n / len(trades) * 100,
time_pct = time_n / len(trades) * 100,
)
def main() -> None:
print(f"데이터 수집 중 ({len(TICKERS)}개 종목, 60일 1h 캔들)...")
datasets: dict = {}
for i, ticker in enumerate(TICKERS):
try:
df = pyupbit.get_ohlcv(ticker, interval="minute60", count=1440)
if df is not None and len(df) > 50:
datasets[ticker] = df
sys.stderr.write(f"\r {i+1}/{len(TICKERS)} {ticker} ")
time.sleep(0.08)
except Exception:
pass
sys.stderr.write("\n")
print(f" 완료: {len(datasets)}개 종목\n")
# ── 스탑 크기별 전체 집계 ─────────────────────────────────
print("=" * 80)
print(" 트레일링 스탑 크기별 성과 비교 (60일 / 모멘텀 진입 기준)")
print("=" * 80)
print(f" {'스탑%':>5} {'거래':>5} {'승률':>6} {'평균PnL':>8} "
f"{'손익비':>6} {'총PnL':>8} {'MaxDD':>7} "
f"{'평균보유':>7} {'Trail%':>7} {'Time%':>6}")
print(" " + "-" * 77)
best_total = -9999
best_stop = None
all_stats: dict[float, dict] = {}
for stop in STOP_SIZES:
all_trades: list[Trade] = []
for df in datasets.values():
all_trades.extend(simulate(df, stop))
s = stats(all_trades)
all_stats[stop] = s
marker = ""
if stop == 2.0:
marker = " ◀ 현행"
print(
f" {stop:>4.1f}% {s['n']:>5}{s['wr']:>5.1f}% "
f"{s['avg_pnl']:>+7.3f}% {s['rr']:>5.2f} "
f"{s['total_pnl']:>+7.1f}% -{s['max_dd']:>5.1f}% "
f"{s['avg_h']:>6.1f}h {s['trail_pct']:>6.1f}% "
f"{s['time_pct']:>5.1f}%{marker}"
)
if s["total_pnl"] > best_total and s["n"] >= 30:
best_total = s["total_pnl"]
best_stop = stop
# ── 손익 분포 시각화 ─────────────────────────────────────
print()
print(" 총 손익 트렌드 (스탑 크기별):")
for stop in STOP_SIZES:
s = all_stats[stop]
if s["n"] == 0:
continue
bar_val = s["total_pnl"]
bar_len = int(abs(bar_val) / 5)
bar = ("" * min(bar_len, 30)) if bar_val > 0 else ("" * min(bar_len, 30))
sign = "+" if bar_val > 0 else "-"
marker = " ◀ 현행" if stop == 2.0 else ""
print(f" {stop:>4.1f}% {sign}{bar} ({bar_val:+.1f}%){marker}")
# ── 이익/손실 평균 비교 ─────────────────────────────────
print()
print(" 이익 vs 손실 평균 비교:")
print(f" {'스탑%':>5} {'평균이익':>8} {'평균손실':>9} {'손익비':>6} {'설명'}")
print(" " + "-" * 55)
for stop in STOP_SIZES:
s = all_stats[stop]
if s["n"] == 0:
continue
marker = " ◀ 현행" if stop == 2.0 else ""
# 손익비 해석
if s["rr"] >= 1.5: interp = "양호"
elif s["rr"] >= 1.0: interp = "보통"
elif s["rr"] >= 0.7: interp = "불량"
else: interp = "매우불량"
print(
f" {stop:>4.1f}% {s['avg_win']:>+7.3f}% {s['avg_loss']:>+8.3f}% "
f"{s['rr']:>5.2f} {interp}{marker}"
)
if best_stop:
s = all_stats[best_stop]
print(f"\n ★ 최적 스탑: {best_stop:.1f}% "
f"(거래 {s['n']}건 | 승률 {s['wr']:.1f}% | "
f"총손익 {s['total_pnl']:+.1f}% | 손익비 {s['rr']:.2f})")
# ── 핵심 분석: 2% 스탑이 노이즈에 걸리는 비율 ───────────
print()
print(" 진단: 스탑 발동 후 실제 추가 하락 vs 반등 분석")
print(" (트레일링 스탑이 걸린 시점 기준 이후 4h 추적)")
for stop_target in [2.0, 3.0, 4.0]:
if stop_target not in all_stats:
continue
trades_checked = 0
recoveries = 0
for df in datasets.values():
tlist = simulate(df, stop_target)
closes = df["close"].values
# 스탑 발동 건 중 이후 4h 안에 스탑가격 이상으로 회복한 비율
# (여기서는 근사: 스탑 발동 후 trail stop 기준점인 peak * (1 - stop) 대비)
# 간단히: trail 발동 후 다음 4개 캔들이 매수가 대비 양수인지 확인
# simulate의 trade에는 exit price가 없으므로, 전체 흐름에서 재확인 필요
# 여기서는 avg_pnl과 avg_win으로 대신 설명
s = all_stats[stop_target]
# 스탑 발동 건 중 손실 비율
trail_loss_approx = s["n"] * (s["trail_pct"]/100) * (1 - s["wr"]/100)
print(f" 스탑 {stop_target:.1f}%: trail 발동 {s['trail_pct']:.0f}% | "
f"time 발동 {s['time_pct']:.0f}% | "
f"avg손실 {s['avg_loss']:+.3f}% | avg이익 {s['avg_win']:+.3f}%")
if __name__ == "__main__":
main()

310
archive/tests/sweep_1min.py Normal file
View File

@@ -0,0 +1,310 @@
"""1분봉 연속 상승 vol spike 전략 파라미터 스윕.
시그널 조건:
봉[n-1]: vol_ratio >= VOL_MIN, 양봉 (close > open)
봉[n] : vol_ratio >= VOL_MIN, 양봉, close > 봉[n-1].close (연속 상승)
진입: 봉[n] 다음 봉 (봉[n+1]) close에서 즉시
추적: 1분봉 trail stop (ATR 기반) + time stop
DB 계산: 지표·시그널·진입·running_peak 모두 Oracle SQL / 월별 배치
"""
import sys, os, itertools
from datetime import datetime, timedelta
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from dotenv import load_dotenv
load_dotenv(os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), '.env'))
import pandas as pd
import oracledb
import time as _time
# ── 고정 파라미터 ─────────────────────────────────────────────────────────────
VOL_LOOKBACK = 61 # vol_ratio 기준: 이전 60봉 평균
ATR_LOOKBACK = 28 # ATR 계산 봉 수
TS_N = 240 # 타임스탑 봉수 (240분 = 4시간)
TIME_STOP_PCT = 0.0 / 100
FEE = 0.0005
BUDGET = 15_000_000
MAX_POS = 3
PER_POS = BUDGET // MAX_POS
# ── 스윕 파라미터 ─────────────────────────────────────────────────────────────
SWEEP = {
"VOL": [15.0, 20.0, 25.0, 30.0, 40.0, 50.0], # 시그널 거래량 배율 (상위 범위 탐색)
"ATR_MULT": [1.5, 2.0, 2.5, 3.0],
"ATR_MIN": [0.005, 0.010, 0.015],
"ATR_MAX": [0.020, 0.025, 0.030],
}
VOL_MIN = min(SWEEP["VOL"]) # SQL pre-filter (15x 이상만 로드)
# ── 시뮬 구간 ─────────────────────────────────────────────────────────────────
SIM_START = datetime(2025, 8, 1)
SIM_END = datetime(2026, 3, 4)
WARMUP_MINS = 120
TICKERS = [
'KRW-XRP','KRW-BTC','KRW-ETH','KRW-SOL','KRW-DOGE',
'KRW-ADA','KRW-SUI','KRW-NEAR','KRW-KAVA','KRW-SXP',
'KRW-AKT','KRW-SONIC','KRW-IP','KRW-ORBS','KRW-VIRTUAL',
'KRW-BARD','KRW-XPL','KRW-KITE','KRW-ENSO','KRW-0G',
]
_TK = ",".join(f"'{t}'" for t in TICKERS)
def _months(start: datetime, end: datetime):
m = start.replace(day=1)
while m < end:
nxt = (m + timedelta(days=32)).replace(day=1)
if nxt > end:
nxt = end
yield m, nxt
m = nxt
def _get_conn():
kwargs = dict(user=os.environ["ORACLE_USER"],
password=os.environ["ORACLE_PASSWORD"],
dsn=os.environ["ORACLE_DSN"])
wallet = os.environ.get("ORACLE_WALLET")
if wallet:
kwargs["config_dir"] = wallet
return oracledb.connect(**kwargs)
# ── 핵심 SQL ──────────────────────────────────────────────────────────────────
# 연속 2봉 vol spike + 상승 확인 후 다음 봉 즉시 진입
TRADE_SQL = f"""
WITH
-- 1) 1분봉 + TR + 이전 봉 정보
base AS (
SELECT ticker, ts, open_p, close_p, high_p, low_p, volume_p,
LAG(close_p, 1) OVER (PARTITION BY ticker ORDER BY ts) prev_close,
LAG(open_p, 1) OVER (PARTITION BY ticker ORDER BY ts) prev_open,
LAG(volume_p, 1) OVER (PARTITION BY ticker ORDER BY ts) prev_volume,
GREATEST(
high_p - low_p,
ABS(high_p - LAG(close_p,1) OVER (PARTITION BY ticker ORDER BY ts)),
ABS(low_p - LAG(close_p,1) OVER (PARTITION BY ticker ORDER BY ts))
) tr
FROM backtest_ohlcv
WHERE interval_cd = 'minute1'
AND ts >= TO_TIMESTAMP(:load_since, 'YYYY-MM-DD HH24:MI:SS')
AND ts < TO_TIMESTAMP(:sim_end, 'YYYY-MM-DD HH24:MI:SS')
AND ticker IN ({_TK})
),
-- 2) 지표: vol_ratio (현재봉), prev_vol_ratio (이전봉), atr_raw
indicators AS (
SELECT ticker, ts, open_p, close_p, prev_close, prev_open,
-- 현재 봉 vol_ratio
volume_p / NULLIF(
AVG(volume_p) OVER (PARTITION BY ticker ORDER BY ts
ROWS BETWEEN {VOL_LOOKBACK} PRECEDING AND 2 PRECEDING), 0
) vol_ratio,
-- 이전 봉 vol_ratio (LAG로 한 봉 앞)
prev_volume / NULLIF(
AVG(volume_p) OVER (PARTITION BY ticker ORDER BY ts
ROWS BETWEEN {VOL_LOOKBACK} PRECEDING AND 2 PRECEDING), 0
) prev_vol_ratio,
-- ATR
AVG(tr) OVER (PARTITION BY ticker ORDER BY ts
ROWS BETWEEN {ATR_LOOKBACK} PRECEDING AND 1 PRECEDING)
/ NULLIF(prev_close, 0) atr_raw
FROM base
),
-- 3) 연속 2봉 조건:
-- 봉[n-1]: prev_vol_ratio >= min_vol, 이전 봉 양봉
-- 봉[n] : vol_ratio >= min_vol, 양봉, close > prev_close (상승 지속)
signals AS (
SELECT ticker, ts sig_ts, close_p sig_price, vol_ratio, atr_raw
FROM indicators
WHERE ts >= TO_TIMESTAMP(:sim_start, 'YYYY-MM-DD HH24:MI:SS')
AND ts < TO_TIMESTAMP(:sim_end, 'YYYY-MM-DD HH24:MI:SS')
-- 현재 봉 조건
AND vol_ratio >= :min_vol
AND close_p > open_p
-- 이전 봉 조건
AND prev_vol_ratio >= :min_vol
AND prev_close > prev_open
-- 연속 상승
AND close_p > prev_close
),
-- 4) 진입: 시그널 다음 1분봉 즉시
entry_cands AS (
SELECT
s.ticker, s.sig_ts, s.sig_price, s.vol_ratio, s.atr_raw,
e.ts entry_ts,
e.close_p entry_price,
ROW_NUMBER() OVER (PARTITION BY s.ticker, s.sig_ts ORDER BY e.ts) rn
FROM signals s
JOIN backtest_ohlcv e
ON e.ticker = s.ticker
AND e.interval_cd = 'minute1'
AND e.ts > s.sig_ts
AND e.ts <= s.sig_ts + INTERVAL '3' MINUTE
),
-- 5) 첫 봉만
entries AS (
SELECT ticker, sig_ts, sig_price, vol_ratio, atr_raw,
entry_ts, entry_price
FROM entry_cands WHERE rn = 1
),
-- 6) 진입 후 TS_N분 1분봉 + 롤링 피크
post_entry AS (
SELECT
e.ticker, e.sig_ts, e.entry_ts, e.entry_price,
e.vol_ratio, e.atr_raw,
b.close_p bar_price,
ROW_NUMBER() OVER (PARTITION BY e.ticker, e.entry_ts ORDER BY b.ts) bar_n,
MAX(b.close_p) OVER (PARTITION BY e.ticker, e.entry_ts
ORDER BY b.ts
ROWS UNBOUNDED PRECEDING) running_peak
FROM entries e
JOIN backtest_ohlcv b
ON b.ticker = e.ticker
AND b.interval_cd = 'minute1'
AND b.ts >= e.entry_ts
AND b.ts <= e.entry_ts + INTERVAL '{TS_N}' MINUTE
)
SELECT ticker, sig_ts, entry_ts, entry_price,
vol_ratio, atr_raw,
bar_n, bar_price, running_peak
FROM post_entry
WHERE bar_n <= :ts_n + 1
ORDER BY ticker, entry_ts, bar_n
"""
# ── 월별 데이터 로드 ──────────────────────────────────────────────────────────
print(f"연속 2봉 vol spike 전략 (VOL>={VOL_MIN}x, 연속 상승 후 즉시 진입)\n", flush=True)
print("월별 DB 로드...\n", flush=True)
conn = _get_conn()
cur = conn.cursor()
cur.arraysize = 100_000
ENTRIES: dict = {}
t_load = _time.time()
for m_start, m_end in _months(SIM_START, SIM_END):
load_since = (m_start - timedelta(minutes=WARMUP_MINS)).strftime('%Y-%m-%d %H:%M:%S')
sim_start = m_start.strftime('%Y-%m-%d %H:%M:%S')
sim_end = m_end.strftime('%Y-%m-%d %H:%M:%S')
t0 = _time.time()
cur.execute(TRADE_SQL, {
"load_since": load_since,
"sim_start": sim_start,
"sim_end": sim_end,
"min_vol": VOL_MIN,
"ts_n": TS_N,
})
rows = cur.fetchall()
t1 = _time.time()
n_new = 0
for row in rows:
(ticker, sig_ts, entry_ts, entry_price,
vol_ratio, atr_raw,
bar_n, bar_price, running_peak) = row
key = (ticker, entry_ts)
if key not in ENTRIES:
ENTRIES[key] = {
'entry_price': float(entry_price),
'vol_ratio': float(vol_ratio),
'atr_raw': float(atr_raw) if atr_raw is not None else float('nan'),
'bars': [],
}
n_new += 1
ENTRIES[key]['bars'].append((float(bar_price), float(running_peak)))
print(f" {sim_start[:7]}: {len(rows):>8,}행 ({t1-t0:.1f}s) | 진입 {n_new:>5}", flush=True)
conn.close()
print(f"\n총 진입 이벤트: {len(ENTRIES):,}건 | 로드 {_time.time()-t_load:.1f}s\n", flush=True)
# ── 출구 탐색 ─────────────────────────────────────────────────────────────────
def find_exit(entry_price: float, atr_stop: float, bars: list) -> float:
for n, (bp, pk) in enumerate(bars):
drop = (pk - bp) / pk if pk > 0 else 0.0
pnl = (bp - entry_price) / entry_price
if drop >= atr_stop:
return pnl * 100
if n + 1 >= TS_N and pnl < TIME_STOP_PCT:
return pnl * 100
return (bars[-1][0] - entry_price) / entry_price * 100 if bars else 0.0
# ── 스윕 ──────────────────────────────────────────────────────────────────────
ENTRY_LIST = list(ENTRIES.values())
keys = list(SWEEP.keys())
combos = list(itertools.product(*SWEEP.values()))
print(f"{len(combos)}가지 조합 스윕...\n", flush=True)
t_sweep = _time.time()
results = []
for combo in combos:
params = dict(zip(keys, combo))
if params['ATR_MIN'] >= params['ATR_MAX']:
continue
vol_thr = params['VOL']
atr_mult = params['ATR_MULT']
atr_min = params['ATR_MIN']
atr_max = params['ATR_MAX']
trades = []
for e in ENTRY_LIST:
if e['vol_ratio'] < vol_thr:
continue
ar = e['atr_raw']
atr_s = (atr_min if (ar != ar)
else max(atr_min, min(atr_max, ar * atr_mult)))
pnl_pct = find_exit(e['entry_price'], atr_s, e['bars'])
krw = PER_POS * (pnl_pct / 100) - PER_POS * FEE * 2
trades.append((pnl_pct, krw))
if not trades:
results.append({**params, 'trades': 0, 'wins': 0,
'win_rate': 0.0, 'avg_pnl': 0.0, 'total_krw': 0.0})
continue
wins = sum(1 for p, _ in trades if p > 0)
results.append({
**params,
'trades': len(trades),
'wins': wins,
'win_rate': wins / len(trades) * 100,
'avg_pnl': sum(p for p, _ in trades) / len(trades),
'total_krw': sum(k for _, k in trades),
})
print(f"스윕 완료 ({_time.time()-t_sweep:.1f}s)\n")
# ── 결과 출력 ─────────────────────────────────────────────────────────────────
df_r = pd.DataFrame(results)
df_r = df_r[df_r['trades'] > 0].sort_values('total_krw', ascending=False)
print("=" * 100)
print(f"{'순위':>4} {'VOL':>5} {'ATR_M':>6} {'ATR_N':>6} {'ATR_X':>6} "
f"{'건수':>5} {'승률':>6} {'평균PNL':>8} {'총손익':>14}")
print("=" * 100)
for rank, (_, row) in enumerate(df_r.head(20).iterrows(), 1):
print(f"{rank:>4} {row['VOL']:>4.0f}x {row['ATR_MULT']:>6.1f} "
f"{row['ATR_MIN']*100:>5.1f}% {row['ATR_MAX']*100:>5.1f}% "
f"{int(row['trades']):>5}{row['win_rate']:>5.0f}% "
f"{row['avg_pnl']:>+7.2f}% {row['total_krw']:>+14,.0f}")
# VOL별 최상위 요약
print("\n" + "" * 75)
print(f" {'VOL':>5} {'건수':>5} {'승률':>5} {'평균PNL':>8} {'총손익':>14} (최적 ATR)")
print("" * 75)
for vol in SWEEP["VOL"]:
sub = df_r[df_r['VOL'] == vol]
if sub.empty:
continue
best = sub.iloc[0]
print(f" {vol:>4.0f}x {int(best['trades']):>5}{best['win_rate']:>4.0f}% "
f"{best['avg_pnl']:>+7.2f}% {best['total_krw']:>+14,.0f}"
f"(M={best['ATR_MULT']:.1f} N={best['ATR_MIN']*100:.1f}% X={best['ATR_MAX']*100:.1f}%)")

357
archive/tests/sweep_nbar.py Normal file
View File

@@ -0,0 +1,357 @@
"""1분봉 N봉 연속 상승 vol spike 전략 파라미터 스윕.
N=2/3/4 연속 조건 (양봉 + 상승 + vol spike) 모두 Oracle SQL에서 처리.
Python은 ATR 파라미터 + VOL 임계값 스윕만 담당.
시그널 조건 (N_BARS=N):
봉[n-(N-1)]~봉[n]: 모두 vol_ratio >= VOL_MIN, 양봉, 연속 상승
진입: 봉[n+1] close 즉시
추적: 1분봉 trail stop (ATR) + time stop / 월별 배치
"""
import sys, os, itertools
from datetime import datetime, timedelta
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from dotenv import load_dotenv
load_dotenv(os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), '.env'))
import pandas as pd
import oracledb
import time as _time
# ── 고정 파라미터 ─────────────────────────────────────────────────────────────
VOL_LOOKBACK = 61
ATR_LOOKBACK = 28
TS_N = 240
TIME_STOP_PCT = 0.0 / 100
FEE = 0.0005
BUDGET = 15_000_000
MAX_POS = 3
PER_POS = BUDGET // MAX_POS
# ── 스윕 파라미터 ─────────────────────────────────────────────────────────────
VOL_SWEEP = [1.5, 2.0, 3.0, 5.0, 8.0, 10.0, 15.0, 20.0, 25.0]
ATR_SWEEP = {
"ATR_MULT": [1.5, 2.0, 2.5, 3.0],
"ATR_MIN": [0.005, 0.010, 0.015],
"ATR_MAX": [0.020, 0.025, 0.030],
}
N_BARS_LIST = [2, 3, 4]
VOL_MIN = min(VOL_SWEEP) # SQL pre-filter (모든 봉 >= 1.5x) (모든 봉에 적용)
# ── 시뮬 구간 ─────────────────────────────────────────────────────────────────
SIM_START = datetime(2025, 8, 1)
SIM_END = datetime(2026, 3, 4)
WARMUP_MINS = 120
TICKERS = [
'KRW-XRP','KRW-BTC','KRW-ETH','KRW-SOL','KRW-DOGE',
'KRW-ADA','KRW-SUI','KRW-NEAR','KRW-KAVA','KRW-SXP',
'KRW-AKT','KRW-SONIC','KRW-IP','KRW-ORBS','KRW-VIRTUAL',
'KRW-BARD','KRW-XPL','KRW-KITE','KRW-ENSO','KRW-0G',
]
_TK = ",".join(f"'{t}'" for t in TICKERS)
def _months(start: datetime, end: datetime):
m = start.replace(day=1)
while m < end:
nxt = (m + timedelta(days=32)).replace(day=1)
if nxt > end:
nxt = end
yield m, nxt
m = nxt
def _get_conn():
kwargs = dict(user=os.environ["ORACLE_USER"],
password=os.environ["ORACLE_PASSWORD"],
dsn=os.environ["ORACLE_DSN"])
wallet = os.environ.get("ORACLE_WALLET")
if wallet:
kwargs["config_dir"] = wallet
return oracledb.connect(**kwargs)
# ── N별 SQL 생성 ──────────────────────────────────────────────────────────────
# 공통 CTE: base(TR + LAG N개), indicators(vol_ratio N개 + ATR)
# signals: N봉 연속 조건 모두 SQL에서 처리
# vol_ratio 컬럼은 Python VOL 스윕용으로 모두 반환
def build_sql(n: int) -> str:
"""n봉 연속 조건을 SQL로 구현. 반환 컬럼에 vol_ratio 0~n-1 포함."""
# LAG 컬럼 정의 (base CTE)
lag_cols = "\n".join(
f" LAG(close_p, {i}) OVER (PARTITION BY ticker ORDER BY ts) prev_close_{i},\n"
f" LAG(open_p, {i}) OVER (PARTITION BY ticker ORDER BY ts) prev_open_{i},\n"
f" LAG(volume_p, {i}) OVER (PARTITION BY ticker ORDER BY ts) prev_vol_{i},"
for i in range(1, n)
)
# indicators CTE: vol_ratio 0 ~ n-1
vr_cols = []
vr_cols.append(f""" volume_p / NULLIF(
AVG(volume_p) OVER (PARTITION BY ticker ORDER BY ts
ROWS BETWEEN {VOL_LOOKBACK} PRECEDING AND 2 PRECEDING), 0
) vol_ratio_0,""")
for i in range(1, n):
vr_cols.append(f""" prev_vol_{i} / NULLIF(
AVG(volume_p) OVER (PARTITION BY ticker ORDER BY ts
ROWS BETWEEN {VOL_LOOKBACK} PRECEDING AND 2 PRECEDING), 0
) vol_ratio_{i},""")
vr_cols_str = "\n".join(vr_cols)
# pass-through LAG 컬럼 from base → indicators
lag_passthrough = "\n".join(
f" prev_close_{i}, prev_open_{i},"
for i in range(1, n)
)
# signals WHERE 조건: 현재봉 + 이전 n-1봉 all 양봉 + 연속 상승 + vol
cond_lines = [
" AND vol_ratio_0 >= :min_vol",
" AND close_p > open_p", # 현재봉 양봉
]
for i in range(1, n):
cond_lines.append(f" AND vol_ratio_{i} >= :min_vol")
cond_lines.append(f" AND prev_close_{i} > prev_open_{i}") # 양봉
if i == 1:
cond_lines.append(f" AND close_p > prev_close_{i}") # 현재봉 > 1봉전
else:
cond_lines.append(f" AND prev_close_{i-1} > prev_close_{i}") # 상승 연속
cond_str = "\n".join(cond_lines)
# SELECT: vol_ratio 0~n-1 반환 (Python VOL 스윕용)
vr_select = ", ".join(f"vol_ratio_{i}" for i in range(n))
return f"""
WITH
base AS (
SELECT ticker, ts, open_p, close_p, high_p, low_p, volume_p,
{lag_cols}
GREATEST(
high_p - low_p,
ABS(high_p - LAG(close_p,1) OVER (PARTITION BY ticker ORDER BY ts)),
ABS(low_p - LAG(close_p,1) OVER (PARTITION BY ticker ORDER BY ts))
) tr
FROM backtest_ohlcv
WHERE interval_cd = 'minute1'
AND ts >= TO_TIMESTAMP(:load_since, 'YYYY-MM-DD HH24:MI:SS')
AND ts < TO_TIMESTAMP(:sim_end, 'YYYY-MM-DD HH24:MI:SS')
AND ticker IN ({_TK})
),
indicators AS (
SELECT ticker, ts, open_p, close_p,
{lag_passthrough}
{vr_cols_str}
AVG(tr) OVER (PARTITION BY ticker ORDER BY ts
ROWS BETWEEN {ATR_LOOKBACK} PRECEDING AND 1 PRECEDING)
/ NULLIF(prev_close_1, 0) atr_raw
FROM base
),
signals AS (
SELECT ticker, ts sig_ts, close_p sig_price,
{vr_select}, atr_raw
FROM indicators
WHERE ts >= TO_TIMESTAMP(:sim_start, 'YYYY-MM-DD HH24:MI:SS')
AND ts < TO_TIMESTAMP(:sim_end, 'YYYY-MM-DD HH24:MI:SS')
{cond_str}
),
entry_cands AS (
SELECT s.ticker, s.sig_ts, {vr_select.replace('vol_ratio_', 's.vol_ratio_')}, s.atr_raw,
e.ts entry_ts, e.close_p entry_price,
ROW_NUMBER() OVER (PARTITION BY s.ticker, s.sig_ts ORDER BY e.ts) rn
FROM signals s
JOIN backtest_ohlcv e
ON e.ticker = s.ticker
AND e.interval_cd = 'minute1'
AND e.ts > s.sig_ts
AND e.ts <= s.sig_ts + INTERVAL '3' MINUTE
),
entries AS (
SELECT ticker, sig_ts, {vr_select}, atr_raw, entry_ts, entry_price
FROM entry_cands WHERE rn = 1
),
post_entry AS (
SELECT
e.ticker, e.sig_ts, e.entry_ts, e.entry_price,
{vr_select.replace('vol_ratio_', 'e.vol_ratio_')}, e.atr_raw,
b.close_p bar_price,
ROW_NUMBER() OVER (PARTITION BY e.ticker, e.entry_ts ORDER BY b.ts) bar_n,
MAX(b.close_p) OVER (PARTITION BY e.ticker, e.entry_ts
ORDER BY b.ts ROWS UNBOUNDED PRECEDING) running_peak
FROM entries e
JOIN backtest_ohlcv b
ON b.ticker = e.ticker
AND b.interval_cd = 'minute1'
AND b.ts >= e.entry_ts
AND b.ts <= e.entry_ts + INTERVAL '{TS_N}' MINUTE
)
SELECT ticker, sig_ts, entry_ts, entry_price,
{vr_select}, atr_raw,
bar_n, bar_price, running_peak
FROM post_entry
WHERE bar_n <= :ts_n + 1
ORDER BY ticker, entry_ts, bar_n
"""
# ── 월별 데이터 로드 ──────────────────────────────────────────────────────────
print(f"N봉 연속 vol spike 전략 (VOL>={VOL_MIN}x, N={N_BARS_LIST})\n", flush=True)
conn = _get_conn()
cur = conn.cursor()
cur.arraysize = 100_000
# N별로 별도 딕셔너리
ALL_ENTRIES: dict[int, dict] = {n: {} for n in N_BARS_LIST}
t_load = _time.time()
for n in N_BARS_LIST:
sql = build_sql(n)
print(f"── {n}봉 로드 중... ──────────────────────────", flush=True)
n_total = 0
for m_start, m_end in _months(SIM_START, SIM_END):
load_since = (m_start - timedelta(minutes=WARMUP_MINS)).strftime('%Y-%m-%d %H:%M:%S')
sim_start = m_start.strftime('%Y-%m-%d %H:%M:%S')
sim_end = m_end.strftime('%Y-%m-%d %H:%M:%S')
t0 = _time.time()
cur.execute(sql, {
"load_since": load_since,
"sim_start": sim_start,
"sim_end": sim_end,
"min_vol": VOL_MIN,
"ts_n": TS_N,
})
rows = cur.fetchall()
t1 = _time.time()
n_new = 0
for row in rows:
# ticker, sig_ts, entry_ts, entry_price, vr0..vr(n-1), atr_raw, bar_n, bar_price, peak
ticker = row[0]
entry_ts = row[2]
entry_price= float(row[3])
vr_vals = [float(row[4 + i]) if row[4 + i] is not None else 0.0 for i in range(n)]
atr_raw = row[4 + n]
bar_price = float(row[4 + n + 2]) # bar_n is at 4+n+1
running_peak = float(row[4 + n + 3])
key = (ticker, entry_ts)
if key not in ALL_ENTRIES[n]:
ALL_ENTRIES[n][key] = {
'entry_price': entry_price,
'vr': vr_vals,
'atr_raw': float(atr_raw) if atr_raw is not None else float('nan'),
'bars': [],
}
n_new += 1
ALL_ENTRIES[n][key]['bars'].append((bar_price, running_peak))
n_total += n_new
print(f" {sim_start[:7]}: {len(rows):>8,}행 ({t1-t0:.1f}s) | 진입 {n_new:>5}", flush=True)
print(f"{n}봉 합계: {n_total}\n", flush=True)
conn.close()
print(f"전체 로드 완료 ({_time.time()-t_load:.1f}s)\n", flush=True)
# ── 출구 탐색 ─────────────────────────────────────────────────────────────────
def find_exit(entry_price: float, atr_stop: float, bars: list) -> float:
for i, (bp, pk) in enumerate(bars):
drop = (pk - bp) / pk if pk > 0 else 0.0
pnl = (bp - entry_price) / entry_price
if drop >= atr_stop:
return pnl * 100
if i + 1 >= TS_N and pnl < TIME_STOP_PCT:
return pnl * 100
return (bars[-1][0] - entry_price) / entry_price * 100 if bars else 0.0
# ── 스윕 ──────────────────────────────────────────────────────────────────────
atr_keys = list(ATR_SWEEP.keys())
atr_combos = list(itertools.product(*ATR_SWEEP.values()))
total_combos = len(N_BARS_LIST) * len(VOL_SWEEP) * len(atr_combos)
print(f"{total_combos}가지 조합 스윕...\n", flush=True)
t_sweep = _time.time()
results = []
for n in N_BARS_LIST:
entry_list = list(ALL_ENTRIES[n].values())
for vol in VOL_SWEEP:
for atr_combo in atr_combos:
atr_params = dict(zip(atr_keys, atr_combo))
if atr_params['ATR_MIN'] >= atr_params['ATR_MAX']:
continue
atr_mult = atr_params['ATR_MULT']
atr_min = atr_params['ATR_MIN']
atr_max = atr_params['ATR_MAX']
trades = []
for e in entry_list:
# Python: vol 임계값 최종 필터 (모든 봉 vol >= vol)
if min(e['vr']) < vol:
continue
ar = e['atr_raw']
atr_s = (atr_min if (ar != ar)
else max(atr_min, min(atr_max, ar * atr_mult)))
pnl_pct = find_exit(e['entry_price'], atr_s, e['bars'])
krw = PER_POS * (pnl_pct / 100) - PER_POS * FEE * 2
trades.append((pnl_pct, krw))
if not trades:
results.append({'N_BARS': n, 'VOL': vol, **atr_params,
'trades': 0, 'wins': 0,
'win_rate': 0.0, 'avg_pnl': 0.0, 'total_krw': 0.0})
continue
wins = sum(1 for p, _ in trades if p > 0)
results.append({
'N_BARS': n,
'VOL': vol,
**atr_params,
'trades': len(trades),
'wins': wins,
'win_rate': wins / len(trades) * 100,
'avg_pnl': sum(p for p, _ in trades) / len(trades),
'total_krw': sum(k for _, k in trades),
})
print(f"스윕 완료 ({_time.time()-t_sweep:.1f}s)\n")
# ── 결과 출력 ─────────────────────────────────────────────────────────────────
df_r = pd.DataFrame(results)
df_r = df_r[df_r['trades'] > 0].sort_values('total_krw', ascending=False)
print("=" * 105)
print(f"{'순위':>4} {'N봉':>4} {'VOL':>5} {'ATR_M':>6} {'ATR_N':>6} {'ATR_X':>6} "
f"{'건수':>5} {'승률':>6} {'평균PNL':>8} {'총손익':>14}")
print("=" * 105)
for rank, (_, row) in enumerate(df_r.head(30).iterrows(), 1):
print(f"{rank:>4} {int(row['N_BARS']):>2}{row['VOL']:>4.0f}x {row['ATR_MULT']:>6.1f} "
f"{row['ATR_MIN']*100:>5.1f}% {row['ATR_MAX']*100:>5.1f}% "
f"{int(row['trades']):>5}{row['win_rate']:>5.0f}% "
f"{row['avg_pnl']:>+7.2f}% {row['total_krw']:>+14,.0f}")
# N봉 × VOL별 최상위 요약
print("\n" + "" * 85)
print(f" {'N봉':>3} {'VOL':>5} {'건수':>5} {'승률':>5} {'평균PNL':>8} {'총손익':>14} (최적 ATR)")
print("" * 85)
for n in N_BARS_LIST:
for vol in VOL_SWEEP:
sub = df_r[(df_r['N_BARS'] == n) & (df_r['VOL'] == vol)]
if sub.empty:
continue
best = sub.iloc[0]
if best['trades'] == 0:
continue
print(f" {int(n):>2}{vol:>4.0f}x {int(best['trades']):>5}{best['win_rate']:>4.0f}% "
f"{best['avg_pnl']:>+7.2f}% {best['total_krw']:>+14,.0f}"
f"(M={best['ATR_MULT']:.1f} N={best['ATR_MIN']*100:.1f}% X={best['ATR_MAX']*100:.1f}%)")
print()

View File

@@ -0,0 +1,355 @@
"""1분봉 볼륨 가속 전략 파라미터 스윕.
시그널 조건: N봉 연속으로 가격 AND 거래량이 함께 증가
봉[n-(N-1)] < 봉[n-(N-2)] < ... < 봉[n]
- 각 봉: 양봉 (close > open)
- 가격 연속 상승: close[k] > close[k-1]
- 볼륨 연속 증가: vol_ratio[k] > vol_ratio[k-1]
- 현재봉(가장 강한 봉) vol_ratio >= VOL_MIN (pre-filter)
진입: 봉[n+1] close 즉시
추적: 1분봉 trail stop (ATR) + time stop / 월별 배치
"""
import sys, os, itertools
from datetime import datetime, timedelta
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from dotenv import load_dotenv
load_dotenv(os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), '.env'))
import pandas as pd
import oracledb
import time as _time
# ── 고정 파라미터 ─────────────────────────────────────────────────────────────
VOL_LOOKBACK = 61
ATR_LOOKBACK = 28
TS_N = 240
TIME_STOP_PCT = 0.0 / 100
FEE = 0.0005
BUDGET = 15_000_000
MAX_POS = 3
PER_POS = BUDGET // MAX_POS
# ── 스윕 파라미터 ─────────────────────────────────────────────────────────────
# 현재봉(가장 강한 봉)의 vol_ratio 최솟값 — 가속의 "끝점" 기준
VOL_SWEEP = [2.0, 3.0, 4.0, 5.0, 8.0, 10.0, 15.0]
ATR_SWEEP = {
"ATR_MULT": [1.5, 2.0, 2.5, 3.0],
"ATR_MIN": [0.005, 0.010, 0.015],
"ATR_MAX": [0.020, 0.025, 0.030],
}
N_BARS_LIST = [2, 3, 4]
VOL_MIN = min(VOL_SWEEP) # SQL pre-filter
# ── 시뮬 구간 ─────────────────────────────────────────────────────────────────
SIM_START = datetime(2025, 8, 1)
SIM_END = datetime(2026, 3, 4)
WARMUP_MINS = 120
TICKERS = [
'KRW-XRP','KRW-BTC','KRW-ETH','KRW-SOL','KRW-DOGE',
'KRW-ADA','KRW-SUI','KRW-NEAR','KRW-KAVA','KRW-SXP',
'KRW-AKT','KRW-SONIC','KRW-IP','KRW-ORBS','KRW-VIRTUAL',
'KRW-BARD','KRW-XPL','KRW-KITE','KRW-ENSO','KRW-0G',
]
_TK = ",".join(f"'{t}'" for t in TICKERS)
def _months(start: datetime, end: datetime):
m = start.replace(day=1)
while m < end:
nxt = (m + timedelta(days=32)).replace(day=1)
if nxt > end:
nxt = end
yield m, nxt
m = nxt
def _get_conn():
kwargs = dict(user=os.environ["ORACLE_USER"],
password=os.environ["ORACLE_PASSWORD"],
dsn=os.environ["ORACLE_DSN"])
wallet = os.environ.get("ORACLE_WALLET")
if wallet:
kwargs["config_dir"] = wallet
return oracledb.connect(**kwargs)
# ── N별 SQL 생성 ──────────────────────────────────────────────────────────────
# 조건: 가격 연속 상승 + 볼륨 연속 증가 (+ 양봉)
# vol_ratio_0 > vol_ratio_1 > ... > vol_ratio_(n-1)
# close_0 > close_1 > ... > close_(n-1)
# 모두 Oracle SQL에서 처리
def build_sql(n: int) -> str:
lag_cols = "\n".join(
f" LAG(close_p, {i}) OVER (PARTITION BY ticker ORDER BY ts) prev_close_{i},\n"
f" LAG(open_p, {i}) OVER (PARTITION BY ticker ORDER BY ts) prev_open_{i},\n"
f" LAG(volume_p, {i}) OVER (PARTITION BY ticker ORDER BY ts) prev_vol_{i},"
for i in range(1, n)
)
vr_cols = []
vr_cols.append(f""" volume_p / NULLIF(
AVG(volume_p) OVER (PARTITION BY ticker ORDER BY ts
ROWS BETWEEN {VOL_LOOKBACK} PRECEDING AND 2 PRECEDING), 0
) vol_ratio_0,""")
for i in range(1, n):
vr_cols.append(f""" prev_vol_{i} / NULLIF(
AVG(volume_p) OVER (PARTITION BY ticker ORDER BY ts
ROWS BETWEEN {VOL_LOOKBACK} PRECEDING AND 2 PRECEDING), 0
) vol_ratio_{i},""")
vr_cols_str = "\n".join(vr_cols)
lag_passthrough = "\n".join(
f" prev_close_{i}, prev_open_{i},"
for i in range(1, n)
)
# 조건: 양봉 + 가격 연속 상승 + 볼륨 연속 증가
cond_lines = [
" AND vol_ratio_0 >= :min_vol", # pre-filter: 현재봉(최강봉)
" AND close_p > open_p", # 현재봉 양봉
]
for i in range(1, n):
cond_lines.append(f" AND prev_close_{i} > prev_open_{i}") # 이전봉 양봉
if i == 1:
cond_lines.append(f" AND close_p > prev_close_{i}") # 가격 상승
cond_lines.append(f" AND vol_ratio_0 > vol_ratio_{i}") # 볼륨 증가
else:
cond_lines.append(f" AND prev_close_{i-1} > prev_close_{i}") # 가격 상승
cond_lines.append(f" AND vol_ratio_{i-1} > vol_ratio_{i}") # 볼륨 증가
cond_str = "\n".join(cond_lines)
vr_select = ", ".join(f"vol_ratio_{i}" for i in range(n))
return f"""
WITH
base AS (
SELECT ticker, ts, open_p, close_p, high_p, low_p, volume_p,
{lag_cols}
GREATEST(
high_p - low_p,
ABS(high_p - LAG(close_p,1) OVER (PARTITION BY ticker ORDER BY ts)),
ABS(low_p - LAG(close_p,1) OVER (PARTITION BY ticker ORDER BY ts))
) tr
FROM backtest_ohlcv
WHERE interval_cd = 'minute1'
AND ts >= TO_TIMESTAMP(:load_since, 'YYYY-MM-DD HH24:MI:SS')
AND ts < TO_TIMESTAMP(:sim_end, 'YYYY-MM-DD HH24:MI:SS')
AND ticker IN ({_TK})
),
indicators AS (
SELECT ticker, ts, open_p, close_p,
{lag_passthrough}
{vr_cols_str}
AVG(tr) OVER (PARTITION BY ticker ORDER BY ts
ROWS BETWEEN {ATR_LOOKBACK} PRECEDING AND 1 PRECEDING)
/ NULLIF(prev_close_1, 0) atr_raw
FROM base
),
signals AS (
SELECT ticker, ts sig_ts, close_p sig_price,
{vr_select}, atr_raw
FROM indicators
WHERE ts >= TO_TIMESTAMP(:sim_start, 'YYYY-MM-DD HH24:MI:SS')
AND ts < TO_TIMESTAMP(:sim_end, 'YYYY-MM-DD HH24:MI:SS')
{cond_str}
),
entry_cands AS (
SELECT s.ticker, s.sig_ts,
{vr_select.replace('vol_ratio_', 's.vol_ratio_')}, s.atr_raw,
e.ts entry_ts, e.close_p entry_price,
ROW_NUMBER() OVER (PARTITION BY s.ticker, s.sig_ts ORDER BY e.ts) rn
FROM signals s
JOIN backtest_ohlcv e
ON e.ticker = s.ticker
AND e.interval_cd = 'minute1'
AND e.ts > s.sig_ts
AND e.ts <= s.sig_ts + INTERVAL '3' MINUTE
),
entries AS (
SELECT ticker, sig_ts, {vr_select}, atr_raw, entry_ts, entry_price
FROM entry_cands WHERE rn = 1
),
post_entry AS (
SELECT
e.ticker, e.sig_ts, e.entry_ts, e.entry_price,
{vr_select.replace('vol_ratio_', 'e.vol_ratio_')}, e.atr_raw,
b.close_p bar_price,
ROW_NUMBER() OVER (PARTITION BY e.ticker, e.entry_ts ORDER BY b.ts) bar_n,
MAX(b.close_p) OVER (PARTITION BY e.ticker, e.entry_ts
ORDER BY b.ts ROWS UNBOUNDED PRECEDING) running_peak
FROM entries e
JOIN backtest_ohlcv b
ON b.ticker = e.ticker
AND b.interval_cd = 'minute1'
AND b.ts >= e.entry_ts
AND b.ts <= e.entry_ts + INTERVAL '{TS_N}' MINUTE
)
SELECT ticker, sig_ts, entry_ts, entry_price,
{vr_select}, atr_raw,
bar_n, bar_price, running_peak
FROM post_entry
WHERE bar_n <= :ts_n + 1
ORDER BY ticker, entry_ts, bar_n
"""
# ── 월별 데이터 로드 ──────────────────────────────────────────────────────────
print(f"볼륨 가속 전략 (현재봉 VOL>={VOL_MIN}x, N={N_BARS_LIST}, 가격·볼륨 동시 가속)\n", flush=True)
conn = _get_conn()
cur = conn.cursor()
cur.arraysize = 100_000
ALL_ENTRIES: dict[int, dict] = {n: {} for n in N_BARS_LIST}
t_load = _time.time()
for n in N_BARS_LIST:
sql = build_sql(n)
print(f"── {n}봉 로드 중... ──────────────────────────", flush=True)
n_total = 0
for m_start, m_end in _months(SIM_START, SIM_END):
load_since = (m_start - timedelta(minutes=WARMUP_MINS)).strftime('%Y-%m-%d %H:%M:%S')
sim_start = m_start.strftime('%Y-%m-%d %H:%M:%S')
sim_end = m_end.strftime('%Y-%m-%d %H:%M:%S')
t0 = _time.time()
cur.execute(sql, {
"load_since": load_since,
"sim_start": sim_start,
"sim_end": sim_end,
"min_vol": VOL_MIN,
"ts_n": TS_N,
})
rows = cur.fetchall()
t1 = _time.time()
n_new = 0
for row in rows:
ticker = row[0]
entry_ts = row[2]
entry_price = float(row[3])
vr_vals = [float(row[4 + i]) if row[4 + i] is not None else 0.0 for i in range(n)]
atr_raw = row[4 + n]
bar_price = float(row[4 + n + 2])
running_peak= float(row[4 + n + 3])
key = (ticker, entry_ts)
if key not in ALL_ENTRIES[n]:
ALL_ENTRIES[n][key] = {
'entry_price': entry_price,
'vr0': vr_vals[0], # 현재봉 vol (가장 강한 봉)
'atr_raw': float(atr_raw) if atr_raw is not None else float('nan'),
'bars': [],
}
n_new += 1
ALL_ENTRIES[n][key]['bars'].append((bar_price, running_peak))
n_total += n_new
print(f" {sim_start[:7]}: {len(rows):>8,}행 ({t1-t0:.1f}s) | 진입 {n_new:>5}", flush=True)
print(f"{n}봉 합계: {n_total}\n", flush=True)
conn.close()
print(f"전체 로드 완료 ({_time.time()-t_load:.1f}s)\n", flush=True)
# ── 출구 탐색 ─────────────────────────────────────────────────────────────────
def find_exit(entry_price: float, atr_stop: float, bars: list) -> float:
for i, (bp, pk) in enumerate(bars):
drop = (pk - bp) / pk if pk > 0 else 0.0
pnl = (bp - entry_price) / entry_price
if drop >= atr_stop:
return pnl * 100
if i + 1 >= TS_N and pnl < TIME_STOP_PCT:
return pnl * 100
return (bars[-1][0] - entry_price) / entry_price * 100 if bars else 0.0
# ── 스윕 ──────────────────────────────────────────────────────────────────────
atr_keys = list(ATR_SWEEP.keys())
atr_combos = list(itertools.product(*ATR_SWEEP.values()))
total_combos = len(N_BARS_LIST) * len(VOL_SWEEP) * len(atr_combos)
print(f"{total_combos}가지 조합 스윕...\n", flush=True)
t_sweep = _time.time()
results = []
for n in N_BARS_LIST:
entry_list = list(ALL_ENTRIES[n].values())
for vol in VOL_SWEEP:
for atr_combo in atr_combos:
atr_params = dict(zip(atr_keys, atr_combo))
if atr_params['ATR_MIN'] >= atr_params['ATR_MAX']:
continue
atr_mult = atr_params['ATR_MULT']
atr_min = atr_params['ATR_MIN']
atr_max = atr_params['ATR_MAX']
trades = []
for e in entry_list:
# 현재봉(최강봉) vol 기준으로 필터
if e['vr0'] < vol:
continue
ar = e['atr_raw']
atr_s = (atr_min if (ar != ar)
else max(atr_min, min(atr_max, ar * atr_mult)))
pnl_pct = find_exit(e['entry_price'], atr_s, e['bars'])
krw = PER_POS * (pnl_pct / 100) - PER_POS * FEE * 2
trades.append((pnl_pct, krw))
if not trades:
results.append({'N_BARS': n, 'VOL': vol, **atr_params,
'trades': 0, 'wins': 0,
'win_rate': 0.0, 'avg_pnl': 0.0, 'total_krw': 0.0})
continue
wins = sum(1 for p, _ in trades if p > 0)
results.append({
'N_BARS': n,
'VOL': vol,
**atr_params,
'trades': len(trades),
'wins': wins,
'win_rate': wins / len(trades) * 100,
'avg_pnl': sum(p for p, _ in trades) / len(trades),
'total_krw': sum(k for _, k in trades),
})
print(f"스윕 완료 ({_time.time()-t_sweep:.1f}s)\n")
# ── 결과 출력 ─────────────────────────────────────────────────────────────────
df_r = pd.DataFrame(results)
df_r = df_r[df_r['trades'] > 0].sort_values('total_krw', ascending=False)
print("=" * 105)
print(f"{'순위':>4} {'N봉':>4} {'VOL':>5} {'ATR_M':>6} {'ATR_N':>6} {'ATR_X':>6} "
f"{'건수':>5} {'승률':>6} {'평균PNL':>8} {'총손익':>14}")
print("=" * 105)
for rank, (_, row) in enumerate(df_r.head(30).iterrows(), 1):
print(f"{rank:>4} {int(row['N_BARS']):>2}{row['VOL']:>4.1f}x {row['ATR_MULT']:>6.1f} "
f"{row['ATR_MIN']*100:>5.1f}% {row['ATR_MAX']*100:>5.1f}% "
f"{int(row['trades']):>5}{row['win_rate']:>5.0f}% "
f"{row['avg_pnl']:>+7.2f}% {row['total_krw']:>+14,.0f}")
# N봉 × VOL별 최상위 요약
print("\n" + "" * 85)
print(f" {'N봉':>3} {'VOL':>5} {'건수':>5} {'승률':>5} {'평균PNL':>8} {'총손익':>14} (최적 ATR)")
print("" * 85)
for n in N_BARS_LIST:
for vol in VOL_SWEEP:
sub = df_r[(df_r['N_BARS'] == n) & (df_r['VOL'] == vol)]
if sub.empty:
continue
best = sub.iloc[0]
if best['trades'] == 0:
continue
print(f" {int(n):>2}{vol:>4.1f}x {int(best['trades']):>5}{best['win_rate']:>4.0f}% "
f"{best['avg_pnl']:>+7.2f}% {best['total_krw']:>+14,.0f}"
f"(M={best['ATR_MULT']:.1f} N={best['ATR_MIN']*100:.1f}% X={best['ATR_MAX']*100:.1f}%)")
print()

View File

@@ -0,0 +1,187 @@
"""core/llm_advisor 단위 테스트.
실행:
.venv/bin/python3 -m pytest tests/test_llm_advisor.py -v
"""
import json
import sys
import os
from datetime import datetime, timedelta
from unittest.mock import MagicMock, patch
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
os.environ.setdefault('ORACLE_USER', 'x')
os.environ.setdefault('ORACLE_PASSWORD', 'x')
os.environ.setdefault('ORACLE_DSN', 'x')
os.environ.setdefault('ANTHROPIC_API_KEY', 'test-key')
from core.llm_advisor import (
get_exit_price,
_describe_bars,
_build_prompt,
_execute_tool,
)
TICKER = 'KRW-XRP'
def _make_bars(n: int = 20, base: float = 2100.0) -> list[dict]:
now = datetime.now()
bars = []
for i in range(n):
p = base + i * 0.5
bars.append({
'open': p,
'high': p + 2,
'low': p - 2,
'close': p + 1,
'volume': 100.0,
'ts': now - timedelta(seconds=(n - i) * 20),
})
return bars
def _make_pos(entry: float = 2084.0, seconds_ago: int = 120,
sell_price: float = 2126.0) -> dict:
return {
'entry_price': entry,
'entry_ts': datetime.now() - timedelta(seconds=seconds_ago),
'sell_price': sell_price,
'sell_uuid': 'uuid-1',
'qty': 2399.0,
'stage': 0,
'llm_last_ts': None,
}
def _mock_response(action: str, price: float = 0):
"""Anthropic API 응답 Mock."""
if action == 'hold':
text = '{"action": "hold"}'
else:
text = json.dumps({'action': 'sell', 'price': price})
content_block = MagicMock()
content_block.type = 'text'
content_block.text = text
response = MagicMock()
response.content = [content_block]
response.stop_reason = 'end_turn'
return response
# ── _describe_bars ────────────────────────────────────────────────────────────
class TestDescribeBars:
def test_returns_string(self):
bars = _make_bars(20, base=2100.0)
desc = _describe_bars(bars, current_price=2110.0)
assert isinstance(desc, str)
assert '패턴 요약' in desc
def test_empty_bars_returns_fallback(self):
desc = _describe_bars([], current_price=2100.0)
assert desc == '봉 데이터 없음'
def test_trend_direction_detected(self):
bars = _make_bars(20, base=2100.0) # 상승 bars (base + i*0.5)
desc = _describe_bars(bars, current_price=2110.0)
assert '상승▲' in desc
# ── get_exit_price: hold ──────────────────────────────────────────────────────
class TestGetExitPriceHold:
def test_returns_none_on_hold(self):
pos = _make_pos()
bar_list = _make_bars()
with patch('anthropic.Anthropic') as MockClient:
MockClient.return_value.messages.create.return_value = _mock_response('hold')
result = get_exit_price(TICKER, pos, bar_list, current_price=2109.0)
assert result is None
def test_returns_none_when_no_api_key(self):
pos = _make_pos()
bar_list = _make_bars()
with patch.dict(os.environ, {'ANTHROPIC_API_KEY': ''}):
result = get_exit_price(TICKER, pos, bar_list, current_price=2109.0)
assert result is None
# ── get_exit_price: sell ─────────────────────────────────────────────────────
class TestGetExitPriceSell:
def test_returns_llm_suggested_price(self):
pos = _make_pos(entry=2084.0, sell_price=2126.0)
bar_list = _make_bars()
with patch('anthropic.Anthropic') as MockClient:
MockClient.return_value.messages.create.return_value = _mock_response('sell', 2112.0)
result = get_exit_price(TICKER, pos, bar_list, current_price=2109.0)
assert result == 2112.0
def test_llm_can_suggest_below_current_price(self):
"""LLM의 판단을 신뢰 — 현재가 이하 제안도 그대로 반환."""
pos = _make_pos(entry=2084.0, sell_price=2126.0)
bar_list = _make_bars()
with patch('anthropic.Anthropic') as MockClient:
MockClient.return_value.messages.create.return_value = _mock_response('sell', 2080.0)
result = get_exit_price(TICKER, pos, bar_list, current_price=2109.0)
assert result == 2080.0 # 가드 없음 — LLM 신뢰
def test_llm_can_suggest_high_price(self):
"""LLM의 판단을 신뢰 — 진입가 대비 10% 높은 제안도 그대로 반환."""
pos = _make_pos(entry=2084.0, sell_price=2126.0)
bar_list = _make_bars()
with patch('anthropic.Anthropic') as MockClient:
MockClient.return_value.messages.create.return_value = _mock_response('sell', 2300.0)
result = get_exit_price(TICKER, pos, bar_list, current_price=2109.0)
assert result == 2300.0 # 상한 가드 없음 — LLM 신뢰
# ── get_exit_price: 오류 처리 ─────────────────────────────────────────────────
class TestGetExitPriceErrors:
def test_returns_none_on_json_error(self):
"""JSON 파싱 실패 → None (cascade fallback)."""
pos = _make_pos()
bar_list = _make_bars()
bad_resp = MagicMock()
bad_resp.content = [MagicMock(type='text', text='not json')]
bad_resp.stop_reason = 'end_turn'
with patch('anthropic.Anthropic') as MockClient:
MockClient.return_value.messages.create.return_value = bad_resp
result = get_exit_price(TICKER, pos, bar_list, current_price=2109.0)
assert result is None
def test_returns_none_on_api_exception(self):
"""API 오류 → None (cascade fallback)."""
pos = _make_pos()
bar_list = _make_bars()
with patch('anthropic.Anthropic') as MockClient:
MockClient.return_value.messages.create.side_effect = Exception('API Error')
result = get_exit_price(TICKER, pos, bar_list, current_price=2109.0)
assert result is None
# ── tool 실행 ─────────────────────────────────────────────────────────────────
class TestExecuteTool:
def test_unknown_tool_returns_error_string(self):
result = _execute_tool('unknown_tool', {'ticker': TICKER})
assert '알 수 없는 tool' in result
def test_get_price_ticks_db_error_returns_string(self):
"""DB 연결 실패 시 오류 문자열 반환 (예외 아님)."""
result = _execute_tool('get_price_ticks', {'ticker': TICKER, 'minutes': 5})
assert isinstance(result, str)
def test_get_ohlcv_db_error_returns_string(self):
result = _execute_tool('get_ohlcv', {'ticker': TICKER, 'limit': 10})
assert isinstance(result, str)

View File

@@ -0,0 +1,216 @@
"""tick_trader 핵심 로직 단위 테스트.
실행:
.venv/bin/python3 -m pytest tests/test_tick_trader.py -v
테스트 대상:
- update_positions: Trail Stop 발동 시점 / peak 초기화
- _advance_stage: cascade 단계 전환 / trail 전환
- check_filled_positions: 체결 확인 / 단계 시간 초과
- enter_position: sell_uuid=None(주문실패)일 때 즉시 Trail Stop 방지
"""
import sys, os
from datetime import datetime, timedelta
from unittest.mock import patch, MagicMock
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
# 환경변수 Mock — 실제 API 키 불필요
os.environ.setdefault('ACCESS_KEY', 'test')
os.environ.setdefault('SECRET_KEY', 'test')
os.environ.setdefault('SIMULATION_MODE', 'true')
os.environ.setdefault('MAX_POSITIONS', '3')
os.environ.setdefault('MAX_BUDGET', '15000000')
os.environ.setdefault('ORACLE_USER', 'x')
os.environ.setdefault('ORACLE_PASSWORD', 'x')
os.environ.setdefault('ORACLE_DSN', 'x')
os.environ.setdefault('TELEGRAM_TRADE_TOKEN', 'x')
os.environ.setdefault('TELEGRAM_CHAT_ID', '0')
# pyupbit 임포트 전 Mock 처리
with patch('pyupbit.Upbit'), patch('pyupbit.WebSocketManager'):
import importlib
import daemons.tick_trader as tt
TICKER = 'KRW-TEST'
def _make_pos(entry_price=1000.0, seconds_ago=0, sell_uuid='uuid-1', stage=0):
"""테스트용 포지션 딕셔너리 생성."""
return {
'entry_price': entry_price,
'entry_ts': datetime.now() - timedelta(seconds=seconds_ago),
'running_peak': entry_price,
'qty': 100.0,
'stage': stage,
'sell_uuid': sell_uuid,
'sell_price': entry_price * 1.02,
'trail_peak_set': False,
}
# ── update_positions ──────────────────────────────────────────────────────────
class TestUpdatePositions:
def setup_method(self):
tt.positions.clear()
def test_trail_stop_does_not_fire_before_stage3_end(self):
"""③ 종료(300s) 이전에는 sell_uuid=None이어도 Trail Stop 발동 안 함."""
tt.positions[TICKER] = _make_pos(
entry_price=1000, seconds_ago=10, sell_uuid=None, stage=4
)
with patch.object(tt, 'do_sell_market') as mock_sell:
tt.update_positions({TICKER: 900.0}) # -10% 하락이어도
mock_sell.assert_not_called()
def test_trail_stop_fires_after_stage3_end(self):
"""300s 경과 후 peak 대비 0.8% 이상 하락 시 Trail Stop 발동."""
tt.positions[TICKER] = _make_pos(
entry_price=1000, seconds_ago=310, sell_uuid=None, stage=4
)
# 300s 이후 peak 설정 — 첫 틱에 running_peak = 1100 초기화
tt.positions[TICKER]['trail_peak_set'] = True
tt.positions[TICKER]['running_peak'] = 1100.0
with patch.object(tt, 'do_sell_market', return_value=1091.0) as mock_sell, \
patch.object(tt, '_record_exit') as mock_exit:
tt.update_positions({TICKER: 1091.0}) # 1100→1091 = -0.82%
mock_sell.assert_called_once()
mock_exit.assert_called_once_with(TICKER, 1091.0, 'trail')
def test_trail_stop_not_fire_with_sell_uuid_set(self):
"""sell_uuid가 있으면(지정가 대기 중) Trail Stop 발동 안 함."""
tt.positions[TICKER] = _make_pos(
entry_price=1000, seconds_ago=400, sell_uuid='uuid-1', stage=3
)
tt.positions[TICKER]['trail_peak_set'] = True
tt.positions[TICKER]['running_peak'] = 1100.0
with patch.object(tt, 'do_sell_market') as mock_sell:
tt.update_positions({TICKER: 900.0})
mock_sell.assert_not_called()
def test_peak_initialized_to_current_price_at_300s(self):
"""300s 첫 틱에서 running_peak이 진입가 아닌 현재가로 초기화된다."""
pos = _make_pos(entry_price=1000, seconds_ago=305, sell_uuid=None, stage=4)
pos['trail_peak_set'] = False
tt.positions[TICKER] = pos
with patch.object(tt, 'do_sell_market'):
tt.update_positions({TICKER: 950.0}) # 진입가보다 낮은 현재가
assert tt.positions[TICKER]['running_peak'] == 950.0, \
"running_peak이 현재가(950)로 초기화되어야 함"
assert tt.positions[TICKER]['trail_peak_set'] is True
def test_trail_stop_does_not_fire_on_peak_init_tick(self):
"""peak 초기화 첫 틱에서는 drop=0이므로 Trail Stop 발동 안 함."""
pos = _make_pos(entry_price=1000, seconds_ago=305, sell_uuid=None, stage=4)
pos['trail_peak_set'] = False
tt.positions[TICKER] = pos
with patch.object(tt, 'do_sell_market') as mock_sell:
tt.update_positions({TICKER: 850.0}) # 진입가 대비 -15%
mock_sell.assert_not_called() # 초기화 틱이므로 발동 안 함
def test_submit_fail_sell_uuid_none_no_trail_before_300s(self):
"""
[회귀] HOLO 버그 재현: ① 지정가 제출 실패(sell_uuid=None) + 진입 2초 후
→ Trail Stop이 발동하면 안 됨.
"""
tt.positions[TICKER] = _make_pos(
entry_price=97, seconds_ago=2, sell_uuid=None, stage=0
)
with patch.object(tt, 'do_sell_market') as mock_sell:
tt.update_positions({TICKER: 95.0}) # -2.06% 하락
mock_sell.assert_not_called() # 300s 이전이므로 발동 안 함
# ── _advance_stage ────────────────────────────────────────────────────────────
class TestAdvanceStage:
def setup_method(self):
tt.positions.clear()
def test_advance_from_stage0_to_stage1(self):
"""① → ② 단계 전환 시 sell_uuid 갱신."""
tt.positions[TICKER] = _make_pos(stage=0, sell_uuid='old-uuid')
with patch.object(tt, 'cancel_order_safe'), \
patch.object(tt, 'submit_limit_sell', return_value='new-uuid'):
tt._advance_stage(TICKER)
pos = tt.positions[TICKER]
assert pos['stage'] == 1
assert pos['sell_uuid'] == 'new-uuid'
assert abs(pos['sell_price'] - 1000 * 1.01) < 0.01
def test_advance_to_trail_stage(self):
"""마지막 cascade 단계 → ⑤ Trail 전환 시 sell_uuid=None."""
tt.positions[TICKER] = _make_pos(stage=len(tt.CASCADE_STAGES) - 1)
with patch.object(tt, 'cancel_order_safe'), \
patch.object(tt, 'submit_limit_sell'):
tt._advance_stage(TICKER)
pos = tt.positions[TICKER]
assert pos['stage'] == len(tt.CASCADE_STAGES)
assert pos['sell_uuid'] is None
def test_advance_submit_fail_sell_uuid_none(self):
"""지정가 재주문 실패(submit_limit_sell=None) 시 sell_uuid=None — Trail 비활성 확인."""
tt.positions[TICKER] = _make_pos(stage=0, seconds_ago=50)
with patch.object(tt, 'cancel_order_safe'), \
patch.object(tt, 'submit_limit_sell', return_value=None):
tt._advance_stage(TICKER)
pos = tt.positions[TICKER]
assert pos['sell_uuid'] is None
# Trail Stop은 300s 미경과이므로 update_positions에서 발동 안 해야 함
with patch.object(tt, 'do_sell_market') as mock_sell:
tt.update_positions({TICKER: 500.0}) # -50% 하락이어도
mock_sell.assert_not_called()
# ── check_filled_positions ────────────────────────────────────────────────────
class TestCheckFilledPositions:
def setup_method(self):
tt.positions.clear()
def test_done_order_records_exit(self):
"""체결 완료(done) 시 _record_exit 호출 (실거래 모드)."""
tt.positions[TICKER] = _make_pos(stage=0, sell_uuid='u1', seconds_ago=10)
with patch.object(tt, 'check_order_state', return_value=('done', 1020.0)), \
patch.object(tt, '_record_exit') as mock_exit, \
patch.object(tt, 'SIM_MODE', False):
tt.check_filled_positions()
mock_exit.assert_called_once_with(TICKER, 1020.0, '')
def test_timeout_advances_stage(self):
"""단계 시간 초과 시 _advance_stage 호출."""
stage_end = tt.CASCADE_STAGES[0][1] # 40s
tt.positions[TICKER] = _make_pos(stage=0, sell_uuid='u1',
seconds_ago=stage_end + 5)
with patch.object(tt, 'check_order_state', return_value=('wait', None)), \
patch.object(tt, '_advance_stage') as mock_advance:
tt.check_filled_positions()
mock_advance.assert_called_once_with(TICKER)
def test_cancelled_order_resubmits(self):
"""주문 취소(cancel) 감지 시 _advance_stage 호출 (실거래 모드)."""
tt.positions[TICKER] = _make_pos(stage=1, sell_uuid='u1', seconds_ago=50)
with patch.object(tt, 'check_order_state', return_value=('cancel', None)), \
patch.object(tt, '_advance_stage') as mock_advance, \
patch.object(tt, 'SIM_MODE', False):
tt.check_filled_positions()
mock_advance.assert_called_once_with(TICKER)
def test_trail_stage_skipped(self):
"""Trail 단계(sell_uuid=None)는 check_filled_positions에서 스킵."""
tt.positions[TICKER] = _make_pos(stage=4, sell_uuid=None, seconds_ago=4000)
with patch.object(tt, 'check_order_state') as mock_state:
tt.check_filled_positions()
mock_state.assert_not_called()

119
archive/tests/ticker_sim.py Normal file
View File

@@ -0,0 +1,119 @@
"""종목 수 확장 시뮬레이션 - 거래량 상위 N개 종목별 vol-lead 전략 비교."""
import os
import pickle
import sys
from pathlib import Path
from dotenv import load_dotenv
load_dotenv()
import pandas as pd
import pyupbit
# vol_lead_sim.py의 공통 파라미터/함수 재사용
sys.path.insert(0, str(Path(__file__).parent))
from vol_lead_sim import (
STOP_LOSS_PCT, TIME_STOP_HOURS, TIME_STOP_MIN_PCT,
FEE, LOCAL_VOL_HOURS, VOL_MULT, PRICE_QUIET_PCT, SIGNAL_TIMEOUT_H,
FROM_DATE, simulate_pos, run_trend, run_vol_lead_thresh,
)
CACHE_FILE = Path("vol_lead_cache_30.pkl")
TOP30_FILE = Path("top30_tickers.pkl")
DAYS = 46.0
def load_data() -> dict:
return pickle.load(open(CACHE_FILE, "rb"))
def run_subset(data: dict, tickers: list, thresh: float) -> dict:
agg = {"total": 0, "wins": 0, "pnl": 0.0, "per_ticker": []}
for t in tickers:
if t not in data:
continue
trades = run_vol_lead_thresh(data[t], thresh)
n = len(trades)
w = sum(1 for x in trades if x[0])
p = sum(x[1] for x in trades)
agg["total"] += n
agg["wins"] += w
agg["pnl"] += p
agg["per_ticker"].append((t, n, w, p))
agg["wr"] = agg["wins"] / agg["total"] * 100 if agg["total"] else 0
return agg
def main() -> None:
data = load_data()
top30 = pickle.load(open(TOP30_FILE, "rb"))
# 데이터 충분한 종목만 (400봉 이상 = 16일 이상)
valid = [t for t in top30 if t in data and len(data[t]) >= 400]
n_max = len(valid)
print(f"유효 종목: {n_max}")
print(f"기간: 46일 (2026-01-15 ~ 2026-03-02)\n")
# ── A 현행 기준선 (9종목) ─────────────────────────
orig9 = ["KRW-DKA","KRW-LAYER","KRW-SIGN","KRW-SOL","KRW-ETH",
"KRW-XRP","KRW-HOLO","KRW-OM","KRW-ORBS"]
orig9_valid = [t for t in orig9 if t in data]
a_agg = {"total": 0, "wins": 0, "pnl": 0.0}
for t in orig9_valid:
trades = run_trend(data[t])
a_agg["total"] += len(trades)
a_agg["wins"] += sum(1 for x in trades if x[0])
a_agg["pnl"] += sum(x[1] for x in trades)
a_wr = a_agg["wins"] / a_agg["total"] * 100 if a_agg["total"] else 0
print(f"[기준: A 현행 9종목] {a_agg['total']}건 | 승률={a_wr:.0f}% | 누적={a_agg['pnl']:+.2f}%\n")
# ── 종목수별 비교 (임계값 4.8% 고정) ────────────────
THRESH = 4.8
subset_ns = [9, 15, 20, n_max]
print(f"임계값 +{THRESH}% | 종목 수 확장 효과")
print(f"{'종목수':>6}{'총거래':>6} {'일평균':>7} {'월환산':>7}{'승률':>5} {'누적PnL':>10}")
print("" * 56)
for n in subset_ns:
s = run_subset(data, valid[:n], THRESH)
pdm = s["total"] / DAYS
pmm = pdm * 30
marker = " ← 현재설정" if n == 9 else ""
print(f"{n:>5}종목 │ {s['total']:>6}{pdm:>6.2f}회/일 {pmm:>6.1f}회/월 │ "
f"{s['wr']:>4.0f}% {s['pnl']:>+9.2f}%{marker}")
# ── 임계값 × 종목수 매트릭스 ─────────────────────
thresholds = [3.6, 4.0, 4.4, 4.8]
col_ns = [9, 15, 20, n_max]
print(f"\n임계값 × 종목수 매트릭스 (건수 / 승률 / 누적PnL)")
col_w = 20
header = f"{'임계값':>6}"
for n in col_ns:
header += f" {f'{n}종목':^{col_w}}"
print(header)
print("" * (10 + col_w * len(col_ns)))
for thresh in thresholds:
row = f"+{thresh:.1f}% │"
for n in col_ns:
s = run_subset(data, valid[:n], thresh)
wr = s["wins"] / s["total"] * 100 if s["total"] else 0
cell = f"{s['total']}{wr:.0f}% {s['pnl']:+.1f}%"
row += f" {cell:<{col_w}}"
print(row)
# ── 전체 종목별 기여도 (4.8%) ────────────────────
print(f"\n종목별 기여도 ({n_max}종목, +4.8%)")
print(f"{'종목':<16} {'거래':>5} {'승률':>6} {'누적PnL':>10} {'평균PnL/거래':>12}")
print("" * 55)
s = run_subset(data, valid, THRESH)
s["per_ticker"].sort(key=lambda x: x[3], reverse=True)
for t, n, w, p in s["per_ticker"]:
wr = w / n * 100 if n else 0
avg = p / n if n else 0
print(f"{t:<16} {n:>5}{wr:>5.0f}% {p:>+9.2f}% {avg:>+10.2f}%/건")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,208 @@
"""5% 익절 전략 백테스트 (손절 없음)
규칙:
- 진입: 직전 포지션 청산 다음 캔들 종가 매수
- 청산: 고가 기준 +5% 달성 시 TP 가격으로 매도
- 손절: 없음 — 손실 중 무조건 보유
- 자본: 1,000,000 KRW (복리 없음 / 고정)
- 캔들: 1h
Output:
- 종목별: 거래 수, TP 성공, 승률, 총 손익, 평균 보유시간, 최대 보유시간
- 현재 미결 포지션 현황
- 전략 한계 분석
"""
import os as _os, sys as _sys
_sys.path.insert(0, _os.path.dirname(_os.path.dirname(_os.path.abspath(__file__))))
from __future__ import annotations
import time
from dataclasses import dataclass, field
from typing import Optional
import pyupbit
import pandas as pd
from datetime import datetime
# ─── 파라미터 ──────────────────────────────────────────────────
TICKERS = [
"KRW-WET", "KRW-FLOW", "KRW-BIO",
"KRW-CYBER", "KRW-JTO", "KRW-AERO",
"KRW-LAYER", "KRW-DKA",
]
TP_PCT = 0.05 # 익절 목표 5%
CAPITAL = 1_000_000 # 거래당 고정 자본 (복리 없음)
INTERVAL = "minute60"
COUNT = 1440 # 60일치 1h 캔들
# ─── 데이터 클래스 ──────────────────────────────────────────────
@dataclass
class Trade:
entry_time: datetime
exit_time: datetime
entry_px: float
exit_px: float
pnl: float
holding_h: int
status: str # TP | HOLDING_LOSS | HOLDING_PROFIT
# ─── 백테스트 ──────────────────────────────────────────────────
def backtest_ticker(ticker: str) -> Optional[dict]:
df = pyupbit.get_ohlcv(ticker, interval=INTERVAL, count=COUNT)
if df is None or len(df) < 20:
return None
trades: list[Trade] = []
i = 0
while i < len(df) - 1:
# 매수: 현재 캔들 종가
entry_px = float(df["close"].iloc[i])
entry_time = df.index[i]
tp_px = entry_px * (1 + TP_PCT)
hit = False
for j in range(i + 1, len(df)):
if float(df["high"].iloc[j]) >= tp_px:
trades.append(Trade(
entry_time = entry_time,
exit_time = df.index[j],
entry_px = entry_px,
exit_px = tp_px,
pnl = CAPITAL * TP_PCT,
holding_h = j - i,
status = "TP",
))
i = j # 다음 진입은 j 캔들 종가
hit = True
break
if not hit:
# 데이터 끝까지 TP 미달성
last_px = float(df["close"].iloc[-1])
pnl = CAPITAL * (last_px - entry_px) / entry_px
status = "HOLDING_PROFIT" if pnl >= 0 else "HOLDING_LOSS"
trades.append(Trade(
entry_time = entry_time,
exit_time = df.index[-1],
entry_px = entry_px,
exit_px = last_px,
pnl = pnl,
holding_h = len(df) - i - 1,
status = status,
))
break
closed = [t for t in trades if t.status == "TP"]
holding = [t for t in trades if t.status != "TP"]
total_pnl = sum(t.pnl for t in trades)
realized_pnl = sum(t.pnl for t in closed)
return {
"ticker": ticker,
"n_total": len(trades),
"n_tp": len(closed),
"win_rate": len(closed) / len(trades) * 100 if trades else 0,
"realized_pnl": realized_pnl,
"total_pnl": total_pnl,
"avg_h": sum(t.holding_h for t in closed) / len(closed) if closed else 0,
"max_h": max(t.holding_h for t in trades) if trades else 0,
"holding": holding,
"trades": trades,
}
# ─── 메인 ──────────────────────────────────────────────────────
def main() -> None:
print("=" * 62)
print(" 5% 익절 전략 백테스트 (손절 없음)")
print(f" 기간: 최근 {COUNT}h ({COUNT//24}일) | 자본: {CAPITAL:,}원 | TP: {TP_PCT*100:.0f}%")
print("=" * 62)
all_results = []
for ticker in TICKERS:
r = backtest_ticker(ticker)
time.sleep(0.1)
if r:
all_results.append(r)
# ─── 종목별 출력 ───────────────────────────────────────────
print(f"\n{'종목':<12} {'거래':<5} {'TP':>5} {'승률':>7} {'실현손익':>12} "
f"{'미결손익':>12} {'평균보유':>8} {'최장보유':>8}")
print("-" * 80)
total_realized = 0
total_open_pnl = 0
for r in all_results:
h_pnl = sum(t.pnl for t in r["holding"])
hold_str = ""
if r["holding"]:
h = r["holding"][0]
mark = "⚠️ " if h.status == "HOLDING_LOSS" else "📈"
hold_str = f" {mark}{h.pnl:+,.0f}원 ({h.holding_h}h 보유중)"
print(f"{r['ticker']:<12} {r['n_total']:<5} {r['n_tp']:>5} "
f"{r['win_rate']:>6.1f}% {r['realized_pnl']:>+12,.0f} "
f"{h_pnl:>+12,.0f} {r['avg_h']:>7.1f}h {r['max_h']:>7}h"
+ hold_str)
total_realized += r["realized_pnl"]
total_open_pnl += h_pnl
print("-" * 80)
print(f"{'합계':<12} {'':5} {'':>5} {'':>7} {total_realized:>+12,.0f} "
f"{total_open_pnl:>+12,.0f}")
# ─── 미결 포지션 상세 ──────────────────────────────────────
open_trades = [(r["ticker"], h) for r in all_results for h in r["holding"]]
if open_trades:
print("\n▶ 현재 미결 포지션")
print(f" {'종목':<12} {'진입시각':<20} {'진입가':>12} {'현재가':>12} {'수익률':>8} {'보유시간':>8}")
for ticker, h in open_trades:
chg = (h.exit_px - h.entry_px) / h.entry_px * 100
print(f" {ticker:<12} {str(h.entry_time)[:16]:<20} "
f"{h.entry_px:>12,.2f} {h.exit_px:>12,.2f} "
f"{chg:>+7.2f}% {h.holding_h:>7}h")
# ─── 최장 잠김 분석 ────────────────────────────────────────
all_trades_flat = [(r["ticker"], t) for r in all_results for t in r["trades"]]
if all_trades_flat:
worst = max(all_trades_flat, key=lambda x: x[1].holding_h)
print(f"\n▶ 최장 자본 잠김")
print(f" {worst[0]}: {worst[1].holding_h}h ({worst[1].holding_h/24:.1f}일) "
f"진입 {str(worst[1].entry_time)[:16]}"
f"{'TP 달성' if worst[1].status == 'TP' else '미결'}")
# ─── 전략 평가 ─────────────────────────────────────────────
if all_results:
total_trades = sum(r["n_total"] for r in all_results)
total_tp = sum(r["n_tp"] for r in all_results)
avg_hold_all = [r["avg_h"] for r in all_results if r["avg_h"] > 0]
still_holding = sum(1 for r in all_results if r["holding"])
print("\n▶ 종합 평가")
print(f" 전체 거래: {total_trades}건 | TP 성공: {total_tp}건 | "
f"승률: {total_tp/total_trades*100:.1f}%")
if avg_hold_all:
print(f" TP까지 평균 보유: {sum(avg_hold_all)/len(avg_hold_all):.1f}h "
f"({sum(avg_hold_all)/len(avg_hold_all)/24:.1f}일)")
print(f" 현재 미결 포지션: {still_holding}개 종목")
print(f" 총 실현 손익: {total_realized:+,.0f}")
print(f" 총 평가 손익: {total_realized + total_open_pnl:+,.0f}")
print("\n▶ 전략 한계")
if still_holding > 0:
print(f" ⚠️ {still_holding}개 종목이 TP 미달성 상태로 자본이 잠겨있습니다.")
print(" ⚠️ 손절 없음 → 하락장에서 자본이 무한정 묶일 수 있습니다.")
print(" ⚠️ 기회비용: 잠긴 자본으로 다른 거래 불가.")
print(" 💡 개선안: 최대 보유기간(시간) 제한 또는 % 손절 추가 권장.")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,45 @@
import os as _os, sys as _sys
_sys.path.insert(0, _os.path.dirname(_os.path.dirname(_os.path.abspath(__file__))))
import os
from dotenv import load_dotenv
load_dotenv()
import oracledb
conn = oracledb.connect(user=os.getenv('ORACLE_USER'), password=os.getenv('ORACLE_PASSWORD'),
dsn=os.getenv('ORACLE_DSN'), config_dir=os.getenv('ORACLE_WALLET'))
cur = conn.cursor()
for ticker in ['KRW-DKA', 'KRW-LAYER', 'KRW-SIGN']:
cur.execute("""
SELECT price, recorded_at FROM price_history
WHERE ticker = :t AND recorded_at >= TIMESTAMP '2026-02-28 20:00:00'
ORDER BY recorded_at
""", t=ticker)
rows = cur.fetchall()
lookback = 12 # 10분봉 * 12 = 2h
gains = []
for i in range(lookback, len(rows)):
curr = rows[i][0]
past = rows[i - lookback][0]
if past > 0:
gains.append((curr - past) / past * 100)
if not gains:
continue
above_5 = sum(1 for g in gains if g >= 5.0)
above_3 = sum(1 for g in gains if g >= 3.0)
above_0 = sum(1 for g in gains if g >= 0.0)
negative = sum(1 for g in gains if g < 0.0)
print(f"[{ticker}] 2h 등락률 분포 ({len(gains)}개 틱)")
print(f" 평균={sum(gains)/len(gains):+.2f}% 최고={max(gains):+.2f}% 최저={min(gains):+.2f}%")
print(f" +5% 이상(신호): {above_5}건 ({above_5/len(gains)*100:.0f}%)")
print(f" +3%~+5%: {above_3-above_5}건 ({(above_3-above_5)/len(gains)*100:.0f}%)")
print(f" 0%~+3%: {above_0-above_3}건 ({(above_0-above_3)/len(gains)*100:.0f}%)")
print(f" 음전(하락): {negative}건 ({negative/len(gains)*100:.0f}%)")
print()
conn.close()

View File

@@ -0,0 +1,461 @@
"""velocity_backtest.py — 속도 진입 효과 비교 백테스트.
전략 A: 기존 거리 기반 (signal_price 대비 +THRESH% 도달 시 진입)
전략 B: 거리 + 속도 기반 (velocity >= 레짐별 VELOCITY_THRESHOLD 시 조기 진입)
BULL → vel_thresh = 0.10 (공격적)
NEUTRAL → vel_thresh = 0.15 (보수적)
BEAR → vel_thresh = 0.20 (더 높음)
레짐 판단: KRW-BTC 1h 변동률 (캐시 데이터 활용)
10분봉 캐시(sim10m_cache.pkl)를 사용.
신호 감지: 40분봉 vol spike + 2h 횡보 (10분봉 합산/슬라이스로 계산)
진입/청산: 10분봉 단위로 체크 (실제 시스템의 15초 폴링 근사)
"""
import os as _os, sys as _sys
_sys.path.insert(0, _os.path.dirname(_os.path.dirname(_os.path.abspath(__file__))))
import pickle
from pathlib import Path
import pandas as pd
# ── 파라미터 ──────────────────────────────────────────────────────────────────
CACHE_FILE = Path("sim10m_cache.pkl")
TOP_FILE = Path("top30_tickers.pkl")
TOP_N = 20
BUDGET = 15_000_000
MIN_BUDGET = BUDGET * 3 // 10
MAX_POS = 3
FEE = 0.0005
# 전략 파라미터
VOL_MULT = 2.0 # 거래량 배수 기준
QUIET_PCT = 2.0 # 2h 횡보 기준 (%)
THRESH = 4.8 # 거리 기반 진입 임계값 (%)
# 10분봉 기준 캔들 수
QUIET_C = 12 # 2h = 12 × 10분
VOL40_C = 4 # 40분봉 1개 = 4 × 10분봉
LOCAL_C = 7 # 로컬 평균 40분봉 7개 = 28 × 10분봉
TIMEOUT_C = 48 # 신호 타임아웃 8h = 48 × 10분봉
TS_C = 48 # 타임스탑 8h = 48 × 10분봉
ATR_C = 28 # ATR 5h = 7 × 40분 = 28 × 10분봉
ATR_MULT = 1.5
ATR_MIN = 0.010
ATR_MAX = 0.020
TS_MIN_PCT = 3.0
MIN_I = LOCAL_C * VOL40_C + VOL40_C + QUIET_C + 2 # = 42
# 속도 기반 진입 파라미터 (레짐별)
VEL_THRESH_BULL = 0.10 # BULL: 0.10%/분 (공격적)
VEL_THRESH_NEUTRAL = 0.15 # NEUTRAL: 0.15%/분 (보수적)
VEL_THRESH_BEAR = 0.20 # BEAR: 0.20%/분 (더 높음)
VELOCITY_MIN_MOVE = 0.5 # 최소 이동 % (잡음 제거)
VELOCITY_MIN_AGE_M = 5.0 # 최소 경과 분
# 레짐 판단 기준 (BTC 1h 변동률)
REGIME_BULL_CHANGE = 5.0 # +5% 이상 → BULL
REGIME_BEAR_CHANGE = -5.0 # -5% 이하 → BEAR
WF_WINDOW = 4
WF_MIN_WIN_RATE = 0.01
WF_SHADOW_WINS = 2
# ── 낙폭 제어 파라미터 ────────────────────────────────────────────────────────
HARD_STOP_PCT = 0.015 # 진입가 대비 -1.5% 즉시 청산 (하드 손절)
STREAK_TIGHT_N = 2 # 연속 N회 손절 시 타임스탑 강화
TS_C_TIGHT = 24 # 강화 타임스탑 보유 시간 (4h = 24 × 10분)
TS_MIN_PCT_TIGHT = 0.0 # 강화 타임스탑 최소 수익률 (0%)
# ── 레짐 헬퍼 ─────────────────────────────────────────────────────────────────
def build_regime_series(btc_df: pd.DataFrame) -> pd.Series:
"""BTC 10분봉으로 1h 변동률 계산 → 레짐 시리즈 반환."""
close = btc_df["close"]
change = close.pct_change(6) * 100 # 6 × 10분 = 1h
regime = pd.Series("neutral", index=close.index, dtype=object)
regime[change > REGIME_BULL_CHANGE] = "bull"
regime[change < REGIME_BEAR_CHANGE] = "bear"
return regime
def _vel_thresh_for(regime_s: pd.Series, ts) -> float:
"""타임스탬프 기준 레짐별 velocity 임계값 반환."""
if regime_s is None:
return VEL_THRESH_NEUTRAL
idx = regime_s.index.searchsorted(ts)
if idx >= len(regime_s):
return VEL_THRESH_NEUTRAL
r = regime_s.iloc[idx]
if r == "bull":
return VEL_THRESH_BULL
elif r == "bear":
return VEL_THRESH_BEAR
return VEL_THRESH_NEUTRAL
# ── 헬퍼 ──────────────────────────────────────────────────────────────────────
def calc_atr(df: pd.DataFrame, i: int) -> float:
sub = df.iloc[max(0, i - ATR_C):i]
if len(sub) < 3:
return ATR_MIN
try:
avg = ((sub["high"] - sub["low"]) / sub["low"]).mean()
return float(max(ATR_MIN, min(ATR_MAX, avg * ATR_MULT)))
except Exception:
return ATR_MIN
def simulate_pos(df: pd.DataFrame, buy_idx: int,
buy_price: float, stop_pct: float,
hard_stop: bool = False, tight_ts: bool = False):
"""매수 이후 청산 시점·손익 계산.
hard_stop : True → 진입가 대비 -HARD_STOP_PCT% 즉시 청산
tight_ts : True → 강화된 타임스탑 (4h / 0%) 적용
"""
peak = buy_price
hard_stop_px = buy_price * (1 - HARD_STOP_PCT) if hard_stop else None
ts_c_use = TS_C_TIGHT if tight_ts else TS_C
ts_min_use = TS_MIN_PCT_TIGHT if tight_ts else TS_MIN_PCT
for i in range(buy_idx + 1, len(df)):
row = df.iloc[i]
if row["high"] > peak:
peak = row["high"]
# 1. 하드 손절 (진입가 대비 고정 %)
if hard_stop_px is not None and row["low"] <= hard_stop_px:
pnl = (hard_stop_px * (1 - FEE) - buy_price * (1 + FEE)) / (buy_price * (1 + FEE)) * 100
return pnl > 0, df.index[i], pnl
# 2. 트레일링 스탑 (최고가 대비)
if row["low"] <= peak * (1 - stop_pct):
sp = peak * (1 - stop_pct)
pnl = (sp * (1 - FEE) - buy_price * (1 + FEE)) / (buy_price * (1 + FEE)) * 100
return pnl > 0, df.index[i], pnl
# 3. 타임스탑
pnl_now = (row["close"] - buy_price) / buy_price * 100
if (i - buy_idx) >= ts_c_use and pnl_now < ts_min_use:
pnl = (row["close"] * (1 - FEE) - buy_price * (1 + FEE)) / (buy_price * (1 + FEE)) * 100
return pnl > 0, df.index[i], pnl
last = df.iloc[-1]["close"]
pnl = (last * (1 - FEE) - buy_price * (1 + FEE)) / (buy_price * (1 + FEE)) * 100
return pnl > 0, df.index[-1], pnl
def _prev_40m_vol(df: pd.DataFrame, i: int) -> float:
"""직전 완성 40분봉 거래량 (10분봉 4개 합산)."""
return df.iloc[max(0, i - VOL40_C):i]["volume"].sum()
def _local_vol_avg(df: pd.DataFrame, i: int) -> float:
"""로컬 5h 평균 (직전 7개 40분봉 각각의 합산 평균)."""
vols = []
for k in range(1, LOCAL_C + 1):
end = i - VOL40_C * (k - 1)
start = end - VOL40_C
if start < 0:
break
vols.append(df.iloc[start:end]["volume"].sum())
return sum(vols) / len(vols) if vols else 0
# ── 핵심 전략 루프 ─────────────────────────────────────────────────────────────
def run_strategy(df: pd.DataFrame, ticker: str,
use_velocity: bool = False,
regime_s: pd.Series = None,
dd_control: bool = False) -> list:
"""
Returns list of (is_win, pnl, buy_dt, sell_dt, ticker, entry_type)
entry_type: 'dist' | 'vel'
dd_control: True → 연속 손절 추적하여 hard_stop + tight_ts 적용
"""
trades = []
sig_i = sig_p = None
in_pos = False
buy_idx = buy_price = stop_pct = None
entry_type = "dist"
consec_losses = 0 # 연속 손절 횟수
i = MIN_I
while i < len(df):
# ── 포지션 중 → 청산 계산 후 다음 진입 탐색 ──────────────────
if in_pos:
use_hard = dd_control and consec_losses >= STREAK_TIGHT_N
use_tight = dd_control and consec_losses >= STREAK_TIGHT_N
is_win, sdt, pnl = simulate_pos(
df, buy_idx, buy_price, stop_pct,
hard_stop=use_hard, tight_ts=use_tight,
)
next_i = next(
(j for j in range(i, len(df)) if df.index[j] > sdt),
len(df),
)
trades.append((is_win, pnl, df.index[buy_idx], sdt, ticker, entry_type))
# 연속 손절 카운터 업데이트
if is_win:
consec_losses = 0
else:
consec_losses += 1
in_pos = False
sig_i = sig_p = None
i = next_i
continue
close = df.iloc[i]["close"]
# ── 신호 감지 ─────────────────────────────────────────────────
prev_vol = _prev_40m_vol(df, i)
local_avg = _local_vol_avg(df, i)
vol_r = prev_vol / local_avg if local_avg > 0 else 0
close_2h = df.iloc[i - QUIET_C]["close"]
quiet = abs(close - close_2h) / close_2h * 100 < QUIET_PCT
spike = vol_r >= VOL_MULT
if quiet and spike:
if sig_i is None:
sig_i, sig_p = i, close
else:
if sig_i is not None and close < sig_p:
sig_i = sig_p = None
# 타임아웃
if sig_i is not None and (i - sig_i) > TIMEOUT_C:
sig_i = sig_p = None
# ── 진입 판단 ─────────────────────────────────────────────────
if sig_i is not None:
move_pct = (close - sig_p) / sig_p * 100
age_min = (i - sig_i) * 10 # 10분봉 × 10분
# A. 거리 기반
if move_pct >= THRESH:
in_pos = True
buy_idx = i
buy_price = close
stop_pct = calc_atr(df, i)
entry_type = "dist"
sig_i = sig_p = None
i += 1
continue
# B. 속도 기반 (use_velocity=True 일 때만)
if (use_velocity
and age_min >= VELOCITY_MIN_AGE_M
and move_pct >= VELOCITY_MIN_MOVE):
velocity = move_pct / age_min
vel_thresh = _vel_thresh_for(regime_s, df.index[i])
if velocity >= vel_thresh:
in_pos = True
buy_idx = i
buy_price = close
stop_pct = calc_atr(df, i)
entry_type = "vel"
sig_i = sig_p = None
i += 1
continue
i += 1
return trades
# ── WF 필터 ───────────────────────────────────────────────────────────────────
def apply_wf(trades: list) -> tuple:
history, shadow_streak, blocked = [], 0, False
accepted, blocked_cnt = [], 0
for trade in trades:
is_win = int(trade[0])
if not blocked:
accepted.append(trade)
history.append(is_win)
if len(history) >= WF_WINDOW:
if sum(history[-WF_WINDOW:]) / WF_WINDOW < WF_MIN_WIN_RATE:
blocked = True
shadow_streak = 0
else:
blocked_cnt += 1
shadow_streak = (shadow_streak + 1) if is_win else 0
if shadow_streak >= WF_SHADOW_WINS:
blocked = False
history = []
shadow_streak = 0
return accepted, blocked_cnt
# ── MAX_POSITIONS 필터 ────────────────────────────────────────────────────────
def apply_max_pos(all_trades: list) -> tuple:
open_exits, accepted, skipped = [], [], []
for trade in sorted(all_trades, key=lambda x: x[2]):
buy_dt, sell_dt = trade[2], trade[3]
open_exits = [s for s in open_exits if s > buy_dt]
if len(open_exits) < MAX_POS:
open_exits.append(sell_dt)
accepted.append(trade)
else:
skipped.append(trade)
return accepted, skipped
# ── 복리 시뮬 ─────────────────────────────────────────────────────────────────
def simulate(accepted: list) -> dict:
portfolio = float(BUDGET)
total_krw = 0.0
monthly = {}
peak_pf = BUDGET
max_dd = 0.0
win_cnt = 0
vel_count = sum(1 for t in accepted if t[5] == "vel")
vel_wins = sum(1 for t in accepted if t[5] == "vel" and t[0])
vel_pnls = [t[1] for t in accepted if t[5] == "vel"]
dist_pnls = [t[1] for t in accepted if t[5] == "dist"]
for is_win, pnl, buy_dt, sell_dt, ticker, etype in accepted:
pos_size = max(portfolio, MIN_BUDGET) / MAX_POS
krw_profit = pos_size * pnl / 100
portfolio = max(portfolio + krw_profit, MIN_BUDGET)
total_krw += krw_profit
peak_pf = max(peak_pf, portfolio)
dd = (peak_pf - portfolio) / peak_pf * 100
max_dd = max(max_dd, dd)
win_cnt += int(is_win)
ym = buy_dt.strftime("%Y-%m")
m = monthly.setdefault(ym, {"t": 0, "w": 0, "krw": 0.0})
m["t"] += 1; m["w"] += int(is_win); m["krw"] += krw_profit
n = len(accepted)
return {
"portfolio": portfolio,
"total_krw": total_krw,
"roi": (portfolio - BUDGET) / BUDGET * 100,
"n": n,
"wins": win_cnt,
"wr": win_cnt / n * 100 if n else 0,
"max_dd": max_dd,
"monthly": monthly,
"vel_count": vel_count,
"vel_wins": vel_wins,
"vel_wr": vel_wins / vel_count * 100 if vel_count else 0,
"vel_avg_pnl": sum(vel_pnls) / len(vel_pnls) if vel_pnls else 0,
"dist_avg_pnl": sum(dist_pnls) / len(dist_pnls) if dist_pnls else 0,
}
# ── 메인 ──────────────────────────────────────────────────────────────────────
def main():
print("캐시 로드...")
cache = pickle.load(open(CACHE_FILE, "rb"))
top30 = pickle.load(open(TOP_FILE, "rb"))
tickers = [t for t in top30[:TOP_N] if t in cache["10m"]]
print(f"유효 종목: {len(tickers)}\n")
# BTC 레짐 시리즈 빌드
btc_df = cache["10m"].get("KRW-BTC")
regime_s = build_regime_series(btc_df) if btc_df is not None else None
if regime_s is not None:
bull_pct = (regime_s == "bull").mean() * 100
bear_pct = (regime_s == "bear").mean() * 100
print(f"레짐 분포: BULL {bull_pct:.1f}% / NEUTRAL {100-bull_pct-bear_pct:.1f}% / BEAR {bear_pct:.1f}%")
print(f"vel threshold: BULL={VEL_THRESH_BULL} / NEUTRAL={VEL_THRESH_NEUTRAL} / BEAR={VEL_THRESH_BEAR}\n")
all_a, all_b, all_c = [], [], []
wf_a_total = wf_b_total = wf_c_total = 0
for t in tickers:
df = cache["10m"][t]
if len(df) < MIN_I + 50:
continue
raw_a = run_strategy(df, t, use_velocity=False)
raw_b = run_strategy(df, t, use_velocity=True, regime_s=regime_s)
raw_c = run_strategy(df, t, use_velocity=True, regime_s=regime_s, dd_control=True)
fa, ba = apply_wf(raw_a)
fb, bb = apply_wf(raw_b)
fc, bc = apply_wf(raw_c)
wf_a_total += ba; wf_b_total += bb; wf_c_total += bc
all_a.extend(fa); all_b.extend(fb); all_c.extend(fc)
acc_a, skp_a = apply_max_pos(all_a)
acc_b, skp_b = apply_max_pos(all_b)
acc_c, skp_c = apply_max_pos(all_c)
ra = simulate(acc_a)
rb = simulate(acc_b)
rc = simulate(acc_c)
# ── 날짜 범위 ─────────────────────────────────────────
def date_range(acc):
if acc:
s = min(t[2] for t in acc).strftime("%Y-%m-%d")
e = max(t[3] for t in acc).strftime("%Y-%m-%d")
return f"{s} ~ {e}"
return "N/A"
# ── 출력 ─────────────────────────────────────────────
W = 72
print("=" * W)
print(f" 낙폭 제어 비교 백테스트 | 10분봉 | {len(tickers)}종목")
print(f" 기간: {date_range(acc_a)}")
print(f" hard_stop={HARD_STOP_PCT*100:.1f}% | tight_ts={TS_C_TIGHT*10//60}h+{TS_MIN_PCT_TIGHT:.0f}% "
f"(연속 {STREAK_TIGHT_N}손절 후)")
print("=" * W)
print(f" {'항목':<22} {'A. 기존':>12} {'B. +속도':>12} {'C. +속도+DD제어':>14}")
print(f" {''*64}")
def row3(label, va, vb, vc, fmt="{}"):
sa, sb, sc = fmt.format(va), fmt.format(vb), fmt.format(vc)
try:
dbc = float(str(vc).replace(",","").replace("%","").replace("","")) \
- float(str(va).replace(",","").replace("%","").replace("",""))
dc = f" ({dbc:+.1f})" if abs(dbc) >= 0.01 else ""
except Exception:
dc = ""
print(f" {label:<22} {sa:>12} {sb:>12} {sc:>14}{dc}")
row3("총 진입", ra["n"], rb["n"], rc["n"], "{:,}")
row3(" 속도 진입", 0, rb["vel_count"], rc["vel_count"], "{:,}")
row3("WF 차단", wf_a_total, wf_b_total, wf_c_total, "{:,}")
row3("MAX_POS 스킵", len(skp_a), len(skp_b), len(skp_c), "{:,}")
print(f" {''*64}")
row3("승률", f"{ra['wr']:.1f}%", f"{rb['wr']:.1f}%", f"{rc['wr']:.1f}%")
row3(" 속도진입 승률","-", f"{rb['vel_wr']:.1f}%", f"{rc['vel_wr']:.1f}%")
print(f" {''*64}")
row3("평균 pnl (거리)",f"{ra['dist_avg_pnl']:.2f}%", f"{rb['dist_avg_pnl']:.2f}%", f"{rc['dist_avg_pnl']:.2f}%")
row3("평균 pnl (속도)","-", f"{rb['vel_avg_pnl']:.2f}%", f"{rc['vel_avg_pnl']:.2f}%")
print(f" {''*64}")
row3("최종 자산", f"{ra['portfolio']:,.0f}", f"{rb['portfolio']:,.0f}", f"{rc['portfolio']:,.0f}")
row3("총 수익", f"{ra['total_krw']:+,.0f}", f"{rb['total_krw']:+,.0f}", f"{rc['total_krw']:+,.0f}")
row3("수익률", f"{ra['roi']:.2f}%", f"{rb['roi']:.2f}%", f"{rc['roi']:.2f}%")
row3("최대 낙폭", f"{-ra['max_dd']:.2f}%", f"{-rb['max_dd']:.2f}%", f"{-rc['max_dd']:.2f}%")
print("=" * W)
# ── 월별 ─────────────────────────────────────────────
print(f"\n── 월별 수익 비교 {''*50}")
print(f" {'':^8}{'A':>5} {'A%':>4} {'A수익':>10}"
f"{'B':>5} {'B%':>4} {'B수익':>10}"
f"{'C':>5} {'C%':>4} {'C수익':>10}")
all_months = sorted(set(list(ra["monthly"]) + list(rb["monthly"]) + list(rc["monthly"])))
for ym in all_months:
ma = ra["monthly"].get(ym, {"t":0,"w":0,"krw":0})
mb = rb["monthly"].get(ym, {"t":0,"w":0,"krw":0})
mc = rc["monthly"].get(ym, {"t":0,"w":0,"krw":0})
wra = ma["w"]/ma["t"]*100 if ma["t"] else 0
wrb = mb["w"]/mb["t"]*100 if mb["t"] else 0
wrc = mc["w"]/mc["t"]*100 if mc["t"] else 0
print(f" {ym:^8}{ma['t']:>4}{wra:>3.0f}% {ma['krw']:>+9,.0f}원 │ "
f"{mb['t']:>4}{wrb:>3.0f}% {mb['krw']:>+9,.0f}원 │ "
f"{mc['t']:>4}{wrc:>3.0f}% {mc['krw']:>+9,.0f}")
print("=" * W)
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,399 @@
"""거래량 선행(Volume Lead) 진입 전략 시뮬레이션.
3가지 전략 비교:
A (현행): 12h 가격 +5% 확인 + 1h 거래량 급증 → 진입 (이미 오른 뒤 추격)
B (신규): 가격 횡보 중 거래량 급증(축적) → 그 후 추세 +N% 시작 시 선진입
C (단순): 거래량 급증만 (베이스라인, 노이즈 확인용)
"""
import os as _os, sys as _sys
_sys.path.insert(0, _os.path.dirname(_os.path.dirname(_os.path.abspath(__file__))))
import os
import pickle
import time
from datetime import datetime
from pathlib import Path
import pandas as pd
from dotenv import load_dotenv
load_dotenv()
import pyupbit
# ── 공통 파라미터 ─────────────────────────────────────
STOP_LOSS_PCT = 0.015 # 트레일링 스탑 1.5%
TIME_STOP_HOURS = 8
TIME_STOP_MIN_PCT = 3.0
FEE = 0.0005
LOCAL_VOL_HOURS = 5 # 거래량 기준 이전 N시간
VOL_MULT = 2.0 # 거래량 배수 기준
# 현행 전략 파라미터
TREND_HOURS = 12
TREND_MIN_PCT = 5.0
# B 전략 파라미터: 거래량 선행 + 이후 소규모 추세 확인
PRICE_QUIET_PCT = 2.0 # 거래량 급증 시점 가격 횡보 기준 (2h 변동 < N%)
TREND_AFTER_VOL = 1.5 # 축적 신호 후 진입 기준 (vol 시점 대비 +N% 상승 시)
SIGNAL_TIMEOUT_H = 8 # 축적 신호 후 N시간 내 추세 미발생 시 초기화
FROM_DATE = "2026-01-15 00:00:00"
TICKERS = [
'KRW-DKA', 'KRW-LAYER', 'KRW-SIGN',
'KRW-SOL', 'KRW-ETH', 'KRW-XRP',
'KRW-HOLO', 'KRW-OM', 'KRW-ORBS',
]
CACHE_FILE = Path("vol_lead_cache.pkl")
# ── 데이터 로드 ───────────────────────────────────────
def fetch_all(ticker: str, from_date: str):
"""1h봉 전체 로드 (from_date 이후, 페이지 역방향 수집)."""
target = datetime.strptime(from_date, "%Y-%m-%d %H:%M:%S")
frames = []
to_dt = None
for _ in range(15): # 최대 15페이지 = 3000h ≈ 125일
kwargs: dict = dict(ticker=ticker, interval="minute60", count=200)
if to_dt:
kwargs["to"] = to_dt.strftime("%Y-%m-%d %H:%M:%S")
df = pyupbit.get_ohlcv(**kwargs)
if df is None or df.empty:
break
frames.append(df)
oldest = df.index[0].to_pydatetime().replace(tzinfo=None)
if oldest <= target:
break
to_dt = oldest
time.sleep(0.2)
if not frames:
return None
result = pd.concat(frames).sort_index().drop_duplicates()
result.index = result.index.tz_localize(None)
return result[result.index >= target]
def load_data() -> dict:
if CACHE_FILE.exists():
print(f"캐시 로드: {CACHE_FILE}")
return pickle.load(open(CACHE_FILE, "rb"))
data = {}
for ticker in TICKERS:
print(f" {ticker} 로딩...", end=" ", flush=True)
df = fetch_all(ticker, FROM_DATE)
if df is not None:
data[ticker] = df
print(f"{len(df)}봉 ({df.index[0].strftime('%m-%d')}~{df.index[-1].strftime('%m-%d')})")
else:
print("실패")
time.sleep(0.3)
pickle.dump(data, open(CACHE_FILE, "wb"))
return data
# ── 포지션 시뮬 ───────────────────────────────────────
def simulate_pos(df: pd.DataFrame, buy_idx: int, buy_price: float):
"""매수 후 청산 시뮬레이션.
- 최고가: 각 봉의 high 기준
- 스탑 발동 체크: 각 봉의 low 기준 (intra-candle 포착)
- 청산가: peak × (1 - stop_pct) 근사
"""
buy_dt = df.index[buy_idx]
peak = buy_price
for i in range(buy_idx + 1, len(df)):
row = df.iloc[i]
ts = df.index[i]
if row["high"] > peak:
peak = row["high"]
elapsed_h = (ts - buy_dt).total_seconds() / 3600
stop_price = peak * (1 - STOP_LOSS_PCT)
# 트레일링 스탑 (low가 stop_price 이하 진입 시)
if row["low"] <= stop_price:
sell_price = stop_price
pnl = (sell_price * (1 - FEE) - buy_price * (1 + FEE)) / (buy_price * (1 + FEE)) * 100
return pnl > 0, sell_price, ts, f"트레일링({pnl:+.1f}%)", pnl
# 타임 스탑
pnl_now = (row["close"] - buy_price) / buy_price * 100
if elapsed_h >= TIME_STOP_HOURS and pnl_now < TIME_STOP_MIN_PCT:
pnl = (row["close"] * (1 - FEE) - buy_price * (1 + FEE)) / (buy_price * (1 + FEE)) * 100
return pnl > 0, row["close"], ts, "타임스탑", pnl
last = df.iloc[-1]["close"]
pnl = (last * (1 - FEE) - buy_price * (1 + FEE)) / (buy_price * (1 + FEE)) * 100
return pnl > 0, last, df.index[-1], "데이터종료", pnl
# ── 현행 전략 (추세 확인형) ───────────────────────────
def run_trend(df: pd.DataFrame) -> list:
"""12h 가격 +5% 확인 + 1h 거래량 급증 + 1h 워치리스트."""
trades = []
watchlist_i = None
in_pos = False
buy_idx = buy_price = None
i = max(TREND_HOURS, LOCAL_VOL_HOURS + 2)
while i < len(df):
if in_pos:
is_win, sp, sdt, reason, pnl = simulate_pos(df, buy_idx, buy_price)
next_i = next((j for j in range(i, len(df)) if df.index[j] > sdt), len(df))
trades.append((is_win, pnl, df.index[buy_idx], sdt, reason))
in_pos = False
watchlist_i = None
i = next_i
continue
close = df.iloc[i]["close"]
past12 = df.iloc[i - TREND_HOURS]["close"]
trend_ok = (close - past12) / past12 * 100 >= TREND_MIN_PCT
vol_recent = df.iloc[i - 1]["volume"]
vol_avg = df.iloc[i - LOCAL_VOL_HOURS - 1:i - 1]["volume"].mean()
vol_ok = vol_avg > 0 and vol_recent >= vol_avg * VOL_MULT
if trend_ok and vol_ok:
if watchlist_i is None:
watchlist_i = i
elif i - watchlist_i >= 1: # 1h 확인
in_pos = True
buy_idx = i
buy_price = close
watchlist_i = None
else:
watchlist_i = None
i += 1
return trades
# ── B 전략: 거래량 선행 + 소규모 추세 확인 ───────────
def run_vol_lead(df: pd.DataFrame) -> list:
"""거래량 급증(축적) 감지 후 소규모 추세 확인 시 선진입.
흐름:
1. 직전 1h 거래량 > 이전 5h 평균 × VOL_MULT AND
2h 가격 변동 < PRICE_QUIET_PCT% (횡보 중 축적)
→ 축적 신호 기록 (signal_price = 현재가)
2. 신호 후 현재가가 signal_price 대비 +TREND_AFTER_VOL% 이상 상승 시 진입
(현행 +5% 대신 작은 기준으로 더 일찍 진입)
3. SIGNAL_TIMEOUT_H 시간 내 추세 미발생 → 신호 초기화
"""
trades = []
signal_i = None
signal_price = None
in_pos = False
buy_idx = buy_price = None
i = max(TREND_HOURS, LOCAL_VOL_HOURS + 2)
while i < len(df):
if in_pos:
is_win, sp, sdt, reason, pnl = simulate_pos(df, buy_idx, buy_price)
next_i = next((j for j in range(i, len(df)) if df.index[j] > sdt), len(df))
trades.append((is_win, pnl, df.index[buy_idx], sdt, reason))
in_pos = False
signal_i = None
signal_price = None
i = next_i
continue
close = df.iloc[i]["close"]
close_2h = df.iloc[i - 2]["close"]
quiet = abs(close - close_2h) / close_2h * 100 < PRICE_QUIET_PCT
vol_recent = df.iloc[i - 1]["volume"]
vol_avg = df.iloc[i - LOCAL_VOL_HOURS - 1:i - 1]["volume"].mean()
vol_spike = vol_avg > 0 and vol_recent >= vol_avg * VOL_MULT
# 축적 신호 갱신
if quiet and vol_spike:
if signal_i is None:
signal_i = i
signal_price = close
else:
if signal_i is not None and close < signal_price:
# 가격 하락 → 축적 실패, 초기화
signal_i = None
signal_price = None
# 타임아웃
if signal_i is not None and (i - signal_i) > SIGNAL_TIMEOUT_H:
signal_i = None
signal_price = None
# 진입: 축적 신호 후 가격 +TREND_AFTER_VOL% 이상 상승
if signal_i is not None:
move = (close - signal_price) / signal_price * 100
if move >= TREND_AFTER_VOL:
in_pos = True
buy_idx = i
buy_price = close
signal_i = None
signal_price = None
i += 1
return trades
# ── 결과 출력 ─────────────────────────────────────────
def summarize(label: str, trades: list) -> dict:
if not trades:
print(f" [{label}] 거래 없음")
return {"total": 0, "wins": 0, "wr": 0.0, "pnl": 0.0}
wins = sum(1 for t in trades if t[0])
total = len(trades)
pnl = sum(t[1] for t in trades)
wr = wins / total * 100
print(f" [{label}] {total}건 | 승률={wr:.0f}% ({wins}{total-wins}패) | 누적={pnl:+.2f}%")
for idx, (is_win, p, bdt, sdt, reason) in enumerate(trades, 1):
mark = "" if is_win else ""
print(f" #{idx}: {mark} {p:+.2f}% | {reason}"
f" ({bdt.strftime('%m-%d %H:%M')}{sdt.strftime('%m-%d %H:%M')})")
return {"total": total, "wins": wins, "wr": wr, "pnl": pnl}
def run_vol_lead_thresh(df: pd.DataFrame, thresh: float) -> list:
"""run_vol_lead의 TREND_AFTER_VOL 파라미터를 동적으로 받는 버전."""
trades = []
signal_i = None
signal_price = None
in_pos = False
buy_idx = buy_price = None
i = max(TREND_HOURS, LOCAL_VOL_HOURS + 2)
while i < len(df):
if in_pos:
is_win, sp, sdt, reason, pnl = simulate_pos(df, buy_idx, buy_price)
next_i = next((j for j in range(i, len(df)) if df.index[j] > sdt), len(df))
trades.append((is_win, pnl, df.index[buy_idx], sdt, reason))
in_pos = False
signal_i = None
signal_price = None
i = next_i
continue
close = df.iloc[i]["close"]
close_2h = df.iloc[i - 2]["close"]
quiet = abs(close - close_2h) / close_2h * 100 < PRICE_QUIET_PCT
vol_recent = df.iloc[i - 1]["volume"]
vol_avg = df.iloc[i - LOCAL_VOL_HOURS - 1:i - 1]["volume"].mean()
vol_spike = vol_avg > 0 and vol_recent >= vol_avg * VOL_MULT
if quiet and vol_spike:
if signal_i is None:
signal_i = i
signal_price = close
else:
if signal_i is not None and close < signal_price:
signal_i = None
signal_price = None
if signal_i is not None and (i - signal_i) > SIGNAL_TIMEOUT_H:
signal_i = None
signal_price = None
if signal_i is not None:
move = (close - signal_price) / signal_price * 100
if move >= thresh:
in_pos = True
buy_idx = i
buy_price = close
signal_i = None
signal_price = None
i += 1
return trades
def main() -> None:
print("데이터 로딩 중...")
data = load_data()
# ── A 현행 전략 (기준선) ─────────────────────────────
print(f"\n{'='*72}")
print(f"A(현행 12h+5%+거래량) 기준선 | {FROM_DATE[:10]} ~ 현재")
print(f"{'='*72}")
agg_a = {"total": 0, "wins": 0, "pnl": 0.0}
trend_results = {}
for ticker, df in data.items():
t = run_trend(df)
trend_results[ticker] = t
s = {"total": len(t), "wins": sum(1 for x in t if x[0]),
"pnl": sum(x[1] for x in t)}
agg_a["total"] += s["total"]
agg_a["wins"] += s["wins"]
agg_a["pnl"] += s["pnl"]
a_wr = agg_a["wins"] / agg_a["total"] * 100 if agg_a["total"] else 0
print(f"A 합계: {agg_a['total']}건 | 승률={a_wr:.0f}% | 누적={agg_a['pnl']:+.2f}%")
# ── B 전략: TREND_AFTER_VOL 파라미터 스윕 ───────────
THRESHOLDS = [0.5, 1.0, 1.5, 2.0, 2.5, 3.0, 4.0, 5.0]
print(f"\n{'='*72}")
print(f"B(거래량→+N% 선진입) 파라미터 스윕")
print(f"{''*72}")
print(f"{'임계값':>6}{'거래':>5} {'승률':>6} {'누적PnL':>10} │ vs A PnL")
print(f"{''*72}")
best = None
for thresh in THRESHOLDS:
agg = {"total": 0, "wins": 0, "pnl": 0.0}
for ticker, df in data.items():
t = run_vol_lead_thresh(df, thresh)
agg["total"] += len(t)
agg["wins"] += sum(1 for x in t if x[0])
agg["pnl"] += sum(x[1] for x in t)
wr = agg["wins"] / agg["total"] * 100 if agg["total"] else 0
diff = agg["pnl"] - agg_a["pnl"]
marker = " ← best" if (best is None or agg["pnl"] > best["pnl"]) else ""
if marker:
best = {**agg, "thresh": thresh, "wr": wr}
print(f"+{thresh:>4.1f}% │ {agg['total']:>5}{wr:>5.0f}% {agg['pnl']:>+9.2f}% │ {diff:>+8.2f}%{marker}")
print(f"{''*72}")
print(f"\n★ 최적 임계값: +{best['thresh']}% → "
f"{best['total']}건 | 승률={best['wr']:.0f}% | 누적={best['pnl']:+.2f}%")
# ── 최적 임계값으로 종목별 상세 출력 ─────────────────
best_thresh = best["thresh"]
print(f"\n{'='*72}")
print(f"★ B(vol→+{best_thresh}%) vs A(12h+5%+vol) 종목별 비교")
print(f"{''*72}")
print(f"{'종목':<14}{'A 현행':^24}{'B +{:.1f}%'.format(best_thresh):^24}")
print(f"{'':14}{'거래':>4} {'승률':>5} {'누적':>9}{'거래':>4} {'승률':>5} {'누적':>9}")
print(f"{''*72}")
for ticker, df in data.items():
t_a = trend_results[ticker]
t_b = run_vol_lead_thresh(df, best_thresh)
wa = sum(1 for x in t_a if x[0])
wb = sum(1 for x in t_b if x[0])
pa = sum(x[1] for x in t_a)
pb = sum(x[1] for x in t_b)
wr_a = wa / len(t_a) * 100 if t_a else 0
wr_b = wb / len(t_b) * 100 if t_b else 0
print(f"{ticker:<14}{len(t_a):>4}{wr_a:>4.0f}% {pa:>+8.2f}% │"
f" {len(t_b):>4}{wr_b:>4.0f}% {pb:>+8.2f}%")
if __name__ == "__main__":
main()

143
archive/tests/wf_cmp.py Normal file
View File

@@ -0,0 +1,143 @@
"""WF 윈도우 크기별 비교 시뮬레이션.
실제 42건 거래를 시간순으로 재생하며
WF_WINDOW 크기(2, 3, 5)에 따라 차단/허용 여부를 시뮬레이션.
차단된 거래 → P&L 0 (진입 안 함)
허용된 거래 → 실제 P&L 반영
"""
import os as _os, sys as _sys
_sys.path.insert(0, _os.path.dirname(_os.path.dirname(_os.path.abspath(__file__))))
import os
from dotenv import load_dotenv
load_dotenv()
import oracledb
def get_conn():
return oracledb.connect(
user=os.getenv('ORACLE_USER'), password=os.getenv('ORACLE_PASSWORD'),
dsn=os.getenv('ORACLE_DSN'), config_dir=os.getenv('ORACLE_WALLET'))
def simulate_wf(trades, window, min_wr):
"""
trades: [(ticker, is_win, pnl_pct, krw_profit, traded_at), ...] 시간순
window: WF 윈도우 크기
min_wr: 최소 승률 임계값
Returns: 허용된 거래 목록, 차단된 거래 목록, 요약
"""
history = {} # ticker → [bool, ...]
allowed = []
blocked = []
for t in trades:
ticker, is_win, pnl, profit, dt = t
hist = history.get(ticker, [])
# WF 차단 여부 판단
is_blocked = False
if len(hist) >= window:
recent_wr = sum(hist[-window:]) / window
if recent_wr < min_wr:
is_blocked = True
if is_blocked:
blocked.append(t)
else:
allowed.append(t)
# 실제 결과를 이력에 추가
hist = hist + [bool(is_win)]
if len(hist) > window * 2:
hist = hist[-window:]
history[ticker] = hist
total = len(allowed)
wins = sum(1 for t in allowed if t[1])
pnl = sum(t[2] for t in allowed)
profit = sum(t[3] for t in allowed)
return allowed, blocked, {
'total': total, 'wins': wins,
'wr': wins/total*100 if total else 0,
'pnl': pnl, 'profit': profit,
'blocked_count': len(blocked),
}
def main():
conn = get_conn()
cur = conn.cursor()
# 전체 거래 시간순 로드
cur.execute("""
SELECT ticker, is_win, pnl_pct, NVL(krw_profit,0), traded_at
FROM trade_results
ORDER BY traded_at
""")
trades = cur.fetchall()
print(f"전체 거래: {len(trades)}\n")
configs = [
(2, 0.5, "WF=2 (2연패→차단, 1승→해제)"),
(3, 0.34, "WF=3 (3건중 1승 이상 필요)"),
(5, 0.40, "WF=5 (5건중 2승 이상, 현행)"),
]
results = []
for window, min_wr, label in configs:
allowed, blocked, stats = simulate_wf(trades, window, min_wr)
stats['label'] = label
stats['window'] = window
results.append((label, allowed, blocked, stats))
print(f"[{label}]")
print(f" 허용: {stats['total']}건 | 승률={stats['wr']:.1f}% | "
f"누적수익={stats['profit']:+,.0f}원 | 차단={stats['blocked_count']}")
# 차단된 거래 상세
if blocked:
print(f" 차단된 거래:")
blocked_by_ticker = {}
for t in blocked:
blocked_by_ticker.setdefault(t[0], []).append(t)
for ticker, ts in blocked_by_ticker.items():
pnls = [f"{t[2]:+.1f}%" for t in ts]
print(f" {ticker}: {len(ts)}{pnls}")
print()
# 상세 비교표: 거래별 허용/차단 여부
print("=" * 70)
print(f"{'날짜':>12} {'종목':>12} {'결과':>6} {'PnL':>8}"
f"{'WF=2':>6} {'WF=3':>6} {'WF=5':>6}")
print("" * 70)
# 각 설정별 허용 set 구성 (traded_at + ticker로 식별)
allowed_sets = []
for _, allowed, _, _ in results:
allowed_sets.append(set((t[0], t[4]) for t in allowed))
for t in trades:
ticker, is_win, pnl, profit, dt = t
win_mark = "" if is_win else ""
cols = []
for aset in allowed_sets:
if (ticker, dt) in aset:
cols.append("허용")
else:
cols.append("🔴차단")
print(f"{dt.strftime('%m-%d %H:%M'):>12} {ticker:>12} {win_mark:>4} {pnl:>+7.1f}% │ "
f"{cols[0]:>6} {cols[1]:>6} {cols[2]:>6}")
# 최종 요약
print("\n" + "=" * 70)
print(f"{'설정':<35} {'거래':>5} {'승률':>7} {'KRW수익':>12} {'차단':>5}")
print("" * 70)
for label, _, _, s in results:
print(f"{label:<35} {s['total']:>5}{s['wr']:>6.1f}% {s['profit']:>+12,.0f}{s['blocked_count']:>4}건 차단")
conn.close()
if __name__ == "__main__":
main()

214
archive/tests/wf_cmp2.py Normal file
View File

@@ -0,0 +1,214 @@
"""WF 윈도우 비교 시뮬레이션 v2 - 실거래 + 이후 시뮬 거래 통합.
Phase 1: 실제 42건 거래를 WF 설정별로 허용/차단 재생
Phase 2: 마지막 실거래 이후 price_history 기반 신호로 추가 거래 시뮬
(추세 2h+5% + 15분 워치리스트, 모멘텀은 API 한계로 생략)
→ WF 상태는 Phase1에서 이어짐
비교 설정:
A: WF=2 (min_wr=0.0, 즉 2연패시만 차단 — last2=[L,L]이면 차단)
B: WF=3 (min_wr=0.34)
C: WF=5 현행 (min_wr=0.40)
D: WF 없음
"""
import os as _os, sys as _sys
_sys.path.insert(0, _os.path.dirname(_os.path.dirname(_os.path.abspath(__file__))))
import os, time
from datetime import datetime
from dotenv import load_dotenv
load_dotenv()
import oracledb
STOP_LOSS_PCT = float(os.getenv("STOP_LOSS_PCT", "1.5")) / 100
TIME_STOP_HOURS = int(os.getenv("TIME_STOP_HOURS", "8"))
TIME_STOP_MIN_PCT = float(os.getenv("TIME_STOP_MIN_GAIN_PCT", "3")) / 100
TREND_MIN_PCT = 5.0
CONFIRM_MINUTES = 15
FEE = 0.0005
def get_conn():
return oracledb.connect(
user=os.getenv('ORACLE_USER'), password=os.getenv('ORACLE_PASSWORD'),
dsn=os.getenv('ORACLE_DSN'), config_dir=os.getenv('ORACLE_WALLET'))
# ── WF 판단 ───────────────────────────────────────────
def is_wf_blocked(hist, window, min_wr):
if window == 0: return False
if len(hist) < window: return False
wr = sum(hist[-window:]) / window
return wr < min_wr
# ── 추세 체크 (price_history 기반) ────────────────────
def check_trend(prices, idx):
lb = 12 # 2h = 12 * 10분봉
if idx < lb: return False
curr, past = prices[idx][0], prices[idx-lb][0]
return past > 0 and (curr-past)/past*100 >= TREND_MIN_PCT
# ── 포지션 시뮬 ───────────────────────────────────────
def simulate_pos(prices, buy_idx, buy_price):
buy_dt = prices[buy_idx][1]
peak = buy_price
for price, ts in prices[buy_idx+1:]:
if price > peak: peak = price
elapsed_h = (ts - buy_dt).total_seconds() / 3600
pnl = (price - buy_price) / buy_price
if (peak - price) / peak >= STOP_LOSS_PCT:
net = (price*(1-FEE) - buy_price*(1+FEE)) / (buy_price*(1+FEE)) * 100
return net>0, price, ts, f"트레일링({pnl*100:+.1f}%)", net
if elapsed_h >= TIME_STOP_HOURS and pnl < TIME_STOP_MIN_PCT:
net = (price*(1-FEE) - buy_price*(1+FEE)) / (buy_price*(1+FEE)) * 100
return net>0, price, ts, "타임스탑", net
lp, lt = prices[-1]
net = (lp*(1-FEE) - buy_price*(1+FEE)) / (buy_price*(1+FEE)) * 100
return net>0, lp, lt, "데이터종료", net
# ── Phase1: 실거래 재생 ───────────────────────────────
def phase1(real_trades, window, min_wr):
"""42건 실거래 재생. Returns (허용목록, 차단목록, history_per_ticker)"""
history = {}
allowed = []
blocked = []
for t in real_trades:
ticker, is_win, pnl, profit, dt = t
hist = history.get(ticker, [])
if is_wf_blocked(hist, window, min_wr):
blocked.append(('block', ticker, is_win, pnl, profit, dt))
else:
allowed.append(('real', ticker, is_win, pnl, profit, dt))
hist = hist + [bool(is_win)]
if window > 0 and len(hist) > window * 2:
hist = hist[-window:]
history[ticker] = hist
return allowed, blocked, history
# ── Phase2: price_history 신호 시뮬 ──────────────────
def phase2(cur, history, real_last_dt, window, min_wr):
"""실거래 종료 이후 price_history 기반 신호 시뮬레이션."""
# 스캔 대상: 실거래에 등장한 종목 전체
tickers = list(history.keys()) if history else []
# 실거래 후 WF 해제 가능한 종목만
# (차단됐어도 shadow 없이는 해제 불가 → 차단 상태 종목 제외)
active_tickers = []
for ticker in tickers:
hist = history.get(ticker, [])
if not is_wf_blocked(hist, window, min_wr):
active_tickers.append(ticker)
if not active_tickers:
return [], history
sim_trades = []
for ticker in active_tickers:
cur.execute("""
SELECT price, recorded_at FROM price_history
WHERE ticker=:t AND recorded_at > :dt
ORDER BY recorded_at
""", t=ticker, dt=real_last_dt)
prices = cur.fetchall()
if len(prices) < 13: continue
hist = list(history.get(ticker, []))
watchlist_dt = None
in_pos = False
buy_idx = buy_price = None
idx = 0
while idx < len(prices):
price, dt = prices[idx]
if in_pos:
is_win, sp, sdt, reason, pnl = simulate_pos(prices, buy_idx, buy_price)
next_idx = next((i for i,(_, ts) in enumerate(prices) if ts > sdt), len(prices))
profit = pnl * 3333333 / 100 # 포지션당 예산 기준 근사
sim_trades.append(('sim', ticker, is_win, pnl, profit, dt))
hist = hist + [bool(is_win)]
if window > 0 and len(hist) > window * 2:
hist = hist[-window:]
history[ticker] = hist
in_pos = False
watchlist_dt = None
idx = next_idx
continue
if is_wf_blocked(hist, window, min_wr):
idx += 1
continue
trend_ok = check_trend(prices, idx)
if trend_ok:
if watchlist_dt is None:
watchlist_dt = dt
elif (dt - watchlist_dt).total_seconds() >= CONFIRM_MINUTES * 60:
in_pos = True
buy_idx = idx
buy_price = price
watchlist_dt = None
else:
watchlist_dt = None
idx += 1
return sim_trades, history
# ── 요약 출력 ─────────────────────────────────────────
def print_summary(label, p1_allowed, p1_blocked, p2_trades):
all_trades = p1_allowed + p2_trades
total = len(all_trades)
wins = sum(1 for t in all_trades if t[2])
pnl = sum(t[4] for t in all_trades)
wr = wins/total*100 if total else 0
blk = len(p1_blocked)
p2_cnt = len(p2_trades)
p2_win = sum(1 for t in p2_trades if t[2])
print(f"\n[{label}]")
print(f" 실거래 허용: {len(p1_allowed)}건 | 차단: {blk}")
print(f" 추가 시뮬: {p2_cnt}건 ({p2_win}승)")
print(f" ─────────────────────────────────────")
print(f" 합계: {total}건 | 승률={wr:.1f}% | KRW={pnl:+,.0f}")
return {'label': label, 'total': total, 'wins': wins, 'wr': wr, 'pnl': pnl,
'blk': blk, 'p2': p2_cnt}
def main():
conn = get_conn()
cur = conn.cursor()
cur.execute("""
SELECT ticker, is_win, pnl_pct, NVL(krw_profit,0), traded_at
FROM trade_results ORDER BY traded_at
""")
real_trades = cur.fetchall()
real_last_dt = real_trades[-1][4]
print(f"실거래: {len(real_trades)}건 (마지막: {real_last_dt.strftime('%m-%d %H:%M')})")
cur.execute("SELECT MAX(recorded_at) FROM price_history")
ph_last = cur.fetchone()[0]
print(f"price_history 끝: {ph_last.strftime('%m-%d %H:%M')}\n")
configs = [
(2, 0.01, "WF=2 (2연패→차단)"),
(3, 0.34, "WF=3"),
(5, 0.40, "WF=5 현행"),
(0, 0.00, "WF없음"),
]
summary = []
for window, min_wr, label in configs:
p1_allowed, p1_blocked, history = phase1(real_trades, window, min_wr)
p2_trades, _ = phase2(cur, history, real_last_dt, window, min_wr)
s = print_summary(label, p1_allowed, p1_blocked, p2_trades)
summary.append(s)
print(f"\n{'='*62}")
print(f"{'설정':<22} {'허용':>5} {'차단':>5} {'추가시뮬':>8} {'승률':>7} {'KRW수익':>13}")
print(f"{''*62}")
for s in summary:
print(f"{s['label']:<22} {s['total']-s['p2']:>5}{s['blk']:>5}"
f"{s['p2']:>6}{s['wr']:>6.1f}% {s['pnl']:>+12,.0f}")
conn.close()
if __name__ == "__main__":
main()

433
backtest_march.py Normal file
View File

@@ -0,0 +1,433 @@
"""3월 백테스트 — 현재 tick_trader 설정 기준 (1분봉 시뮬레이션)
20초봉 → 1분봉 환산:
VOL_LOOKBACK 61(20s) → 20(1min)
트레일/손절은 가격 기반이라 동일
타임아웃 4h = 240분
"""
import time
from collections import defaultdict, deque
from datetime import datetime, timedelta
import pyupbit
from dotenv import load_dotenv
load_dotenv()
# ── 설정 (현재 tick_trader 기준) ──
TICKERS = [
'KRW-ETH', 'KRW-XRP', 'KRW-SOL', 'KRW-DOGE', 'KRW-SIGN',
'KRW-BARD', 'KRW-KITE', 'KRW-CFG', 'KRW-SXP', 'KRW-ARDR',
]
VOL_LOOKBACK = 20 # 1분봉 기준
VOL_MIN = 5.0
VOL_KRW_MIN = 5_000_000 # 1분봉 거래대금 (20초봉 500만 × 3)
SPREAD_MIN = 0.3 # 횡보 필터: 5봉 변동폭 % (15봉/3)
HIGHPOS_BARS = 30 # 고점 필터: 30봉(30분)
HIGHPOS_THR = 0.9
HIGHPOS_MOVE = 1.0 # 구간 변동 1%+
TRAIL_PCT = 0.015
MIN_PROFIT_PCT = 0.005
STOP_LOSS_PCT = 0.02
TIMEOUT_BARS = 240 # 4시간
MAX_POS = 3
PER_POS = 33_333
FEE = 0.0005
def calc_vr(bars, idx):
start = max(0, idx - VOL_LOOKBACK)
end = max(0, idx - 1)
baseline = sorted(bars[i]['volume'] for i in range(start, end))
if not baseline:
return 0.0
trim = max(1, len(baseline) // 10)
trimmed = baseline[:len(baseline) - trim]
if not trimmed:
return 0.0
avg = sum(trimmed) / len(trimmed)
return bars[idx]['volume'] / avg if avg > 0 else 0.0
def load_data(ticker, start_date='2026-03-01', end_date='2026-03-07'):
"""Oracle backtest_ohlcv에서 1분봉 로드, 없으면 pyupbit 페이징."""
import os
bars = []
# Oracle DB 시도
try:
import oracledb
kwargs = dict(
user=os.environ['ORACLE_USER'],
password=os.environ['ORACLE_PASSWORD'],
dsn=os.environ['ORACLE_DSN'],
)
if w := os.environ.get('ORACLE_WALLET'):
kwargs['config_dir'] = w
conn = oracledb.connect(**kwargs)
cur = conn.cursor()
cur.execute("""
SELECT ts, open_p, high_p, low_p, close_p, volume_p
FROM backtest_ohlcv
WHERE ticker = :t AND interval_cd = 'minute1'
AND ts >= TO_TIMESTAMP(:s, 'YYYY-MM-DD')
AND ts < TO_TIMESTAMP(:e, 'YYYY-MM-DD')
ORDER BY ts
""", {'t': ticker, 's': start_date, 'e': end_date})
rows = cur.fetchall()
conn.close()
if rows:
for ts, o, h, l, c, v in rows:
bars.append({'ts': ts, 'open': float(o), 'high': float(h),
'low': float(l), 'close': float(c), 'volume': float(v)})
return bars
except Exception as e:
pass
# Fallback: pyupbit 페이징
import pandas as pd
all_df = []
to = end_date + ' 23:59:59'
for _ in range(60):
df = pyupbit.get_ohlcv(ticker, interval='minute1', count=200, to=to)
if df is None or df.empty:
break
all_df.append(df)
earliest = df.index[0]
if str(earliest)[:10] < start_date:
break
to = (earliest - timedelta(minutes=1)).strftime('%Y-%m-%d %H:%M:%S')
time.sleep(0.12)
if not all_df:
return []
merged = pd.concat(all_df).sort_index()
merged = merged[~merged.index.duplicated(keep='first')]
merged = merged[start_date:end_date]
for ts, row in merged.iterrows():
bars.append({'ts': ts, 'open': row['open'], 'high': row['high'],
'low': row['low'], 'close': row['close'], 'volume': row['volume']})
return bars
def run_backtest():
print("=" * 80)
print("3월 백테스트 (1분봉 시뮬레이션)")
print(f"종목: {len(TICKERS)}개 | VOL >= {VOL_MIN}x | 거래대금 >= {VOL_KRW_MIN/1e6:.0f}M")
print(f"트레일: 고점-{TRAIL_PCT*100:.1f}% (최소 +{MIN_PROFIT_PCT*100:.1f}%) | 손절: -{STOP_LOSS_PCT*100:.1f}% | 타임아웃: {TIMEOUT_BARS//60}h")
print(f"포지션: 최대 {MAX_POS} | 종목당 {PER_POS:,}")
print("=" * 80)
# 데이터 로드
all_bars = {}
for t in TICKERS:
print(f" 로딩 {t}...", end=' ')
bars = load_data(t)
print(f"{len(bars)}")
all_bars[t] = bars
time.sleep(0.2)
# 모든 종목의 타임스탬프를 합쳐서 시간순 이벤트 생성
events = []
for t in TICKERS:
for i, b in enumerate(all_bars[t]):
events.append((b['ts'], t, i))
events.sort(key=lambda x: x[0])
# 시뮬레이션
positions = {}
trades = []
signals_count = 0
skipped_spread = 0
skipped_highpos = 0
skipped_maxpos = 0
skipped_greens = 0
for ts, ticker, idx in events:
bar_list = all_bars[ticker]
b = bar_list[idx]
# ── 포지션 관리 (매 봉마다) ──
for t in list(positions.keys()):
pos = positions[t]
# 해당 종목의 현재 봉 찾기
t_bars = all_bars[t]
t_idx = pos['last_idx']
# 시간이 현재 이벤트 이후인 봉이 있으면 업데이트
while t_idx + 1 < len(t_bars) and t_bars[t_idx + 1]['ts'] <= ts:
t_idx += 1
pos['last_idx'] = t_idx
cur_bar = t_bars[t_idx]
cur_price = cur_bar['close']
cur_high = cur_bar['high']
cur_low = cur_bar['low']
# peak 갱신 (봉 고가 기준)
pos['running_peak'] = max(pos['running_peak'], cur_high)
bars_held = t_idx - pos['entry_idx']
# 봉 내 손절 체크 (저가 기준)
loss = (cur_low - pos['entry_price']) / pos['entry_price']
if loss <= -STOP_LOSS_PCT:
exit_p = pos['entry_price'] * (1 - STOP_LOSS_PCT)
pnl = -STOP_LOSS_PCT * 100
krw = PER_POS * (-STOP_LOSS_PCT) - PER_POS * FEE * 2
trades.append({
'ticker': t, 'entry_ts': pos['entry_ts'], 'exit_ts': cur_bar['ts'],
'entry': pos['entry_price'], 'exit': exit_p,
'pnl_pct': pnl, 'pnl_krw': krw, 'bars': bars_held, 'tag': 'stoploss',
'peak': pos['running_peak'],
'vol_ratio': pos.get('vol_ratio', 0),
'prev_greens': pos.get('prev_greens', 0),
'bar_rise_pct': pos.get('bar_rise_pct', 0),
})
del positions[t]
continue
# 트레일링 체크 (종가 기준)
profit = (cur_price - pos['entry_price']) / pos['entry_price']
drop = (pos['running_peak'] - cur_price) / pos['running_peak'] if pos['running_peak'] > 0 else 0
if profit >= MIN_PROFIT_PCT and drop >= TRAIL_PCT:
pnl = profit * 100
krw = PER_POS * profit - PER_POS * FEE * 2
trades.append({
'ticker': t, 'entry_ts': pos['entry_ts'], 'exit_ts': cur_bar['ts'],
'entry': pos['entry_price'], 'exit': cur_price,
'pnl_pct': pnl, 'pnl_krw': krw, 'bars': bars_held, 'tag': 'trail',
'peak': pos['running_peak'],
'vol_ratio': pos.get('vol_ratio', 0),
'prev_greens': pos.get('prev_greens', 0),
'bar_rise_pct': pos.get('bar_rise_pct', 0),
})
del positions[t]
continue
# 타임아웃
if bars_held >= TIMEOUT_BARS:
pnl = profit * 100
krw = PER_POS * profit - PER_POS * FEE * 2
trades.append({
'ticker': t, 'entry_ts': pos['entry_ts'], 'exit_ts': cur_bar['ts'],
'entry': pos['entry_price'], 'exit': cur_price,
'pnl_pct': pnl, 'pnl_krw': krw, 'bars': bars_held, 'tag': 'timeout',
'peak': pos['running_peak'],
'vol_ratio': pos.get('vol_ratio', 0),
'prev_greens': pos.get('prev_greens', 0),
'bar_rise_pct': pos.get('bar_rise_pct', 0),
})
del positions[t]
continue
# ── 시그널 감지 ──
if idx < VOL_LOOKBACK + 3:
continue
if ticker in positions:
continue
if len(positions) >= MAX_POS:
continue
# 양봉
if b['close'] <= b['open']:
continue
# 거래량 배수
vr = calc_vr(bar_list, idx)
if vr < VOL_MIN:
continue
# 거래대금 하한
bar_krw = b['close'] * b['volume']
if bar_krw < VOL_KRW_MIN:
continue
# 횡보 필터 (5봉)
recent = bar_list[idx-4:idx+1]
p_high = max(x['high'] for x in recent)
p_low = min(x['low'] for x in recent)
if p_low > 0:
spread = (p_high - p_low) / p_low * 100
if spread < SPREAD_MIN:
skipped_spread += 1
continue
# 고점 필터
long_start = max(0, idx - HIGHPOS_BARS)
long_bars = bar_list[long_start:idx+1]
l_high = max(x['high'] for x in long_bars)
l_low = min(x['low'] for x in long_bars)
if l_high > l_low:
pos_in_range = (b['close'] - l_low) / (l_high - l_low)
move_pct = (l_high - l_low) / l_low * 100
if pos_in_range > HIGHPOS_THR and move_pct > HIGHPOS_MOVE:
skipped_highpos += 1
continue
# 진입 전 연속 양봉 수
prev_greens = 0
for k in range(idx-1, max(idx-10, 0), -1):
if bar_list[k]['close'] > bar_list[k]['open']:
prev_greens += 1
else:
break
# 연속 양봉 필터: 2봉 이상 연속 양봉 필요
if prev_greens < 2:
skipped_greens += 1
continue
signals_count += 1
# 진입봉 상승폭
bar_rise_pct = (b['close'] - b['open']) / b['open'] * 100 if b['open'] > 0 else 0
# 매수 (현재가)
entry_price = b['close']
positions[ticker] = {
'entry_price': entry_price,
'entry_ts': b['ts'],
'entry_idx': idx,
'last_idx': idx,
'running_peak': b['high'],
'vol_ratio': vr,
'prev_greens': prev_greens,
'bar_rise_pct': bar_rise_pct,
}
# 미청산 포지션 강제 종료
for t, pos in list(positions.items()):
t_bars = all_bars[t]
last_bar = t_bars[-1]
cur_price = last_bar['close']
profit = (cur_price - pos['entry_price']) / pos['entry_price']
krw = PER_POS * profit - PER_POS * FEE * 2
trades.append({
'ticker': t, 'entry_ts': pos['entry_ts'], 'exit_ts': last_bar['ts'],
'entry': pos['entry_price'], 'exit': cur_price,
'pnl_pct': profit * 100, 'pnl_krw': krw,
'bars': len(t_bars) - 1 - pos['entry_idx'], 'tag': 'open',
'peak': pos['running_peak'],
'vol_ratio': pos.get('vol_ratio', 0),
'prev_greens': pos.get('prev_greens', 0),
'bar_rise_pct': pos.get('bar_rise_pct', 0),
})
# ── 결과 출력 ──
print("\n" + "=" * 80)
print("결과")
print("=" * 80)
print(f"\n시그널 발생: {signals_count}")
print(f"필터 제외: 횡보 {skipped_spread}건, 고점 {skipped_highpos}건, 연속양봉<2 {skipped_greens}")
print(f"총 거래: {len(trades)}\n")
if not trades:
print("거래 없음")
return
wins = [t for t in trades if t['pnl_pct'] > 0]
losses = [t for t in trades if t['pnl_pct'] <= 0]
total_krw = sum(t['pnl_krw'] for t in trades)
win_rate = len(wins) / len(trades) * 100
print(f"승률: {win_rate:.1f}% ({len(wins)}{len(losses)}패)")
print(f"총 PNL: {total_krw:+,.0f}")
print(f"평균 PNL: {sum(t['pnl_pct'] for t in trades)/len(trades):+.2f}%")
if wins:
print(f"평균 수익 (승): +{sum(t['pnl_pct'] for t in wins)/len(wins):.2f}%")
if losses:
print(f"평균 손실 (패): {sum(t['pnl_pct'] for t in losses)/len(losses):+.2f}%")
# 청산 유형별
by_tag = defaultdict(list)
for t in trades:
by_tag[t['tag']].append(t)
print("\n[청산 유형별]")
for tag, tlist in sorted(by_tag.items()):
avg_pnl = sum(t['pnl_pct'] for t in tlist) / len(tlist)
total = sum(t['pnl_krw'] for t in tlist)
print(f" {tag:10s}: {len(tlist):3d}건 avg {avg_pnl:+.2f}% 합계 {total:+,.0f}")
# 종목별
by_ticker = defaultdict(list)
for t in trades:
by_ticker[t['ticker']].append(t)
print("\n[종목별]")
for ticker in TICKERS:
tlist = by_ticker.get(ticker, [])
if not tlist:
continue
w = sum(1 for t in tlist if t['pnl_pct'] > 0)
total = sum(t['pnl_krw'] for t in tlist)
avg = sum(t['pnl_pct'] for t in tlist) / len(tlist)
print(f" {ticker:12s}: {len(tlist):3d}{w}{len(tlist)-w}패 avg {avg:+.2f}% 합계 {total:+,.0f}")
# 승패별 시그널 특성 분석
print("\n[승패별 시그널 특성]")
win_vrs = [t['vol_ratio'] for t in trades if t['pnl_pct'] > 0]
loss_vrs = [t['vol_ratio'] for t in trades if t['pnl_pct'] <= 0]
print(f" 승리 거래량비: 평균 {sum(win_vrs)/len(win_vrs):.1f}x 중간값 {sorted(win_vrs)[len(win_vrs)//2]:.1f}x")
print(f" 패배 거래량비: 평균 {sum(loss_vrs)/len(loss_vrs):.1f}x 중간값 {sorted(loss_vrs)[len(loss_vrs)//2]:.1f}x")
# 거래량비 구간별 승률
print("\n[거래량비 구간별 승률]")
for lo, hi in [(5,7),(7,10),(10,15),(15,30),(30,999)]:
subset = [t for t in trades if lo <= t['vol_ratio'] < hi]
if not subset:
continue
w = sum(1 for t in subset if t['pnl_pct'] > 0)
avg_pnl = sum(t['pnl_pct'] for t in subset) / len(subset)
print(f" {lo:3d}~{hi:3d}x: {len(subset):3d}건 승률 {w/len(subset)*100:.0f}% avg {avg_pnl:+.2f}%")
# 연속 양봉 여부별 승률
print("\n[진입 전 연속 양봉 수별 승률]")
for n_green in [1, 2, 3]:
subset = [t for t in trades if t.get('prev_greens', 0) >= n_green]
if not subset:
continue
w = sum(1 for t in subset if t['pnl_pct'] > 0)
avg_pnl = sum(t['pnl_pct'] for t in subset) / len(subset)
print(f" {n_green}봉+ 연속양봉: {len(subset):3d}건 승률 {w/len(subset)*100:.0f}% avg {avg_pnl:+.2f}%")
# 진입봉 상승폭별 승률
print("\n[진입봉 상승폭별 승률]")
for lo, hi in [(0,0.3),(0.3,0.7),(0.7,1.5),(1.5,100)]:
subset = [t for t in trades if lo <= t.get('bar_rise_pct', 0) < hi]
if not subset:
continue
w = sum(1 for t in subset if t['pnl_pct'] > 0)
avg_pnl = sum(t['pnl_pct'] for t in subset) / len(subset)
print(f" {lo:.1f}~{hi:.1f}%: {len(subset):3d}건 승률 {w/len(subset)*100:.0f}% avg {avg_pnl:+.2f}%")
# 상위 거래
trades_sorted = sorted(trades, key=lambda x: -x['pnl_pct'])
print("\n[TOP 10 수익]")
for t in trades_sorted[:10]:
peak_pnl = (t['peak'] - t['entry']) / t['entry'] * 100
print(f" {t['ticker']:12s} {str(t['entry_ts'])[5:16]} {t['entry']:>12,.1f}{t['exit']:>12,.1f} "
f"PNL {t['pnl_pct']:+.2f}% 고점 +{peak_pnl:.1f}% {t['bars']}봉 [{t['tag']}]")
print("\n[WORST 10 손실]")
for t in trades_sorted[-10:]:
print(f" {t['ticker']:12s} {str(t['entry_ts'])[5:16]} {t['entry']:>12,.1f}{t['exit']:>12,.1f} "
f"PNL {t['pnl_pct']:+.2f}% {t['bars']}봉 [{t['tag']}]")
# 일별 PNL
by_date = defaultdict(float)
by_date_cnt = defaultdict(int)
for t in trades:
d = str(t['exit_ts'])[:10]
by_date[d] += t['pnl_krw']
by_date_cnt[d] += 1
print("\n[일별 PNL]")
for d in sorted(by_date.keys()):
print(f" {d}: {by_date_cnt[d]:3d}{by_date[d]:+,.0f}")
if __name__ == '__main__':
run_backtest()

696
core/llm_advisor.py Normal file
View File

@@ -0,0 +1,696 @@
"""OpenRouter LLM 기반 매수 어드바이저.
시그널 감지 후 LLM이 매수 여부를 판단한다.
매도는 트레일링 스탑으로 대체되어 LLM을 사용하지 않는다.
DB Tool (OpenAI function calling):
- get_price_ticks: Oracle price_tick (최근 N분 가격 틱)
- get_ohlcv: Oracle backtest_ohlcv 1분봉
- get_ticker_context: 종목 평판 (가격 변동, 뉴스)
- get_btc_trend: BTC 최근 동향
"""
from __future__ import annotations
import json
import logging
import os
from datetime import datetime
from typing import Optional
log = logging.getLogger(__name__)
# 프롬프트에 포함할 봉 수
INPUT_BARS = 20
# ── Oracle DB Tools ──────────────────────────────────────────────────────────
def _get_conn():
"""Oracle ADB 연결 (.env 기반)."""
import oracledb
kwargs = dict(
user = os.environ['ORACLE_USER'],
password = os.environ['ORACLE_PASSWORD'],
dsn = os.environ['ORACLE_DSN'],
)
if w := os.environ.get('ORACLE_WALLET'):
kwargs['config_dir'] = w
return oracledb.connect(**kwargs)
def _tool_get_price_ticks(ticker: str, minutes: int = 10) -> str:
"""Oracle price_tick 테이블에서 최근 N분 가격 틱 조회."""
try:
conn = _get_conn()
cur = conn.cursor()
cur.execute(
"""SELECT ts, price
FROM price_tick
WHERE ticker = :t
AND ts >= SYSTIMESTAMP - NUMTODSINTERVAL(:m, 'MINUTE')
ORDER BY ts DESC
FETCH FIRST 100 ROWS ONLY""",
{'t': ticker, 'm': minutes},
)
rows = cur.fetchall()
conn.close()
if not rows:
return f"{ticker} 최근 {minutes}분 틱 데이터 없음"
lines = [f" {r[0].strftime('%H:%M:%S')} {float(r[1]):>12,.2f}" for r in rows]
return f"{ticker} 최근 {minutes}분 가격 틱 ({len(rows)}건):\n" + "\n".join(lines)
except Exception as e:
return f"DB 오류: {e}"
def _tool_get_ohlcv(ticker: str, limit: int = 30) -> str:
"""Oracle backtest_ohlcv 1분봉 최근 N개 조회 (지지/저항 파악용)."""
try:
conn = _get_conn()
cur = conn.cursor()
cur.execute(
"""SELECT ts, open_p, high_p, low_p, close_p, volume_p
FROM backtest_ohlcv
WHERE ticker = :t
AND interval_cd = 'minute1'
ORDER BY ts DESC
FETCH FIRST :n ROWS ONLY""",
{'t': ticker, 'n': limit},
)
rows = cur.fetchall()
conn.close()
if not rows:
return f"{ticker} 1분봉 데이터 없음"
lines = [
f" {r[0].strftime('%H:%M')} O{float(r[1]):>10,.0f} H{float(r[2]):>10,.0f}"
f" L{float(r[3]):>10,.0f} C{float(r[4]):>10,.0f} V{float(r[5]):.0f}"
for r in reversed(rows)
]
return f"{ticker} 1분봉 최근 {len(rows)}개:\n" + "\n".join(lines)
except Exception as e:
return f"DB 오류: {e}"
# ── 종목 컨텍스트 조회 ────────────────────────────────────────────────────────
def _tool_get_context(ticker: str) -> str:
"""ticker_context 테이블에서 종목의 가격 통계 + 뉴스 조회."""
try:
conn = _get_conn()
cur = conn.cursor()
cur.execute(
"SELECT context_type, content FROM ticker_context WHERE ticker = :t",
{'t': ticker},
)
rows = cur.fetchall()
if not rows:
conn.close()
return f"{ticker} 컨텍스트 데이터 없음"
parts = []
for ctx_type, content in rows:
# CLOB(LOB) → str 변환 (conn 닫기 전에 읽어야 함)
text = content.read() if hasattr(content, 'read') else str(content)
parts.append(f"[{ctx_type}]\n{text}")
conn.close()
return f"{ticker} 종목 컨텍스트:\n" + "\n\n".join(parts)
except Exception as e:
return f"DB 오류: {e}"
# ── 거래 이력 조회 ────────────────────────────────────────────────────────────
def _tool_get_trade_history(ticker: str, limit: int = 10) -> str:
"""trade_results 테이블에서 해당 종목 최근 거래 이력 조회."""
try:
conn = _get_conn()
cur = conn.cursor()
cur.execute(
"""SELECT traded_at, is_win, pnl_pct, buy_price, sell_price, sell_reason
FROM trade_results
WHERE ticker = :t
ORDER BY traded_at DESC
FETCH FIRST :n ROWS ONLY""",
{'t': ticker, 'n': limit},
)
rows = cur.fetchall()
conn.close()
if not rows:
return f"{ticker} 거래 이력 없음"
lines = []
wins = sum(1 for r in rows if r[1])
for r in rows:
ts = r[0].strftime('%m/%d %H:%M') if r[0] else '?'
wl = '' if r[1] else ''
pnl = float(r[2]) if r[2] else 0
reason = r[5] or ''
lines.append(f" {ts} {wl} {pnl:+.2f}% {reason}")
header = f"{ticker} 최근 {len(rows)}건 (승률 {wins}/{len(rows)}={wins/len(rows)*100:.0f}%):"
note = "\n ※ 과거 성과는 참고용입니다. 현재 시그널 강도가 높으면 과거 연패와 무관하게 진입하세요."
return header + "\n" + "\n".join(lines) + note
except Exception as e:
return f"DB 오류: {e}"
def _tool_get_btc_trend() -> str:
"""BTC 최근 동향 (1시간봉 6개 + 일봉)."""
try:
import pyupbit
lines = []
# 일봉 2개
day_df = pyupbit.get_ohlcv('KRW-BTC', interval='day', count=2)
if day_df is not None and len(day_df) >= 2:
today = day_df.iloc[-1]
prev = day_df.iloc[-2]
chg = (today['close'] - prev['close']) / prev['close'] * 100
lines.append(f"[BTC 일봉] 현재 {today['close']:,.0f}원 (전일 대비 {chg:+.2f}%)")
lines.append(f" 고가 {today['high']:,.0f} 저가 {today['low']:,.0f}")
# 1시간봉 6개
h1_df = pyupbit.get_ohlcv('KRW-BTC', interval='minute60', count=6)
if h1_df is not None and not h1_df.empty:
first_c = float(h1_df['close'].iloc[0])
last_c = float(h1_df['close'].iloc[-1])
h_chg = (last_c - first_c) / first_c * 100
trend = '상승' if h_chg > 0.5 else '하락' if h_chg < -0.5 else '횡보'
lines.append(f"[BTC 6시간 추세] {trend} ({h_chg:+.2f}%)")
for ts, row in h1_df.iterrows():
lines.append(
f" {ts.strftime('%H:%M')}{row['close']:>12,.0f} "
f"거래량{row['volume']:,.2f}"
)
return "\n".join(lines) if lines else "BTC 데이터 조회 실패"
except Exception as e:
return f"BTC 조회 오류: {e}"
# ── Tool 정의 (OpenAI function calling 형식) ─────────────────────────────────
_TOOLS = [
{
'type': 'function',
'function': {
'name': 'get_price_ticks',
'description': (
'Oracle DB에서 특정 종목의 최근 N분간 가격 틱 데이터를 조회합니다. '
'단기 가격 흐름과 지지/저항 수준 파악에 사용하세요.'
),
'parameters': {
'type': 'object',
'properties': {
'ticker': {'type': 'string', 'description': '종목 코드 (예: KRW-XRP)'},
'minutes': {'type': 'integer', 'description': '최근 N분 데이터 (기본 10)'},
},
'required': ['ticker'],
},
},
},
{
'type': 'function',
'function': {
'name': 'get_ohlcv',
'description': (
'Oracle DB에서 특정 종목의 1분봉 OHLCV 데이터를 조회합니다. '
'지지선/저항선 파악과 추세 분석에 사용하세요.'
),
'parameters': {
'type': 'object',
'properties': {
'ticker': {'type': 'string', 'description': '종목 코드 (예: KRW-XRP)'},
'limit': {'type': 'integer', 'description': '조회할 봉 수 (기본 30)'},
},
'required': ['ticker'],
},
},
},
{
'type': 'function',
'function': {
'name': 'get_ticker_context',
'description': (
'종목의 평판 정보를 조회합니다. 24h/7d 가격 변동률, 거래량 추이, '
'최근 뉴스 등 중장기 컨텍스트를 제공합니다. '
'매매 판단 시 시장 분위기와 종목 상황을 파악하는 데 사용하세요.'
),
'parameters': {
'type': 'object',
'properties': {
'ticker': {'type': 'string', 'description': '종목 코드 (예: KRW-XRP)'},
},
'required': ['ticker'],
},
},
},
{
'type': 'function',
'function': {
'name': 'get_trade_history',
'description': (
'특정 종목의 최근 거래 이력을 조회합니다. '
'승률, 손익, 매도 사유 등을 확인해 이 종목의 과거 성과를 파악하세요.'
),
'parameters': {
'type': 'object',
'properties': {
'ticker': {'type': 'string', 'description': '종목 코드 (예: KRW-XRP)'},
'limit': {'type': 'integer', 'description': '조회 건수 (기본 10)'},
},
'required': ['ticker'],
},
},
},
{
'type': 'function',
'function': {
'name': 'get_btc_trend',
'description': (
'BTC(비트코인)의 최근 가격 동향을 조회합니다. '
'알트코인 매수 전 BTC 추세를 반드시 확인하세요. '
'BTC 하락 시 알트코인 동반 하락 위험이 높습니다.'
),
'parameters': {
'type': 'object',
'properties': {},
},
},
},
]
def _execute_tool(tool_name: str, tool_input: dict) -> str:
"""LLM이 요청한 tool을 실행하고 결과를 반환."""
if tool_name == 'get_price_ticks':
return _tool_get_price_ticks(
ticker = tool_input['ticker'],
minutes = tool_input.get('minutes', 10),
)
if tool_name == 'get_ohlcv':
return _tool_get_ohlcv(
ticker = tool_input['ticker'],
limit = tool_input.get('limit', 30),
)
if tool_name == 'get_ticker_context':
return _tool_get_context(ticker=tool_input['ticker'])
if tool_name == 'get_trade_history':
return _tool_get_trade_history(
ticker=tool_input['ticker'],
limit=tool_input.get('limit', 10),
)
if tool_name == 'get_btc_trend':
return _tool_get_btc_trend()
return f'알 수 없는 tool: {tool_name}'
# ── 시장 컨텍스트 수집 ───────────────────────────────────────────────────────
def _get_market_context(ticker: str) -> str:
"""오늘 일봉 요약 + 최근 4시간 1시간봉을 LLM 프롬프트용 텍스트로 반환.
pyupbit API 호출 실패 시 빈 문자열 반환 (graceful degradation).
"""
try:
import pyupbit
lines: list[str] = []
# 오늘 일봉 (오늘 + 전일 2개)
day_df = pyupbit.get_ohlcv(ticker, interval='day', count=2)
if day_df is not None and not day_df.empty:
today = day_df.iloc[-1]
prev = day_df.iloc[-2] if len(day_df) > 1 else None
vol_note = ''
if prev is not None and prev['volume'] > 0:
vol_note = f' (전일 대비 {today["volume"] / prev["volume"]:.1f}x)'
lines.append('[오늘 일봉]')
lines.append(f' 고가 {today["high"]:>12,.0f}원 저가 {today["low"]:>12,.0f}')
lines.append(f' 시가 {today["open"]:>12,.0f}원 거래량 {today["volume"]:,.0f}{vol_note}')
# 최근 4시간 1시간봉
h1_df = pyupbit.get_ohlcv(ticker, interval='minute60', count=4)
if h1_df is not None and not h1_df.empty:
lines.append(f'[최근 {len(h1_df)}시간봉]')
for ts, row in h1_df.iterrows():
lines.append(
f' {ts.strftime("%H:%M")} '
f'{row["high"]:>10,.0f}{row["low"]:>10,.0f} '
f'{row["close"]:>10,.0f} 거래량{row["volume"]:,.0f}'
)
return '\n'.join(lines)
except Exception as e:
log.debug(f'[LLM] 시장 컨텍스트 조회 실패: {e}')
return ''
# ── 프롬프트 빌더 ─────────────────────────────────────────────────────────────
def _describe_bars(bar_list: list[dict], current_price: float) -> str:
"""봉 데이터를 LLM이 읽기 쉬운 텍스트로 변환."""
recent = bar_list[-INPUT_BARS:] if len(bar_list) >= INPUT_BARS else bar_list
if not recent:
return '봉 데이터 없음'
lines = []
for b in recent:
ts_str = b['ts'].strftime('%H:%M:%S') if isinstance(b.get('ts'), datetime) else ''
lines.append(
f' {ts_str} 종가{b["close"]:>10,.0f} 고가{b["high"]:>10,.0f}'
f' 저가{b["low"]:>10,.0f}'
)
highs5 = [b['high'] for b in recent[-5:]]
closes5 = [b['close'] for b in recent[-5:]]
period_high = max(b['high'] for b in recent)
from_peak = (current_price - period_high) / period_high * 100
trend_h = '하락▼' if highs5[-1] < highs5[0] else '상승▲' if highs5[-1] > highs5[0] else '횡보─'
trend_c = '하락▼' if closes5[-1] < closes5[0] else '상승▲' if closes5[-1] > closes5[0] else '횡보─'
summary = (
f'\n[패턴 요약]\n'
f'- 고가 추세: {trend_h} ({highs5[0]:,.0f}{highs5[-1]:,.0f})\n'
f'- 종가 추세: {trend_c} ({closes5[0]:,.0f}{closes5[-1]:,.0f})\n'
f'- 구간 최고가: {period_high:,.0f}원 현재가 대비: {from_peak:+.2f}%'
)
return '\n'.join(lines) + summary
def _calc_momentum(bar_list: list[dict], current_price: float, bar_sec: int = 20) -> str:
"""다중 타임프레임 모멘텀 요약. 매도 LLM에 상승 곡선 판단 근거 제공."""
if len(bar_list) < 10:
return ''
lines = []
# 1분(3봉), 3분(9봉), 5분(15봉), 10분(30봉) 전 가격 대비 변화율
intervals = [
('1분', 3), ('3분', 9), ('5분', 15), ('10분', 30),
]
for label, n_bars in intervals:
if len(bar_list) < n_bars + 1:
continue
past_price = bar_list[-(n_bars + 1)]['close']
chg = (current_price - past_price) / past_price * 100
arrow = '' if chg > 0.3 else '' if chg < -0.3 else ''
lines.append(f' {label}전 대비: {chg:+.2f}% {arrow} ({past_price:,.0f}{current_price:,.0f})')
# 최근 15봉 연속 상승/하락 카운트
recent = bar_list[-15:]
up_count = sum(1 for b in recent if b['close'] > b['open'])
dn_count = sum(1 for b in recent if b['close'] < b['open'])
# 최근 15봉 최저가 → 현재가 상승폭
period_low = min(b['low'] for b in recent)
rise_from_low = (current_price - period_low) / period_low * 100
lines.append(f' 최근 15봉: 양봉 {up_count}개 / 음봉 {dn_count}')
lines.append(f' 구간 저점 대비: {rise_from_low:+.2f}% ({period_low:,.0f}{current_price:,.0f})')
if not lines:
return ''
return '[모멘텀 분석]\n' + '\n'.join(lines)
def _build_prompt(
ticker: str,
entry_price: float,
current_price: float,
elapsed_min: float,
current_target: float,
bar_desc: str,
market_context: str = '',
momentum_desc: str = '',
) -> str:
pnl_pct = (current_price - entry_price) / entry_price * 100
target_gap = (current_target - current_price) / current_price * 100
market_section = f'\n{market_context}\n' if market_context else ''
momentum_section = f'\n{momentum_desc}\n' if momentum_desc else ''
return f"""당신은 암호화폐 단기 트레이더입니다.
아래 포지션과 가격 흐름을 분석해 **지정가 매도 목표가**를 판단하세요.
필요하면 제공된 DB tool을 호출해 추가 데이터를 조회하세요.
특히 get_ticker_context로 종목의 24h/7d 가격 변동, 거래량 추이, 최근 뉴스를 확인하세요.
[현재 포지션]
종목 : {ticker}
진입가 : {entry_price:,.0f}
현재가 : {current_price:,.0f}원 ({pnl_pct:+.2f}%)
보유시간: {elapsed_min:.0f}
현재 지정가: {current_target:,.0f}원 (현재가 대비 {target_gap:+.2f}%, 미체결)
{market_section}
[최근 {INPUT_BARS}봉 (20초봉)]
{bar_desc}
{momentum_section}
[운용 정책 참고 — 최종 판단은 당신이 결정]
- 단기 거래량 가속 신호 진입 후 cascade 청산 전략 (지정가 단계적 조정)
- 수익 목표: 진입가 대비 +0.5% ~ +2% 구간
- 손절 기준: 진입가 대비 -2% 이하이면 즉시 시장가 매도를 강력 권고 (action=sell, price=현재가)
- 체결 가능성이 낮으면 현실적인 목표가로 조정 권장
- **모멘텀이 강하면(1분~10분 전 대비 계속 상승 중) 성급하게 팔지 말고 hold하세요**
- **양봉 비율이 높고 구간 저점 대비 상승폭이 크면 추세가 살아있는 것입니다**
- 상승 여력이 있으면 hold 권장
반드시 아래 JSON 형식으로만 응답하세요. 설명이나 다른 텍스트를 절대 포함하지 마세요.
매도 지정가를 설정할 경우:
{{"action": "sell", "price": 숫자, "confidence": "high|medium|low", "reason": "판단 근거 한줄 요약", "market_status": "상승|하락|횡보|급등|급락", "watch_needed": false}}
현재 주문을 유지할 경우:
{{"action": "hold", "reason": "유지 근거 한줄 요약", "market_status": "상승|하락|횡보|급등|급락", "watch_needed": true/false}}
watch_needed: 관망이 필요한 상황이면 true (급변동 예상, 불확실성 높음 등)"""
# ── 공통 LLM 호출 ────────────────────────────────────────────────────────────
def _call_llm(prompt: str, ticker: str) -> Optional[dict]:
"""OpenRouter API를 호출하고 JSON 응답을 반환. 실패 시 None."""
import requests as _req
import re
api_key = os.environ.get('OPENROUTER_API_KEY', '')
if not api_key:
log.debug('[LLM] OPENROUTER_API_KEY 없음')
return None
model = os.environ.get('LLM_MODEL', 'anthropic/claude-haiku-4.5')
headers = {
'Authorization': f'Bearer {api_key}',
'Content-Type': 'application/json',
}
messages = [{'role': 'user', 'content': prompt}]
max_rounds = 5
called_tools: set = set() # 중복 tool 호출 방지
try:
for round_i in range(max_rounds):
body = {
'model': model,
'max_tokens': 512,
'messages': messages,
'response_format': {'type': 'json_object'},
}
# 마지막 라운드 또는 모든 tool 이미 호출 → tool 제거해 강제 텍스트 응답
if round_i < max_rounds - 1 and len(called_tools) < len(_TOOLS):
body['tools'] = _TOOLS
resp = _req.post(
'https://openrouter.ai/api/v1/chat/completions',
headers=headers, json=body, timeout=30,
)
resp.raise_for_status()
result = resp.json()
choice = result['choices'][0]
message = choice['message']
tool_calls = message.get('tool_calls')
if tool_calls:
messages.append(message)
for tc in tool_calls:
fn_name = tc['function']['name']
fn_args = json.loads(tc['function']['arguments'])
call_key = f'{fn_name}:{json.dumps(fn_args, sort_keys=True)}'
if call_key in called_tools:
# 중복 호출 → 캐시된 결과 반환
fn_result = f'(이미 조회한 데이터입니다. 위 결과를 참고하세요.)'
else:
fn_result = _execute_tool(fn_name, fn_args)
called_tools.add(call_key)
log.info(f'[LLM-Tool] {ticker} {fn_name} 호출')
messages.append({
'role': 'tool',
'tool_call_id': tc['id'],
'content': fn_result,
})
continue
raw = (message.get('content') or '').strip()
if not raw:
log.warning(f'[LLM] {ticker} 빈 응답')
return None
# 코드블록 안 JSON 추출
if '```' in raw:
m = re.search(r'```(?:json)?\s*(.*?)\s*```', raw, re.DOTALL)
if m:
raw = m.group(1)
# 텍스트에 섞인 JSON 객체 추출
try:
return json.loads(raw)
except json.JSONDecodeError as e:
m = re.search(r'\{[^{}]*"action"\s*:\s*"[^"]+?"[^{}]*\}', raw, re.DOTALL)
if m:
try:
return json.loads(m.group(0))
except json.JSONDecodeError:
pass
log.warning(f'[LLM] {ticker} JSON 파싱 실패: {e} raw={raw[:200]}')
return None
else:
log.warning(f'[LLM] {ticker} tool 루프 초과')
return None
except Exception as e:
log.warning(f'[LLM] {ticker} 오류: {e}')
return None
# ── 매수 판단 ────────────────────────────────────────────────────────────────
def get_entry_price(
ticker: str,
signal: dict,
bar_list: list[dict],
current_price: float,
fng: int = 50,
num_positions: int = 0,
max_positions: int = 3,
) -> Optional[float]:
"""LLM에게 매수 여부 + 지정가를 물어본다.
Args:
ticker: 종목 코드
signal: 시그널 정보 {vol_ratio, prices, ...}
bar_list: 최근 20초봉 리스트
current_price: 현재 가격
fng: 현재 F&G 지수
num_positions: 현재 보유 포지션 수
max_positions: 최대 포지션 수
Returns:
float → 지정가 매수 가격
None → 매수하지 않음
"""
bar_desc = _describe_bars(bar_list, current_price)
mkt_ctx = _get_market_context(ticker)
vol_ratio = signal.get('vol_ratio', 0)
market_section = f'\n{mkt_ctx}\n' if mkt_ctx else ''
prompt = f"""당신은 암호화폐 단기 트레이더입니다.
아래 시그널을 분석해 **매수 여부**를 판단하세요. (매수 가격은 현재가로 자동 설정됩니다)
반드시 제공된 DB tool을 호출해 추가 데이터를 조회하세요:
- get_btc_trend: BTC 추세 확인 (필수 — BTC 급락 시 알트 매수 위험)
- get_ticker_context: 종목 24h/7d 변동, 뉴스 확인
- get_ohlcv: 1분봉으로 현재 추세 확인
[시그널 감지]
종목 : {ticker}
현재가 : {current_price:,.0f}
거래량비: {vol_ratio:.1f}x (61봉 평균 대비)
F&G지수: {fng} ({'공포' if fng <= 40 else '중립' if fng <= 50 else '탐욕'})
포지션 : {num_positions}/{max_positions}
{market_section}
[최근 {INPUT_BARS}봉 (20초봉)]
{bar_desc}
[판단 기준]
- 거래량 급증 시그널이 왔으면 빠르게 올라타는 것이 핵심
- BTC가 급락 중(-2% 이상)이면 자제하되, 횡보/소폭하락은 진입 OK
- 상승 추세가 이미 많이 진행됐으면(+5% 이상) 진입 자제
- **과거 연패/승률은 절대 고려하지 마세요. 현재 시그널만 보고 판단하세요.**
- **get_trade_history를 호출하지 마세요. 과거 거래 이력은 판단에 불필요합니다.**
반드시 아래 JSON 형식으로만 응답하세요. 설명이나 다른 텍스트를 절대 포함하지 마세요.
매수할 경우:
{{"action": "buy", "confidence": "high|medium|low", "reason": "판단 근거 한줄 요약", "market_status": "상승|하락|횡보|급등|급락"}}
매수하지 않을 경우:
{{"action": "skip", "reason": "매수하지 않는 이유 한줄 요약", "market_status": "상승|하락|횡보|급등|급락"}}"""
data = _call_llm(prompt, ticker)
if data is None:
log.info(f'[LLM매수] {ticker} → LLM 오류/무응답')
return None
reason = data.get('reason', '')
status = data.get('market_status', '')
confidence = data.get('confidence', '?')
if data.get('action') == 'skip':
log.info(f'[LLM매수] {ticker} → skip | {confidence} | {status} | {reason}')
return data # action=skip
if data.get('action') == 'buy':
data['price'] = float(data['price'])
log.info(f'[LLM매수] {ticker} → buy {data["price"]:,.2f}원 | {confidence} | {status} | {reason}')
return data # action=buy, price=float
log.warning(f'[LLM매수] {ticker} 알 수 없는 action: {data}')
return None
# ── 매도 판단 ────────────────────────────────────────────────────────────────
def get_exit_price(
ticker: str,
pos: dict,
bar_list: list[dict],
current_price: float,
) -> Optional[float]:
"""LLM에게 매도 목표가를 물어본다. (DB tool 사용 가능)
Args:
ticker: 종목 코드 (예: 'KRW-XRP')
pos: positions[ticker] 딕셔너리
bar_list: list(bars[ticker]) — 최신봉이 마지막
current_price: 현재 틱 가격
Returns:
float → 새 지정가 (현재 주문가와 MIN_CHANGE_R 이상 차이)
None → hold 또는 오류
"""
entry_price = pos['entry_price']
elapsed_min = (datetime.now() - pos['entry_ts']).total_seconds() / 60
current_target = pos.get('sell_price') or entry_price * 1.005
bar_desc = _describe_bars(bar_list, current_price)
mkt_ctx = _get_market_context(ticker)
momentum_desc = _calc_momentum(bar_list, current_price)
prompt = _build_prompt(
ticker, entry_price, current_price,
elapsed_min, current_target, bar_desc,
market_context=mkt_ctx,
momentum_desc=momentum_desc,
)
data = _call_llm(prompt, ticker)
if data is None:
log.info(f'[LLM매도] {ticker} → LLM 오류/무응답 → cascade fallback')
return None
reason = data.get('reason', '')
status = data.get('market_status', '')
confidence = data.get('confidence', '?')
watch = data.get('watch_needed', False)
if data.get('action') == 'hold':
log.info(f'[LLM매도] {ticker} → hold | {confidence} | {status} | watch={watch} | {reason}')
return data # action=hold
data['price'] = float(data['price'])
pnl_from_entry = (data['price'] - entry_price) / entry_price * 100
log.info(
f'[LLM매도] {ticker} 지정가 교체: {current_target:,.2f}{data["price"]:,.2f}'
f'(진입 대비 {pnl_from_entry:+.2f}%) | {confidence} | {status} | watch={watch} | {reason}'
)
return data # action=sell, price=float

View File

@@ -1,107 +0,0 @@
"""트레일링 스탑 + 타임 스탑 감시 - 백그라운드 스레드에서 실행."""
import logging
import os
import time
from datetime import datetime
from .market import get_current_price
from . import trader
logger = logging.getLogger(__name__)
STOP_LOSS_PCT = float(os.getenv("STOP_LOSS_PCT", "5")) / 100 # 최고가 대비 -N% → 트레일링 스탑
CHECK_INTERVAL = 10 # 10초마다 체크
# 타임 스탑: N시간 경과 후 수익률이 M% 미만이면 청산
TIME_STOP_HOURS = float(os.getenv("TIME_STOP_HOURS", "24"))
TIME_STOP_MIN_GAIN_PCT = float(os.getenv("TIME_STOP_MIN_GAIN_PCT", "3"))
def _check_trailing_stop(ticker: str, pos: dict, current: float) -> bool:
"""트레일링 스탑 체크. 매도 시 True 반환."""
trader.update_peak(ticker, current)
pos = trader.get_positions().get(ticker)
if pos is None:
return False
peak = pos["peak_price"]
drop_from_peak = (peak - current) / peak
if drop_from_peak >= STOP_LOSS_PCT:
reason = (
f"트레일링스탑 | 최고가={peak:,.0f}원 → "
f"현재={current:,.0f}원 ({drop_from_peak:.1%} 하락)"
)
trader.sell(ticker, reason=reason)
return True
return False
def _check_time_stop(ticker: str, pos: dict, current: float) -> bool:
"""타임 스탑 체크. 매도 시 True 반환.
조건: 보유 후 TIME_STOP_HOURS 경과 AND 수익률 < TIME_STOP_MIN_GAIN_PCT%
"""
entry_time = pos.get("entry_time")
if entry_time is None:
return False
elapsed_hours = (datetime.now() - entry_time).total_seconds() / 3600
if elapsed_hours < TIME_STOP_HOURS:
return False
pnl_pct = (current - pos["buy_price"]) / pos["buy_price"] * 100
if pnl_pct >= TIME_STOP_MIN_GAIN_PCT:
return False
reason = (
f"타임스탑 | {elapsed_hours:.1f}시간 경과 후 "
f"수익률={pnl_pct:+.1f}% (기준={TIME_STOP_MIN_GAIN_PCT:+.0f}% 미달)"
)
trader.sell(ticker, reason=reason)
return True
def _check_position(ticker: str, pos: dict) -> None:
"""단일 포지션 전체 체크 (트레일링 스탑 → 타임 스탑 순서)."""
current = get_current_price(ticker)
if current is None:
return
pnl = (current - pos["buy_price"]) / pos["buy_price"] * 100
peak = pos["peak_price"]
drop_from_peak = (peak - current) / peak
entry_time = pos.get("entry_time", datetime.now())
elapsed_hours = (datetime.now() - entry_time).total_seconds() / 3600
logger.info(
f"[감시] {ticker} 현재={current:,.0f} | 최고={peak:,.0f} | "
f"하락={drop_from_peak:.1%} | 수익률={pnl:+.1f}% | "
f"보유={elapsed_hours:.1f}h"
)
# 1순위: 트레일링 스탑
if _check_trailing_stop(ticker, pos, current):
return
# 2순위: 타임 스탑
_check_time_stop(ticker, pos, current)
def run_monitor(interval: int = CHECK_INTERVAL) -> None:
"""전체 포지션 감시 루프."""
logger.info(
f"모니터 시작 | 체크={interval}초 | "
f"트레일링스탑={STOP_LOSS_PCT:.0%} | "
f"타임스탑={TIME_STOP_HOURS:.0f}h/{TIME_STOP_MIN_GAIN_PCT:+.0f}%"
)
while True:
positions_snapshot = dict(trader.get_positions())
for ticker, pos in positions_snapshot.items():
try:
_check_position(ticker, pos)
except Exception as e:
logger.error(f"모니터 오류 {ticker}: {e}")
time.sleep(interval)

View File

@@ -28,42 +28,157 @@ def _send(text: str) -> None:
logger.error(f"Telegram 알림 실패: {e}")
def notify_buy(ticker: str, price: float, amount: float, invested_krw: int) -> None:
def notify_buy(
ticker: str, price: float, amount: float, invested_krw: int,
max_budget: int = 0, per_position: int = 0,
fng: int = 0,
) -> None:
budget_line = (
f"운용예산: {max_budget:,}원 (포지션당 {per_position:,}원)\n"
if max_budget else ""
)
fng_label = (
"극탐욕" if fng >= 76 else
"탐욕" if fng >= 56 else
"중립" if fng >= 46 else
"약공포" if fng >= 41 else
"공포" if fng >= 26 else
"극공포"
) if fng else ""
fng_line = f"F&amp;G: {fng} ({fng_label})\n" if fng else ""
_send(
f"📈 <b>[매수]</b> {ticker}\n"
f"가격: {price:,.0f}\n"
f"수량: {amount}\n"
f"투자금: {invested_krw:,}"
f"가격: {price:,.2f}\n"
f"수량: {amount:.8f}\n"
f"투자금: {invested_krw:,.2f}\n"
f"{fng_line}"
f"{budget_line}"
)
def notify_sell(ticker: str, price: float, pnl_pct: float, reason: str) -> None:
emoji = "" if pnl_pct >= 0 else "🔴"
def notify_sell(
ticker: str, price: float, pnl_pct: float, reason: str,
krw_profit: float = 0.0, fee_krw: float = 0.0,
cum_profit: float = 0.0,
) -> None:
trade_emoji = "" if pnl_pct >= 0 else ""
cum_emoji = "💚" if cum_profit >= 0 else "🔴"
_send(
f"{emoji} <b>[매도]</b> {ticker}\n"
f"가격: {price:,.0f}\n"
f"수익률: {pnl_pct:+.1f}%\n"
f"{trade_emoji} <b>[매도]</b> {ticker}\n"
f"가격: {price:,.2f}\n"
f"수익률: {pnl_pct:+.2f}%\n"
f"실손익: {krw_profit:+,.2f}원 (수수료 {fee_krw:,.2f}원)\n"
f"{cum_emoji} 누적손익: {cum_profit:+,.2f}\n"
f"사유: {reason}"
)
def notify_signal(ticker: str, signal_price: float, vol_mult: float, fng: int = 0) -> None:
"""거래량 축적 신호 감지 알림."""
fng_label = (
"극탐욕" if fng >= 76 else
"탐욕" if fng >= 56 else
"중립" if fng >= 46 else
"약공포" if fng >= 41 else
"공포" if fng >= 26 else
"극공포"
) if fng else ""
fng_line = f"F&amp;G: {fng} ({fng_label})\n" if fng else ""
from .strategy import TREND_AFTER_VOL
target = signal_price * (1 + TREND_AFTER_VOL / 100)
_send(
f"🔍 <b>[축적감지]</b> {ticker}\n"
f"신호가: {signal_price:,.2f}\n"
f"거래량: {vol_mult:.1f}x 급증 + 횡보\n"
f"{fng_line}"
f"진입 목표: {target:,.2f}원 (+{TREND_AFTER_VOL}%)"
)
def notify_watch(
ticker: str, price: float, vol_r: float, vth: float, chg: float, fng: int = 0
) -> None:
"""거래량 근접 관찰 알림 (신호 임계값에 가까워진 종목)."""
fng_label = (
"극탐욕" if fng >= 76 else
"탐욕" if fng >= 56 else
"중립" if fng >= 46 else
"약공포" if fng >= 41 else
"공포" if fng >= 26 else
"극공포"
) if fng else ""
fng_line = f"F&amp;G: {fng} ({fng_label})\n" if fng else ""
need = vth - vol_r
_send(
f"👀 <b>[관찰]</b> {ticker}\n"
f"거래량: {vol_r:.1f}x (신호까지 +{need:.1f}x 부족)\n"
f"현재가: {price:,.2f}원 | 횡보: {chg:.1f}%\n"
f"{fng_line}"
)
def notify_error(message: str) -> None:
_send(f"⚠️ <b>[오류]</b>\n{message}")
def notify_status(positions: dict) -> None:
"""1시간마다 포지션 현황 요약 전송."""
def notify_status(
positions: dict,
max_budget: int = 0,
per_position: int = 0,
cum_profit: float = 0.0,
) -> None:
"""정각마다 시장 레짐 + 1시간 이상 보유 포지션 현황 전송."""
from datetime import datetime
import pyupbit
from .market_regime import get_regime
now = datetime.now().strftime("%H:%M")
cum_sign = "+" if cum_profit >= 0 else ""
if not positions:
_send(f"📊 <b>[{now} 현황]</b>\n보유 포지션 없음 — 매수 신호 대기 중")
# 시장 레짐
regime = get_regime()
regime_line = (
f"{regime['emoji']} 시장: {regime['name'].upper()} "
f"(score {regime['score']:+.2f}%) "
f"| 조건 TREND≥{regime['trend_pct']}% / VOL≥{regime['vol_mult']}x\n"
)
# F&G 지수
from .fng import get_fng, FNG_MIN_ENTRY
fv = get_fng()
fng_label = (
"극탐욕" if fv >= 76 else
"탐욕" if fv >= 56 else
"중립" if fv >= 46 else
"약공포" if fv >= 41 else
"공포" if fv >= 26 else
"극공포"
)
fng_status = "✅진입허용" if fv >= FNG_MIN_ENTRY else "🚫진입차단"
fng_line = f"😨 F&amp;G: {fv} ({fng_label}) {fng_status}\n"
# 1시간 이상 보유 포지션만 필터
long_positions = {
ticker: pos for ticker, pos in positions.items()
if (datetime.now() - pos["entry_time"]).total_seconds() >= 3600
}
cum_emoji = "💚" if cum_profit >= 0 else "🔴"
budget_info = (
f"💰 운용예산: {max_budget:,}원 | 포지션당: {per_position:,}\n"
f"{cum_emoji} 누적손익: {cum_sign}{cum_profit:,.0f}\n"
if max_budget else ""
)
# 포지션 없어도 레짐 정보는 전송
header = f"📊 <b>[{now} 현황]</b>\n{regime_line}{fng_line}{budget_info}"
if not long_positions:
_send(header + "1h+ 보유 포지션 없음")
return
lines = [f"📊 <b>[{now} 현황]</b>"]
for ticker, pos in positions.items():
lines = [header]
for ticker, pos in long_positions.items():
current = pyupbit.get_current_price(ticker)
if not current:
continue
@@ -73,10 +188,10 @@ def notify_status(positions: dict) -> None:
elapsed = (datetime.now() - pos["entry_time"]).total_seconds() / 3600
emoji = "📈" if pnl >= 0 else "📉"
lines.append(
f"\n{emoji} <b>{ticker}</b>\n"
f" 현재가: {current:,.0f}\n"
f" 수익률: {pnl:+.1f}%\n"
f" 최고가 대비: -{drop:.1f}%\n"
f" 보유: {elapsed:.1f}h"
f"{emoji} <b>{ticker}</b>\n"
f" 현재가: {current:,.2f}\n"
f" 수익률: {pnl:+.2f}%\n"
f" 최고가 대비: -{drop:.2f}%\n"
f" 보유: {elapsed:.2f}h"
)
_send("\n".join(lines))

178
core/order.py Normal file
View File

@@ -0,0 +1,178 @@
"""Upbit 주문 실행 모듈.
주문 제출, 취소, 체결 조회, 시장가 매도 등
Upbit REST API와 직접 통신하는 로직을 담당한다.
"""
from __future__ import annotations
import logging
import math
import time
from typing import Optional, Tuple
import pyupbit
log = logging.getLogger(__name__)
def round_price(price: float) -> float:
"""Upbit 호가 단위로 내림 처리.
Args:
price: 원본 가격.
Returns:
호가 단위에 맞춰 내림된 가격.
"""
if price >= 2_000_000: unit = 1000
elif price >= 1_000_000: unit = 500
elif price >= 100_000: unit = 100
elif price >= 10_000: unit = 10
elif price >= 1_000: unit = 5
elif price >= 100: unit = 1
elif price >= 10: unit = 0.1
else: unit = 0.01
return math.floor(price / unit) * unit
def submit_limit_buy(
client: pyupbit.Upbit,
ticker: str,
price: float,
qty: float,
sim_mode: bool = False,
) -> Optional[str]:
"""지정가 매수 주문 제출.
Returns:
주문 UUID. 실패 시 None.
"""
price = round_price(price)
if sim_mode:
return f"sim-buy-{ticker}"
try:
order = client.buy_limit_order(ticker, price, qty)
if not order or 'error' in str(order):
log.error(f"지정가 매수 실패 {ticker}: {order}")
return None
return order.get('uuid')
except (ConnectionError, TimeoutError, ValueError) as e:
log.error(f"지정가 매수 오류 {ticker}: {e}")
return None
def submit_limit_sell(
client: pyupbit.Upbit,
ticker: str,
qty: float,
price: float,
sim_mode: bool = False,
) -> Optional[str]:
"""지정가 매도 주문 제출.
Returns:
주문 UUID. 실패 시 None.
"""
price = round_price(price)
if sim_mode:
return f"sim-{ticker}"
try:
order = client.sell_limit_order(ticker, price, qty)
if not order or 'error' in str(order):
log.error(f"지정가 매도 실패 {ticker}: price={price} qty={qty} -> {order}")
return None
return order.get('uuid')
except (ConnectionError, TimeoutError, ValueError) as e:
log.error(f"지정가 매도 오류 {ticker}: {e}")
return None
def cancel_order(
client: pyupbit.Upbit,
uuid: Optional[str],
sim_mode: bool = False,
) -> None:
"""주문 취소. sim_mode이거나 uuid가 없으면 무시."""
if sim_mode or not uuid or uuid.startswith('sim-'):
return
try:
client.cancel_order(uuid)
except (ConnectionError, TimeoutError, ValueError) as e:
log.warning(f"주문 취소 실패 {uuid}: {e}")
def check_order_state(
client: pyupbit.Upbit,
uuid: str,
) -> Tuple[Optional[str], Optional[float]]:
"""주문 상태 조회.
Returns:
(state, avg_price) 튜플. state: 'done'|'wait'|'cancel'|None.
"""
try:
detail = client.get_order(uuid)
if not detail:
return None, None
state = detail.get('state')
avg_price = float(detail.get('avg_price') or 0) or None
return state, avg_price
except (ConnectionError, TimeoutError, ValueError) as e:
log.warning(f"주문 조회 실패 {uuid}: {e}")
return None, None
def _avg_price_from_order(
client: pyupbit.Upbit,
uuid: str,
) -> Optional[float]:
"""체결 내역에서 가중평균 체결가를 계산."""
try:
detail = client.get_order(uuid)
if not detail:
return None
trades = detail.get('trades', [])
if trades:
total_funds = sum(float(t['funds']) for t in trades)
total_vol = sum(float(t['volume']) for t in trades)
return total_funds / total_vol if total_vol > 0 else None
avg = detail.get('avg_price')
return float(avg) if avg else None
except (ConnectionError, TimeoutError, ValueError) as e:
log.warning(f"체결가 조회 실패 {uuid}: {e}")
return None
def sell_market(
client: pyupbit.Upbit,
ticker: str,
qty: float,
sim_mode: bool = False,
) -> Optional[float]:
"""시장가 매도. 체결가를 반환.
Args:
client: Upbit 클라이언트.
ticker: 종목 코드.
qty: 매도 수량.
sim_mode: 시뮬레이션 모드.
Returns:
체결 평균가. 실패 시 None.
"""
if sim_mode:
price = pyupbit.get_current_price(ticker)
log.info(f"[SIM 시장가매도] {ticker} {qty:.6f}개 @ {price:,.0f}")
return price
try:
order = client.sell_market_order(ticker, qty)
if not order or 'error' in str(order):
log.error(f"시장가 매도 실패: {order}")
return None
uuid = order.get('uuid')
time.sleep(1.5)
avg_price = _avg_price_from_order(client, uuid) if uuid else None
return avg_price or pyupbit.get_current_price(ticker)
except (ConnectionError, TimeoutError, ValueError) as e:
log.error(f"시장가 매도 오류 {ticker}: {e}")
return None

264
core/position_manager.py Normal file
View File

@@ -0,0 +1,264 @@
"""포지션 + 미체결 매수 관리 모듈.
포지션 활성화, 트레일링 스탑/손절/타임아웃 체크,
미체결 매수 체결 확인, 예산 계산 등을 담당한다.
"""
from __future__ import annotations
import logging
import time
from datetime import datetime
from typing import Optional
import oracledb
import os
log = logging.getLogger(__name__)
# ── DB 연결 (position_sync) ──────────────────────────────────────────────────
_db_conn: Optional[oracledb.Connection] = None
def _get_db() -> oracledb.Connection:
"""Oracle ADB 연결을 반환. 끊어졌으면 재연결."""
global _db_conn
if _db_conn is None:
kwargs = dict(
user=os.environ["ORACLE_USER"],
password=os.environ["ORACLE_PASSWORD"],
dsn=os.environ["ORACLE_DSN"],
)
if w := os.environ.get("ORACLE_WALLET"):
kwargs["config_dir"] = w
_db_conn = oracledb.connect(**kwargs)
return _db_conn
def sync_position(
ticker: str,
state: str,
*,
buy_price: Optional[float] = None,
sell_price: Optional[float] = None,
qty: Optional[float] = None,
order_uuid: Optional[str] = None,
invested_krw: Optional[int] = None,
) -> None:
"""position_sync 테이블에 포지션 상태를 기록/삭제.
Args:
ticker: 종목 코드.
state: 'PENDING_BUY' | 'PENDING_SELL' | 'IDLE'.
"""
try:
conn = _get_db()
cur = conn.cursor()
if state == 'IDLE':
cur.execute("DELETE FROM position_sync WHERE ticker = :1", [ticker])
else:
now = datetime.now()
cur.execute(
"""MERGE INTO position_sync ps
USING (SELECT :1 AS ticker FROM dual) src
ON (ps.ticker = src.ticker)
WHEN MATCHED THEN UPDATE SET
state = :2, buy_price = :3, sell_price = :4,
qty = :5, order_uuid = :6, invested_krw = :7, updated_at = :8
WHEN NOT MATCHED THEN INSERT
(ticker, state, buy_price, sell_price, qty, order_uuid, invested_krw, updated_at)
VALUES (:9, :10, :11, :12, :13, :14, :15, :16)""",
[ticker, state, buy_price, sell_price, qty, order_uuid, invested_krw, now,
ticker, state, buy_price, sell_price, qty, order_uuid, invested_krw, now],
)
conn.commit()
except oracledb.Error as e:
log.warning(f"[sync_position] {ticker} {state} 실패: {e}")
global _db_conn
_db_conn = None
def calc_remaining_budget(
positions: dict,
pending_buys: dict,
max_budget: int,
) -> float:
"""남은 투자 가능 금액을 계산.
Args:
positions: 현재 포지션 dict.
pending_buys: 미체결 매수 dict.
max_budget: 총 예산.
Returns:
남은 투자 가능 금액 (원).
"""
invested = sum(p['entry_price'] * p['qty'] for p in positions.values())
invested += sum(p['price'] * p['qty'] for p in pending_buys.values())
return max_budget - invested
def check_exit_conditions(
pos: dict,
current_price: float,
*,
trail_pct: float = 0.015,
min_profit_pct: float = 0.005,
stop_loss_pct: float = 0.02,
timeout_secs: float = 14400,
) -> Optional[str]:
"""포지션 청산 조건을 체크.
Args:
pos: 포지션 dict (entry_price, entry_ts, running_peak).
current_price: 현재 가격.
Returns:
청산 사유 ('stoploss' | 'trail' | 'timeout') 또는 None.
"""
entry = pos['entry_price']
profit_pct = (current_price - entry) / entry
elapsed = (datetime.now() - pos['entry_ts']).total_seconds()
# 1. 손절
if profit_pct <= -stop_loss_pct:
return 'stoploss'
# 2. 트레일링 스탑
peak = pos['running_peak']
if peak > 0:
drop = (peak - current_price) / peak
if profit_pct >= min_profit_pct and drop >= trail_pct:
return 'trail'
# 3. 타임아웃
if elapsed >= timeout_secs:
return 'timeout'
return None
def restore_from_upbit(
client,
tickers: list[str],
positions: dict,
pending_buys: dict,
*,
cancel_fn,
fp_fn,
tg_fn,
) -> None:
"""Upbit 잔고에서 포지션과 미체결 매수를 복구.
Args:
client: pyupbit.Upbit 인스턴스.
tickers: 감시 종목 리스트.
positions: 포지션 dict (in-place 수정).
pending_buys: 미체결 매수 dict (in-place 수정).
cancel_fn: 주문 취소 함수.
fp_fn: 가격 포맷 함수.
tg_fn: 텔레그램 알림 함수.
"""
_restore_positions(client, tickers, positions, cancel_fn, fp_fn, tg_fn)
_restore_pending_buys(client, tickers, positions, pending_buys, fp_fn)
_sync_restored(positions, pending_buys)
def _restore_positions(
client, tickers: list[str], positions: dict,
cancel_fn, fp_fn, tg_fn,
) -> None:
"""잔고에서 보유 포지션을 복구."""
balances = client.get_balances()
log.info(f"[복구] 잔고 조회: {len(balances)}")
for b in balances:
currency = b.get('currency', '')
bal = float(b.get('balance', 0))
locked = float(b.get('locked', 0))
avg = float(b.get('avg_buy_price', 0))
total = bal + locked
if currency == 'KRW' or total <= 0 or avg <= 0:
continue
ticker = f'KRW-{currency}'
if ticker not in tickers or ticker in positions:
if ticker not in tickers:
log.info(f"[복구] {ticker} TICKERS 외 -> 스킵")
continue
log.info(f"[복구] {ticker} bal={bal:.6f} locked={locked:.6f} avg={fp_fn(avg)}")
# 기존 미체결 매도 주문 취소
try:
old_orders = client.get_order(ticker, state='wait') or []
for o in (old_orders if isinstance(old_orders, list) else []):
if o.get('side') == 'ask':
cancel_fn(o.get('uuid'))
log.info(f"[복구] {ticker} 기존 매도 주문 취소: {o.get('uuid')}")
except (ConnectionError, TimeoutError, ValueError) as e:
log.warning(f"[복구] {ticker} 주문 조회/취소 실패: {e}")
time.sleep(0.5)
actual_bal = client.get_balance(currency)
if not actual_bal or actual_bal <= 0:
actual_bal = total
log.warning(f"[복구] {ticker} get_balance 실패, total={total:.6f} 사용")
positions[ticker] = {
'entry_price': avg,
'entry_ts': datetime.now(),
'running_peak': avg,
'qty': actual_bal,
}
log.info(f"[복구] {ticker} 수량:{actual_bal:.6f} 매수평균:{fp_fn(avg)}원 트레일링")
tg_fn(f"♻️ <b>포지션 복구</b> {ticker}\n매수평균: {fp_fn(avg)}원 수량: {actual_bal:.6f}")
def _restore_pending_buys(
client, tickers: list[str], positions: dict,
pending_buys: dict, fp_fn,
) -> None:
"""미체결 매수 주문을 복구."""
for ticker in tickers:
if ticker in positions or ticker in pending_buys:
continue
try:
orders = client.get_order(ticker, state='wait') or []
for o in (orders if isinstance(orders, list) else []):
if o.get('side') == 'bid':
price = float(o.get('price', 0))
rem = float(o.get('remaining_volume', 0))
if price > 0 and rem > 0:
pending_buys[ticker] = {
'uuid': o.get('uuid'),
'price': price,
'qty': rem,
'ts': datetime.now(),
'vol_ratio': 0,
}
log.info(f"[복구] {ticker} 미체결 매수 복구: {fp_fn(price)}원 수량:{rem:.6f}")
break
except (ConnectionError, TimeoutError, ValueError):
log.warning(f"[복구] {ticker} 미체결 매수 조회 실패")
def _sync_restored(positions: dict, pending_buys: dict) -> None:
"""복구된 포지션을 position_sync DB에 반영."""
restored = len(positions) + len(pending_buys)
if restored:
log.info(f"[복구] 총 {len(positions)}개 포지션 + {len(pending_buys)}개 미체결 매수 복구됨")
for ticker, pos in positions.items():
sync_position(
ticker, 'PENDING_SELL',
buy_price=pos['entry_price'],
qty=pos['qty'],
invested_krw=int(pos['qty'] * pos['entry_price']),
)
for ticker, pb in pending_buys.items():
sync_position(
ticker, 'PENDING_BUY',
buy_price=pb['price'],
qty=pb['qty'],
order_uuid=pb.get('uuid'),
invested_krw=int(pb['qty'] * pb['price']),
)

View File

@@ -1,52 +0,0 @@
"""10분마다 상위 종목 현재가를 Oracle DB에 저장하는 수집기."""
from __future__ import annotations
import logging
import time
import requests
from .market import get_top_tickers
from .price_db import cleanup_old_prices, insert_prices
logger = logging.getLogger(__name__)
COLLECT_INTERVAL = 600 # 10분 (초)
CLEANUP_EVERY = 6 # 1시간(10분 × 6)마다 오래된 데이터 정리
def run_collector(interval: int = COLLECT_INTERVAL) -> None:
"""가격 수집 루프."""
logger.info(f"가격 수집기 시작 (주기={interval//60}분)")
time.sleep(30) # 스캐너와 동시 API 호출 방지
cycle = 0
while True:
try:
tickers = get_top_tickers()
if not tickers:
continue
resp = requests.get(
"https://api.upbit.com/v1/ticker",
params={"markets": ",".join(tickers)},
timeout=5,
)
resp.raise_for_status()
data = resp.json()
valid = {
item["market"]: item["trade_price"]
for item in data
if item.get("trade_price")
}
insert_prices(valid)
logger.info(f"[수집] {len(valid)}개 종목 가격 저장")
cycle += 1
if cycle % CLEANUP_EVERY == 0:
cleanup_old_prices(keep_hours=48)
logger.info("오래된 가격 데이터 정리 완료")
except Exception as e:
logger.error(f"가격 수집 오류: {e}")
time.sleep(interval)

View File

@@ -1,91 +0,0 @@
"""Oracle ADB price_history CRUD."""
from __future__ import annotations
import os
from contextlib import contextmanager
from typing import Generator, Optional
import oracledb
_pool: Optional[oracledb.ConnectionPool] = None
def _get_pool() -> oracledb.ConnectionPool:
global _pool
if _pool is None:
kwargs: dict = dict(
user=os.environ["ORACLE_USER"],
password=os.environ["ORACLE_PASSWORD"],
dsn=os.environ["ORACLE_DSN"],
min=1,
max=3,
increment=1,
)
wallet = os.environ.get("ORACLE_WALLET")
if wallet:
kwargs["config_dir"] = wallet
_pool = oracledb.create_pool(**kwargs)
return _pool
@contextmanager
def _conn() -> Generator[oracledb.Connection, None, None]:
pool = _get_pool()
conn = pool.acquire()
try:
yield conn
conn.commit()
except Exception:
conn.rollback()
raise
finally:
pool.release(conn)
def insert_prices(ticker_prices: dict[str, float]) -> None:
"""여러 종목의 현재가를 한 번에 저장."""
if not ticker_prices:
return
rows = [(ticker, price) for ticker, price in ticker_prices.items()]
sql = "INSERT INTO price_history (ticker, price) VALUES (:1, :2)"
with _conn() as conn:
conn.cursor().executemany(sql, rows)
def get_price_n_hours_ago(ticker: str, hours: float) -> Optional[float]:
"""N시간 전 가장 가까운 가격 반환. 데이터 없으면 None."""
sql = """
SELECT price FROM price_history
WHERE ticker = :ticker
AND recorded_at BETWEEN
SYSTIMESTAMP - INTERVAL ':h' HOUR - INTERVAL '10' MINUTE
AND SYSTIMESTAMP - INTERVAL ':h' HOUR + INTERVAL '10' MINUTE
ORDER BY ABS(CAST(recorded_at AS DATE) -
CAST(SYSTIMESTAMP - INTERVAL ':h' HOUR AS DATE))
FETCH FIRST 1 ROWS ONLY
"""
# Oracle INTERVAL bind param 미지원으로 직접 포맷
h = int(hours)
sql = f"""
SELECT price FROM price_history
WHERE ticker = :ticker
AND recorded_at BETWEEN
SYSTIMESTAMP - ({h}/24) - (10/1440)
AND SYSTIMESTAMP - ({h}/24) + (10/1440)
ORDER BY ABS(CAST(recorded_at AS DATE) -
CAST(SYSTIMESTAMP - ({h}/24) AS DATE))
FETCH FIRST 1 ROWS ONLY
"""
with _conn() as conn:
cursor = conn.cursor()
cursor.execute(sql, {"ticker": ticker})
row = cursor.fetchone()
return float(row[0]) if row else None
def cleanup_old_prices(keep_hours: int = 48) -> None:
"""N시간 이상 오래된 데이터 삭제 (DB 용량 관리)."""
sql = f"DELETE FROM price_history WHERE recorded_at < SYSTIMESTAMP - ({keep_hours}/24)"
with _conn() as conn:
conn.cursor().execute(sql)

139
core/signal.py Normal file
View File

@@ -0,0 +1,139 @@
"""시그널 감지 + 지표 계산 모듈.
20초봉 데이터에서 양봉 + 거래량 + 사전 필터를 적용하여
매수 시그널 후보를 반환한다.
"""
from __future__ import annotations
import logging
from typing import Optional
log = logging.getLogger(__name__)
def calc_vr(bar_list: list[dict], idx: int, lookback: int = 61) -> float:
"""거래량비(Volume Ratio) 계산. 상위 10% 트리밍.
Args:
bar_list: 봉 리스트.
idx: 현재 봉 인덱스.
lookback: 기준 봉 수.
Returns:
현재 봉 거래량 / trimmed mean 비율.
"""
start = max(0, idx - lookback)
end = max(0, idx - 2)
baseline = sorted(bar_list[i]['volume'] for i in range(start, end))
if not baseline:
return 0.0
trim = max(1, len(baseline) // 10)
trimmed = baseline[:len(baseline) - trim]
if not trimmed:
return 0.0
avg = sum(trimmed) / len(trimmed)
return bar_list[idx]['volume'] / avg if avg > 0 else 0.0
def calc_atr(bar_list: list[dict], lookback: int = 28) -> float:
"""ATR(Average True Range) 비율 계산.
Args:
bar_list: 봉 리스트.
lookback: ATR 계산 봉 수.
Returns:
ATR / 직전 종가 비율 (0~1 범위).
"""
if len(bar_list) < lookback + 2:
return 0.0
trs = []
for i in range(-lookback - 1, -1):
b = bar_list[i]
bp = bar_list[i - 1]
tr = max(
b['high'] - b['low'],
abs(b['high'] - bp['close']),
abs(b['low'] - bp['close']),
)
trs.append(tr)
prev_close = bar_list[-2]['close']
return (sum(trs) / len(trs)) / prev_close if prev_close > 0 else 0.0
def detect_signal(
ticker: str,
bar_list: list[dict],
*,
vol_min: float = 5.0,
vol_lookback: int = 61,
vol_krw_min: float = 5_000_000,
spread_min: float = 0.3,
) -> Optional[dict]:
"""양봉 + 거래량 + 사전 필터 3종을 적용하여 시그널 후보를 반환.
Args:
ticker: 종목 코드.
bar_list: 봉 리스트 (list로 변환된 deque).
vol_min: 최소 거래량 배수.
vol_lookback: 거래량 평균 기준 봉 수.
vol_krw_min: 최소 거래대금 (원).
spread_min: 횡보 필터 최소 변동폭 (%).
Returns:
시그널 dict 또는 None.
"""
n = len(bar_list)
if n < vol_lookback + 5:
return None
b = bar_list[-1]
if b['close'] <= b['open']:
return None
vr = calc_vr(bar_list, n - 1, lookback=vol_lookback)
if vr < vol_min:
return None
bar_krw = b['close'] * b['volume']
if bar_krw < vol_krw_min:
return None
# 1) 횡보 필터: 최근 15봉 변동폭 < 0.3%
recent = bar_list[-15:]
period_high = max(x['high'] for x in recent)
period_low = min(x['low'] for x in recent)
if period_low > 0:
spread_pct = (period_high - period_low) / period_low * 100
if spread_pct < spread_min:
log.debug(f"[필터/횡보] {ticker} 15봉 변동 {spread_pct:.2f}% -> 스킵")
return None
# 2) 고점 필터: 30분 구간 90%+ 위치 & 변동 1%+
long_bars = bar_list[-90:]
long_high = max(x['high'] for x in long_bars)
long_low = min(x['low'] for x in long_bars)
if long_high > long_low:
pos_in_range = (b['close'] - long_low) / (long_high - long_low)
move_pct = (long_high - long_low) / long_low * 100
if pos_in_range > 0.9 and move_pct > 1.0:
log.debug(f"[필터/고점] {ticker} 구간 {pos_in_range:.0%} 위치, 변동 {move_pct:.1f}% -> 스킵")
return None
# 3) 연속 양봉 필터: 직전 2봉 이상 연속 양봉
prev_greens = 0
for k in range(len(bar_list) - 2, max(len(bar_list) - 12, 0), -1):
if bar_list[k]['close'] > bar_list[k]['open']:
prev_greens += 1
else:
break
if prev_greens < 2:
log.debug(f"[필터/양봉] {ticker} 직전 연속양봉 {prev_greens}개 < 2 -> 스킵")
return None
return {
'ticker': ticker,
'price': b['close'],
'vol_ratio': vr,
'bar_list': bar_list,
}

View File

@@ -1,78 +0,0 @@
"""Strategy C: 실시간 상승 추세(DB) AND 거래량 모멘텀 동시 충족 시 매수 신호."""
from __future__ import annotations
import logging
import os
from .market import get_current_price, get_ohlcv
from .price_db import get_price_n_hours_ago
logger = logging.getLogger(__name__)
# 추세 판단: N시간 전 대비 +M% 이상이면 상승 중
TREND_HOURS = float(os.getenv("TREND_HOURS", "1"))
TREND_MIN_GAIN_PCT = float(os.getenv("TREND_MIN_GAIN_PCT", "3"))
# 모멘텀: MA 기간, 거래량 급증 배수
MA_PERIOD = 20
VOLUME_MULTIPLIER = 2.0
def check_trend(ticker: str) -> bool:
"""상승 추세 조건: 현재가가 N시간 전 대비 +M% 이상."""
past_price = get_price_n_hours_ago(ticker, TREND_HOURS)
if past_price is None:
logger.debug(f"[추세] {ticker} 과거 가격 없음 (데이터 수집 중)")
return False
current = get_current_price(ticker)
if not current:
return False
gain_pct = (current - past_price) / past_price * 100
result = gain_pct >= TREND_MIN_GAIN_PCT
if result:
logger.info(
f"[추세↑] {ticker} {TREND_HOURS:.0f}h 전={past_price:,.0f} "
f"현재={current:,.0f} (+{gain_pct:.1f}%)"
)
else:
logger.debug(
f"[추세✗] {ticker} {gain_pct:+.1f}% (기준={TREND_MIN_GAIN_PCT:+.0f}%)"
)
return result
def check_momentum(ticker: str) -> bool:
"""모멘텀 조건: 현재가 > MA20 AND 오늘 거래량 > 20일 평균 × 2."""
df = get_ohlcv(ticker, count=MA_PERIOD + 1)
if df is None or len(df) < MA_PERIOD + 1:
return False
ma = df["close"].iloc[-MA_PERIOD:].mean()
avg_vol = df["volume"].iloc[:-1].mean()
today_vol = df["volume"].iloc[-1]
current = get_current_price(ticker)
if current is None:
return False
price_ok = current > ma
vol_ok = today_vol > avg_vol * VOLUME_MULTIPLIER
result = price_ok and vol_ok
if result:
logger.debug(
f"[모멘텀] {ticker} 현재={current:,.0f} MA20={ma:,.0f} "
f"거래량={today_vol:.0f} 평균={avg_vol:.0f}"
)
return result
def should_buy(ticker: str) -> bool:
"""Strategy C: 실시간 상승 추세 AND 거래량 모멘텀 모두 충족 시 True."""
if not check_trend(ticker):
return False
return check_momentum(ticker)

View File

@@ -1,158 +0,0 @@
"""매수/매도 실행 및 포지션 관리."""
from __future__ import annotations
import logging
import os
import threading
import time
from datetime import datetime
from typing import Optional
import pyupbit
from dotenv import load_dotenv
from .notify import notify_buy, notify_sell, notify_error
load_dotenv()
logger = logging.getLogger(__name__)
MAX_BUDGET = 1_000_000 # 총 운용 한도: 100만원
MAX_POSITIONS = 3 # 최대 동시 보유 종목 수
PER_POSITION = MAX_BUDGET // MAX_POSITIONS # 종목당 33만3천원
_lock = threading.Lock()
_positions: dict = {}
# 구조: { ticker: { buy_price, peak_price, amount, invested_krw, entry_time } }
_upbit: Optional[pyupbit.Upbit] = None
def _get_upbit() -> pyupbit.Upbit:
global _upbit
if _upbit is None:
_upbit = pyupbit.Upbit(os.getenv("ACCESS_KEY"), os.getenv("SECRET_KEY"))
return _upbit
def get_positions() -> dict:
return _positions
def restore_positions() -> None:
"""시작 시 Upbit 실제 잔고를 읽어 포지션 복원 (재시작 이중 매수 방지)."""
upbit = _get_upbit()
balances = upbit.get_balances()
for b in balances:
currency = b["currency"]
if currency == "KRW":
continue
amount = float(b["balance"]) + float(b["locked"])
if amount <= 0:
continue
ticker = f"KRW-{currency}"
current = pyupbit.get_current_price(ticker)
if not current:
continue
invested_krw = int(amount * current)
if invested_krw < 1_000: # 소액 잔고 무시
continue
with _lock:
_positions[ticker] = {
"buy_price": current, # 정확한 매수가 불명 → 현재가로 초기화
"peak_price": current,
"amount": amount,
"invested_krw": min(invested_krw, PER_POSITION),
"entry_time": datetime.now(),
}
logger.info(
f"[복원] {ticker} 수량={amount} | 현재가={current:,.0f}"
f"(재시작 시 복원, 매수가 불명으로 현재가 기준)"
)
def buy(ticker: str) -> bool:
"""시장가 매수. 예산·포지션 수 확인 후 진입."""
with _lock:
if ticker in _positions:
logger.debug(f"{ticker} 이미 보유 중")
return False
if len(_positions) >= MAX_POSITIONS:
logger.info(f"최대 포지션 도달({MAX_POSITIONS}), {ticker} 패스")
return False
invested = sum(p["invested_krw"] for p in _positions.values())
available = MAX_BUDGET - invested
order_krw = min(available, PER_POSITION)
if order_krw < 10_000:
logger.info(f"잔여 예산 부족({order_krw:,}원), {ticker} 패스")
return False
upbit = _get_upbit()
try:
current = pyupbit.get_current_price(ticker)
result = upbit.buy_market_order(ticker, order_krw)
if not result or "error" in str(result):
logger.error(f"매수 실패: {result}")
return False
time.sleep(0.5) # 체결 대기
currency = ticker.split("-")[1]
amount = float(upbit.get_balance(currency) or 0)
_positions[ticker] = {
"buy_price": current,
"peak_price": current,
"amount": amount,
"invested_krw": order_krw,
"entry_time": datetime.now(),
}
logger.info(
f"[매수] {ticker} @ {current:,.0f}원 | "
f"수량={amount} | 투자금={order_krw:,}"
)
notify_buy(ticker, current, amount, order_krw)
return True
except Exception as e:
logger.error(f"매수 예외 {ticker}: {e}")
notify_error(f"매수 실패 {ticker}: {e}")
return False
def sell(ticker: str, reason: str = "") -> bool:
"""시장가 전량 매도."""
with _lock:
if ticker not in _positions:
return False
pos = _positions[ticker]
upbit = _get_upbit()
try:
result = upbit.sell_market_order(ticker, pos["amount"])
if not result or "error" in str(result):
logger.error(f"매도 실패: {result}")
return False
current = pyupbit.get_current_price(ticker)
pnl = (current - pos["buy_price"]) / pos["buy_price"] * 100
logger.info(
f"[매도] {ticker} @ {current:,.0f}원 | "
f"수익률={pnl:+.1f}% | 사유={reason}"
)
notify_sell(ticker, current, pnl, reason)
del _positions[ticker]
return True
except Exception as e:
logger.error(f"매도 예외 {ticker}: {e}")
notify_error(f"매도 실패 {ticker}: {e}")
return False
def update_peak(ticker: str, current_price: float) -> None:
"""최고가 갱신 (트레일링 스탑 기준선 상향)."""
with _lock:
if ticker in _positions:
if current_price > _positions[ticker]["peak_price"]:
_positions[ticker]["peak_price"] = current_price

View File

@@ -1,46 +0,0 @@
"""매수 기회 스캔 루프 - 60초마다 전체 시장 스캔."""
import logging
import time
from core import trader
from core.market import get_top_tickers
from core.strategy import should_buy
logger = logging.getLogger(__name__)
SCAN_INTERVAL = 60 # 초
def run_scanner() -> None:
"""메인 스캔 루프."""
logger.info(f"스캐너 시작 (주기={SCAN_INTERVAL}초)")
while True:
try:
# 포지션 꽉 찼으면 스캔 스킵
if len(trader.get_positions()) >= trader.MAX_POSITIONS:
logger.info("포지션 최대치 도달, 스캔 스킵")
time.sleep(SCAN_INTERVAL)
continue
tickers = get_top_tickers()
logger.info(f"스캔 시작: {len(tickers)}개 종목")
for ticker in tickers:
# 이미 보유 중인 종목 제외
if ticker in trader.get_positions():
continue
try:
if should_buy(ticker):
logger.info(f"매수 신호: {ticker}")
trader.buy(ticker)
time.sleep(0.15) # API rate limit 방지
except Exception as e:
logger.error(f"스캔 오류 {ticker}: {e}")
time.sleep(0.3)
except Exception as e:
logger.error(f"스캐너 루프 오류: {e}")
time.sleep(SCAN_INTERVAL)

View File

@@ -0,0 +1,226 @@
"""종목 컨텍스트 수집 데몬.
1시간마다 각 종목의 평판 정보를 수집해 Oracle ticker_context 테이블에 저장.
LLM 어드바이저가 매도 판단 시 참조.
수집 항목:
- price_stats: 24h/7d 가격 변동률, 거래량 추이
- news: SearXNG 웹 검색 뉴스 요약
실행:
.venv/bin/python3 daemons/context_collector.py
로그:
/tmp/context_collector.log
"""
import sys, os, time, logging, json, requests
from datetime import datetime
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from dotenv import load_dotenv
load_dotenv(os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), '.env'))
import pyupbit
import oracledb
TICKERS = [
'KRW-XRP', 'KRW-BTC', 'KRW-ETH', 'KRW-SOL', 'KRW-DOGE',
'KRW-ADA', 'KRW-SUI', 'KRW-NEAR', 'KRW-KAVA', 'KRW-SXP',
'KRW-AKT', 'KRW-SONIC', 'KRW-IP', 'KRW-ORBS', 'KRW-VIRTUAL',
'KRW-BARD', 'KRW-XPL', 'KRW-KITE', 'KRW-ENSO', 'KRW-0G',
'KRW-MANTRA', 'KRW-EDGE', 'KRW-CFG', 'KRW-ARDR', 'KRW-SIGN',
'KRW-AZTEC', 'KRW-ATH', 'KRW-HOLO', 'KRW-BREV', 'KRW-SHIB',
]
COLLECT_INTERVAL = 3600 # 1시간
SEARXNG_URL = 'https://searxng.cloud-handson.com/search'
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s %(levelname)s %(message)s',
handlers=[
logging.FileHandler('/tmp/context_collector.log'),
logging.StreamHandler(sys.stdout),
]
)
log = logging.getLogger(__name__)
# 코인명 매핑 (검색 키워드용)
COIN_NAMES = {
'KRW-BTC': 'Bitcoin', 'KRW-ETH': 'Ethereum', 'KRW-XRP': 'Ripple XRP',
'KRW-SOL': 'Solana', 'KRW-DOGE': 'Dogecoin', 'KRW-ADA': 'Cardano',
'KRW-SUI': 'SUI', 'KRW-NEAR': 'NEAR Protocol', 'KRW-KAVA': 'KAVA',
'KRW-SXP': 'Solar SXP', 'KRW-AKT': 'Akash', 'KRW-SONIC': 'Sonic',
'KRW-IP': 'Story IP', 'KRW-ORBS': 'ORBS', 'KRW-VIRTUAL': 'Virtuals Protocol',
'KRW-BARD': 'BARD', 'KRW-XPL': 'XPL', 'KRW-KITE': 'KITE',
'KRW-ENSO': 'ENSO', 'KRW-0G': '0G', 'KRW-MANTRA': 'MANTRA OM',
'KRW-EDGE': 'EDGE', 'KRW-CFG': 'Centrifuge', 'KRW-ARDR': 'Ardor',
'KRW-SIGN': 'SIGN', 'KRW-AZTEC': 'AZTEC', 'KRW-ATH': 'Aethir',
'KRW-HOLO': 'Holochain', 'KRW-BREV': 'BREV', 'KRW-SHIB': 'Shiba Inu',
}
def get_conn():
kwargs = dict(user=os.environ["ORACLE_USER"], password=os.environ["ORACLE_PASSWORD"],
dsn=os.environ["ORACLE_DSN"])
if w := os.environ.get("ORACLE_WALLET"):
kwargs["config_dir"] = w
return oracledb.connect(**kwargs)
def upsert_context(conn, ticker: str, context_type: str, content: str):
cur = conn.cursor()
cur.execute(
"""MERGE INTO ticker_context tc
USING (SELECT :t AS ticker, :ct AS context_type FROM dual) s
ON (tc.ticker = s.ticker AND tc.context_type = s.context_type)
WHEN MATCHED THEN
UPDATE SET content = :c, updated_at = SYSTIMESTAMP
WHEN NOT MATCHED THEN
INSERT (ticker, context_type, content)
VALUES (:t, :ct, :c)""",
{'t': ticker, 'ct': context_type, 'c': content},
)
def collect_price_stats(conn):
"""각 종목의 24h/7d 가격 변동률 + 거래량 추이를 수집."""
log.info("[price_stats] 수집 시작")
count = 0
for ticker in TICKERS:
try:
# 일봉 7개 (7일치)
df_day = pyupbit.get_ohlcv(ticker, interval='day', count=8)
if df_day is None or len(df_day) < 2:
continue
today = df_day.iloc[-1]
yesterday = df_day.iloc[-2]
# 24h 변동률
chg_24h = (today['close'] - yesterday['close']) / yesterday['close'] * 100
# 7d 변동률
if len(df_day) >= 8:
week_ago = df_day.iloc[-8]
chg_7d = (today['close'] - week_ago['close']) / week_ago['close'] * 100
else:
chg_7d = None
# 거래량 추이 (최근 3일 vs 이전 3일)
if len(df_day) >= 7:
recent_vol = df_day['volume'].iloc[-3:].mean()
prev_vol = df_day['volume'].iloc[-6:-3].mean()
vol_trend = recent_vol / prev_vol if prev_vol > 0 else 1.0
else:
vol_trend = None
# 1시간봉 최근 24개 (24시간)
df_h1 = pyupbit.get_ohlcv(ticker, interval='minute60', count=24)
h1_high = h1_low = None
if df_h1 is not None and not df_h1.empty:
h1_high = float(df_h1['high'].max())
h1_low = float(df_h1['low'].min())
stats = {
'price': float(today['close']),
'chg_24h_pct': round(chg_24h, 2),
'chg_7d_pct': round(chg_7d, 2) if chg_7d is not None else None,
'vol_today': float(today['volume']),
'vol_trend_3d': round(vol_trend, 2) if vol_trend is not None else None,
'h24_high': h1_high,
'h24_low': h1_low,
'updated': datetime.now().strftime('%Y-%m-%d %H:%M'),
}
upsert_context(conn, ticker, 'price_stats', json.dumps(stats, ensure_ascii=False))
count += 1
time.sleep(0.2)
except Exception as e:
log.warning(f"[price_stats] {ticker} 오류: {e}")
conn.commit()
log.info(f"[price_stats] 완료 {count}/{len(TICKERS)} 종목")
def search_news(coin_name: str, max_results: int = 5) -> list[dict]:
"""SearXNG로 코인 뉴스 검색."""
try:
resp = requests.get(
SEARXNG_URL,
params={
'q': f'{coin_name} crypto news',
'format': 'json',
'categories': 'news',
'language': 'ko-KR',
'time_range': 'week',
},
timeout=15,
)
resp.raise_for_status()
data = resp.json()
results = []
for r in data.get('results', [])[:max_results]:
results.append({
'title': r.get('title', ''),
'url': r.get('url', ''),
'content': r.get('content', '')[:200],
'date': r.get('publishedDate', ''),
})
return results
except Exception as e:
log.warning(f"[news] {coin_name} 검색 오류: {e}")
return []
def collect_news(conn):
"""각 종목의 최근 뉴스를 수집."""
log.info("[news] 수집 시작")
count = 0
for ticker in TICKERS:
coin_name = COIN_NAMES.get(ticker, ticker.split('-')[1])
try:
articles = search_news(coin_name)
if not articles:
continue
news_data = {
'coin': coin_name,
'articles': articles,
'updated': datetime.now().strftime('%Y-%m-%d %H:%M'),
}
upsert_context(conn, ticker, 'news', json.dumps(news_data, ensure_ascii=False))
count += 1
time.sleep(1) # 검색 API rate limit 배려
except Exception as e:
log.warning(f"[news] {ticker} 오류: {e}")
conn.commit()
log.info(f"[news] 완료 {count}/{len(TICKERS)} 종목")
def main():
log.info("=== context_collector 시작 (1시간 간격) ===")
log.info(f"대상: {len(TICKERS)}개 종목")
conn = get_conn()
while True:
t0 = time.time()
try:
collect_price_stats(conn)
collect_news(conn)
except oracledb.DatabaseError as e:
log.error(f"DB 오류: {e} — 재연결")
try:
conn.close()
except:
pass
conn = get_conn()
except Exception as e:
log.error(f"오류: {e}")
elapsed = time.time() - t0
log.info(f"[완료] {elapsed:.0f}초 소요, {COLLECT_INTERVAL}초 후 다음 수집")
time.sleep(max(60, COLLECT_INTERVAL - elapsed))
if __name__ == '__main__':
main()

184
daemons/state_sync.py Normal file
View File

@@ -0,0 +1,184 @@
"""10초 주기로 Upbit 잔고/미체결 주문을 조회하여 position_sync 테이블 동기화.
상태:
PENDING_BUY — 매수 주문 제출됨 (미체결)
HOLDING — 보유 중 (매도 주문 없음)
PENDING_SELL — 매도 주문 제출됨 (미체결)
IDLE — 아무 것도 없음 (행 삭제)
tick_trader는 이 테이블을 읽어서 positions/pending_buys를 복구한다.
실행:
.venv/bin/python3 daemons/state_sync.py
로그:
/tmp/state_sync.log
"""
import sys, os, time, logging
from datetime import datetime
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from dotenv import load_dotenv
load_dotenv(os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), '.env'))
import pyupbit
import oracledb
TICKERS = [
'KRW-XRP', 'KRW-BTC', 'KRW-ETH', 'KRW-SOL', 'KRW-DOGE',
'KRW-ADA', 'KRW-SUI', 'KRW-NEAR', 'KRW-KAVA', 'KRW-SXP',
'KRW-AKT', 'KRW-SONIC', 'KRW-IP', 'KRW-ORBS', 'KRW-VIRTUAL',
'KRW-BARD', 'KRW-XPL', 'KRW-KITE', 'KRW-ENSO', 'KRW-0G',
'KRW-MANTRA', 'KRW-EDGE', 'KRW-CFG', 'KRW-ARDR', 'KRW-SIGN',
'KRW-AZTEC', 'KRW-ATH', 'KRW-HOLO', 'KRW-BREV', 'KRW-SHIB',
]
INTERVAL = 10 # 초
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s %(levelname)s %(message)s',
handlers=[
logging.FileHandler('/tmp/state_sync.log'),
logging.StreamHandler(sys.stdout),
]
)
log = logging.getLogger(__name__)
upbit = pyupbit.Upbit(os.environ['ACCESS_KEY'], os.environ['SECRET_KEY'])
def get_conn():
kwargs = dict(user=os.environ["ORACLE_USER"], password=os.environ["ORACLE_PASSWORD"],
dsn=os.environ["ORACLE_DSN"])
if w := os.environ.get("ORACLE_WALLET"):
kwargs["config_dir"] = w
return oracledb.connect(**kwargs)
def sync_once(conn):
"""Upbit 실제 상태를 조회하여 position_sync 테이블 갱신."""
cur = conn.cursor()
now = datetime.now()
# 1. 잔고 조회 → 보유 종목 파악
balances = upbit.get_balances() or []
held = {} # ticker → {qty, avg_price}
for b in balances:
currency = b.get('currency', '')
if currency == 'KRW':
continue
ticker = f'KRW-{currency}'
if ticker not in TICKERS:
continue
bal = float(b.get('balance', 0))
locked = float(b.get('locked', 0))
total = bal + locked
avg = float(b.get('avg_buy_price', 0))
if total > 0 and avg > 0:
held[ticker] = {'qty': total, 'avg_price': avg, 'invested': int(total * avg)}
# 2. 미체결 주문 조회 → 매수/매도 대기 파악
pending_buys = {} # ticker → {uuid, price, qty}
pending_sells = {} # ticker → {uuid, price, qty}
for ticker in TICKERS:
try:
orders = upbit.get_order(ticker, state='wait') or []
if not isinstance(orders, list):
continue
for o in orders:
side = o.get('side')
uuid = o.get('uuid')
price = float(o.get('price', 0))
rem = float(o.get('remaining_volume', 0))
if price <= 0 or rem <= 0:
continue
if side == 'bid':
pending_buys[ticker] = {'uuid': uuid, 'price': price, 'qty': rem}
elif side == 'ask':
pending_sells[ticker] = {'uuid': uuid, 'price': price, 'qty': rem}
except Exception:
pass
# 3. 상태 결정 및 DB 반영
active_tickers = set(held.keys()) | set(pending_buys.keys()) | set(pending_sells.keys())
for ticker in active_tickers:
if ticker in pending_buys and ticker not in held:
state = 'PENDING_BUY'
pb = pending_buys[ticker]
buy_price = pb['price']
sell_price = None
qty = pb['qty']
order_uuid = pb['uuid']
invested = int(qty * buy_price)
elif ticker in held and ticker in pending_sells:
state = 'PENDING_SELL'
h = held[ticker]
ps = pending_sells[ticker]
buy_price = h['avg_price']
sell_price = ps['price']
qty = h['qty']
order_uuid = ps['uuid']
invested = h['invested']
elif ticker in held:
state = 'HOLDING'
h = held[ticker]
buy_price = h['avg_price']
sell_price = None
qty = h['qty']
order_uuid = None
invested = h['invested']
else:
continue
cur.execute(
"""MERGE INTO position_sync ps
USING (SELECT :1 AS ticker FROM dual) src
ON (ps.ticker = src.ticker)
WHEN MATCHED THEN UPDATE SET
state = :2, buy_price = :3, sell_price = :4,
qty = :5, order_uuid = :6, invested_krw = :7, updated_at = :8
WHEN NOT MATCHED THEN INSERT
(ticker, state, buy_price, sell_price, qty, order_uuid, invested_krw, updated_at)
VALUES (:9, :10, :11, :12, :13, :14, :15, :16)""",
[ticker, state, buy_price, sell_price, qty, order_uuid, invested, now,
ticker, state, buy_price, sell_price, qty, order_uuid, invested, now]
)
# 4. 이제 없는 종목은 삭제
if active_tickers:
placeholders = ','.join(f"'{t}'" for t in active_tickers)
cur.execute(f"DELETE FROM position_sync WHERE ticker NOT IN ({placeholders})")
else:
cur.execute("DELETE FROM position_sync")
conn.commit()
if active_tickers:
summary = ', '.join(f"{t.split('-')[1]}={cur.execute('SELECT state FROM position_sync WHERE ticker=:1',[t]).fetchone()[0]}" for t in sorted(active_tickers))
log.info(f"[동기화] {summary}")
def main():
log.info(f"=== state_sync 시작 (주기 {INTERVAL}초) ===")
conn = get_conn()
fail_count = 0
while True:
try:
sync_once(conn)
fail_count = 0
except Exception as e:
fail_count += 1
log.error(f"[동기화 오류] {e}", exc_info=(fail_count <= 3))
try:
conn.close()
except Exception:
pass
try:
conn = get_conn()
except Exception:
pass
time.sleep(INTERVAL)
if __name__ == '__main__':
main()

118
daemons/tick_collector.py Normal file
View File

@@ -0,0 +1,118 @@
"""30초마다 전 종목 현재가를 Oracle price_tick 테이블에 적재.
+ 60초마다 backtest_ohlcv 1분봉 최신 데이터 갱신.
"""
import sys, os, time, logging
from datetime import datetime
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from dotenv import load_dotenv
load_dotenv(os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), '.env'))
import pyupbit
import oracledb
TICKERS = [
'KRW-XRP', 'KRW-BTC', 'KRW-ETH', 'KRW-SOL', 'KRW-DOGE',
'KRW-ADA', 'KRW-SUI', 'KRW-NEAR', 'KRW-KAVA', 'KRW-SXP',
'KRW-AKT', 'KRW-SONIC', 'KRW-IP', 'KRW-ORBS', 'KRW-VIRTUAL',
'KRW-BARD', 'KRW-XPL', 'KRW-KITE', 'KRW-ENSO', 'KRW-0G',
'KRW-MANTRA', 'KRW-EDGE', 'KRW-CFG', 'KRW-ARDR', 'KRW-SIGN',
'KRW-AZTEC', 'KRW-ATH', 'KRW-HOLO', 'KRW-BREV', 'KRW-SHIB',
]
INTERVAL = 30 # 초
OHLCV_INTERVAL = 60 # 1분봉 갱신 주기 (초)
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s %(levelname)s %(message)s',
handlers=[
logging.FileHandler('/tmp/tick_collector.log'),
logging.StreamHandler(sys.stdout),
]
)
log = logging.getLogger(__name__)
def get_conn():
kwargs = dict(user=os.environ["ORACLE_USER"], password=os.environ["ORACLE_PASSWORD"],
dsn=os.environ["ORACLE_DSN"])
if w := os.environ.get("ORACLE_WALLET"):
kwargs["config_dir"] = w
return oracledb.connect(**kwargs)
def collect(conn):
prices = pyupbit.get_current_price(TICKERS)
if not prices:
log.warning("현재가 조회 실패")
return
ts = datetime.now().replace(microsecond=0)
rows = [(t, ts, p) for t, p in prices.items() if p]
cur = conn.cursor()
cur.executemany(
"INSERT INTO price_tick (ticker, ts, price) VALUES (:1, :2, :3)",
rows
)
conn.commit()
log.info(f"적재 {len(rows)}건 ts={ts}")
def refresh_ohlcv(conn):
"""backtest_ohlcv 1분봉을 최근 5개씩 갱신 (중복 무시)."""
total = 0
for ticker in TICKERS:
try:
df = pyupbit.get_ohlcv(ticker, interval='minute1', count=5)
if df is None or df.empty:
continue
rows = [
(ticker, 'minute1', ts.to_pydatetime(),
float(r['open']), float(r['high']), float(r['low']),
float(r['close']), float(r['volume']))
for ts, r in df.iterrows()
]
cur = conn.cursor()
cur.executemany(
"INSERT INTO backtest_ohlcv "
"(ticker,interval_cd,ts,open_p,high_p,low_p,close_p,volume_p) "
"VALUES (:1,:2,:3,:4,:5,:6,:7,:8)",
rows, batcherrors=True,
)
inserted = len(rows) - len(cur.getbatcherrors())
total += inserted
time.sleep(0.15)
except Exception as e:
log.warning(f"[ohlcv] {ticker} 오류: {e}")
conn.commit()
if total > 0:
log.info(f"[ohlcv] 1분봉 갱신 {total}")
def main():
log.info("=== tick_collector 시작 (30초 간격 + 1분봉 갱신) ===")
conn = get_conn()
last_ohlcv = 0
while True:
t0 = time.time()
try:
collect(conn)
# 1분봉 갱신 (OHLCV_INTERVAL마다)
if t0 - last_ohlcv >= OHLCV_INTERVAL:
refresh_ohlcv(conn)
last_ohlcv = t0
except oracledb.DatabaseError as e:
log.error(f"DB 오류: {e} — 재연결")
try: conn.close()
except: pass
conn = get_conn()
except Exception as e:
log.error(f"오류: {e}")
elapsed = time.time() - t0
time.sleep(max(1.0, INTERVAL - elapsed))
if __name__ == '__main__':
main()

489
daemons/tick_trader.py Normal file
View File

@@ -0,0 +1,489 @@
"""WebSocket 기반 20초봉 트레이더 (Controller).
구조:
WebSocket -> trade tick 수신 -> 20초봉 집계
-> 시그널(양봉 + VOL>=5x + 사전필터 3종) -> LLM 매수 판단 -> 현재가 지정가 매수
-> 트레일링 스탑 청산 (고점 -1.5%, 손절 -2%, 타임아웃 4h)
실행:
.venv/bin/python3 daemons/tick_trader.py
로그:
/tmp/tick_trader.log
"""
import sys
import os
import time
import logging
import threading
import requests
from datetime import datetime
from collections import deque, defaultdict
from typing import Optional
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from dotenv import load_dotenv
load_dotenv(os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), '.env'))
from core.llm_advisor import get_entry_price
from core.signal import detect_signal, calc_vr
from core.order import (
round_price, submit_limit_buy, cancel_order,
check_order_state, sell_market,
)
from core.position_manager import (
sync_position, calc_remaining_budget,
check_exit_conditions, restore_from_upbit,
)
import pyupbit
# ── 전략 파라미터 ──────────────────────────────────────────────────────────────
TICKERS = [
'KRW-ETH', 'KRW-XRP', 'KRW-SOL', 'KRW-DOGE', 'KRW-SIGN',
'KRW-BARD', 'KRW-KITE', 'KRW-CFG', 'KRW-SXP', 'KRW-ARDR',
]
BAR_SEC = 20
VOL_LOOKBACK = 61
VOL_MIN = 5.0
VOL_KRW_MIN = 5_000_000
BUY_TIMEOUT = 180
MAX_POS = int(os.environ.get('MAX_POSITIONS', 5))
MAX_BUDGET = int(os.environ.get('MAX_BUDGET', 1_000_000))
PER_POS = MAX_BUDGET // MAX_POS
FEE = 0.0005
TRAIL_PCT = 0.015
MIN_PROFIT_PCT = 0.005
STOP_LOSS_PCT = 0.02
TIMEOUT_SECS = 14400
SIM_MODE = os.environ.get('SIMULATION_MODE', 'true').lower() == 'true'
upbit_client = pyupbit.Upbit(os.environ['ACCESS_KEY'], os.environ['SECRET_KEY'])
TG_TOKEN = os.environ.get('TELEGRAM_TRADE_TOKEN', '')
TG_CHAT_ID = os.environ.get('TELEGRAM_CHAT_ID', '')
# ── 로깅 ──────────────────────────────────────────────────────────────────────
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s %(levelname)s %(message)s',
handlers=[logging.FileHandler('/tmp/tick_trader.log')],
)
log = logging.getLogger(__name__)
# ── 상태 ──────────────────────────────────────────────────────────────────────
bars: dict = defaultdict(lambda: deque(maxlen=VOL_LOOKBACK + 10))
cur_bar: dict = {}
bar_lock = threading.Lock()
positions: dict = {}
pending_buys: dict = {}
# ── 유틸리티 ──────────────────────────────────────────────────────────────────
def fp(price: float) -> str:
"""가격 포맷. 100원 미만은 소수점 표시."""
if price >= 100:
return f"{price:,.0f}"
elif price >= 10:
return f"{price:,.1f}"
else:
return f"{price:,.2f}"
def tg(msg: str) -> None:
"""텔레그램 알림 전송."""
if not TG_TOKEN or not TG_CHAT_ID:
return
try:
requests.post(
f'https://api.telegram.org/bot{TG_TOKEN}/sendMessage',
json={'chat_id': TG_CHAT_ID, 'text': msg, 'parse_mode': 'HTML'},
timeout=5,
)
except (ConnectionError, TimeoutError) as e:
log.warning(f'Telegram 전송 실패: {e}')
# ── 20초봉 집계 ───────────────────────────────────────────────────────────────
def _new_bar(price: float, volume: float, ts: datetime) -> dict:
"""새 봉 초기화."""
return {'open': price, 'high': price, 'low': price,
'close': price, 'volume': volume, 'ts': ts}
def on_tick(ticker: str, price: float, volume: float) -> None:
"""WebSocket tick -> 현재 봉에 반영."""
with bar_lock:
if ticker not in cur_bar:
cur_bar[ticker] = _new_bar(price, volume, datetime.now())
return
b = cur_bar[ticker]
b['high'] = max(b['high'], price)
b['low'] = min(b['low'], price)
b['close'] = price
b['volume'] += volume
def finalize_bars() -> None:
"""BAR_SEC마다 봉 확정 -> 시그널 감지 -> 매수/청산 처리."""
while True:
time.sleep(BAR_SEC)
now = datetime.now()
signals = []
with bar_lock:
for ticker in list(cur_bar.keys()):
b = cur_bar[ticker]
if b['volume'] == 0:
continue
bars[ticker].append(b)
cur_bar[ticker] = _new_bar(b['close'], 0, now)
if ticker in positions or ticker in pending_buys:
continue
if len(positions) + len(pending_buys) >= MAX_POS:
continue
sig = detect_signal(
ticker, list(bars[ticker]),
vol_min=VOL_MIN, vol_lookback=VOL_LOOKBACK,
vol_krw_min=VOL_KRW_MIN,
)
if sig:
signals.append(sig)
for sig in signals:
process_signal(sig)
check_pending_buys()
check_filled_positions()
# ── 매수 처리 ─────────────────────────────────────────────────────────────────
def process_signal(sig: dict) -> None:
"""시그널 감지 후 LLM 매수 판단 -> 지정가 매수 제출."""
ticker = sig['ticker']
cur_price = sig['price']
vol_ratio = sig['vol_ratio']
if ticker in positions or ticker in pending_buys:
return
if len(positions) + len(pending_buys) >= MAX_POS:
return
log.info(f"[시그널] {ticker} {fp(cur_price)}원 vol {vol_ratio:.1f}x -> LLM 판단 요청")
llm_result = get_entry_price(
ticker=ticker, signal=sig, bar_list=sig['bar_list'],
current_price=cur_price,
num_positions=len(positions), max_positions=MAX_POS,
)
if llm_result is None or llm_result.get('action') != 'buy':
_handle_skip(ticker, cur_price, vol_ratio, llm_result)
return
if ticker in positions or ticker in pending_buys:
return
if len(positions) + len(pending_buys) >= MAX_POS:
log.info(f"[매수/LLM] {ticker} -> 승인됐으나 포지션 한도 도달 -> 스킵")
return
_submit_buy(ticker, cur_price, vol_ratio, llm_result)
def _handle_skip(
ticker: str, price: float, vol_ratio: float,
llm_result: Optional[dict],
) -> None:
"""LLM skip 결과 로깅 + 텔레그램 알림."""
reason = llm_result.get('reason', 'LLM 오류') if llm_result else 'LLM 무응답'
status = llm_result.get('market_status', '') if llm_result else ''
log.info(f"[매수/LLM] {ticker} -> 스킵 | {reason}")
tg(
f"⏭️ <b>매수 스킵</b> {ticker}\n"
f"현재가: {fp(price)}원 볼륨: {vol_ratio:.1f}x\n"
f"시장: {status}\n"
f"사유: {reason}"
)
def _submit_buy(
ticker: str, cur_price: float, vol_ratio: float,
llm_result: dict,
) -> None:
"""LLM 승인 후 예산 체크 -> 지정가 매수 제출."""
buy_price = round_price(cur_price)
confidence = llm_result.get('confidence', '?')
reason = llm_result.get('reason', '')
status = llm_result.get('market_status', '')
remaining = calc_remaining_budget(positions, pending_buys, MAX_BUDGET)
invest_amt = min(PER_POS, remaining)
if invest_amt < 5000:
log.info(f"[매수/예산부족] {ticker} 남은예산 {remaining:,.0f}원 -> 스킵")
return
qty = invest_amt * (1 - FEE) / buy_price
log.info(f"[매수/LLM] {ticker} -> 승인 {fp(buy_price)}원 (현재가 매수)")
uuid = submit_limit_buy(upbit_client, ticker, buy_price, qty, sim_mode=SIM_MODE)
if uuid is None:
return
pending_buys[ticker] = {
'uuid': uuid, 'price': buy_price, 'qty': qty,
'ts': datetime.now(), 'vol_ratio': vol_ratio,
}
invested = int(qty * buy_price)
sync_position(ticker, 'PENDING_BUY', buy_price=buy_price, qty=qty,
order_uuid=uuid, invested_krw=invested)
log.info(f"[지정가매수] {ticker} {fp(buy_price)}원 수량: {qty:.6f}")
tg(
f"📥 <b>지정가 매수</b> {ticker}\n"
f"지정가: {fp(buy_price)}원 투자: {invested:,}\n"
f"수량: {qty:.6f} 볼륨: {vol_ratio:.1f}x\n"
f"확신: {confidence} 시장: {status}\n"
f"LLM: {reason}\n"
f"{'[시뮬]' if SIM_MODE else '[실거래]'}"
)
# ── 체결 확인 ─────────────────────────────────────────────────────────────────
def check_pending_buys() -> None:
"""미체결 매수 주문 체결 확인. 타임아웃/한도 초과 시 취소."""
for ticker in list(pending_buys.keys()):
pb = pending_buys[ticker]
elapsed = (datetime.now() - pb['ts']).total_seconds()
if len(positions) >= MAX_POS:
cancel_order(upbit_client, pb['uuid'], sim_mode=SIM_MODE)
log.info(f"[매수취소] {ticker} 포지션 한도({MAX_POS}) 도달 -> 취소")
sync_position(ticker, 'IDLE')
del pending_buys[ticker]
continue
if SIM_MODE:
bar_list = list(bars.get(ticker, []))
if bar_list and bar_list[-1]['low'] <= pb['price']:
log.info(f"[SIM 매수체결] {ticker} {fp(pb['price'])}")
_activate_position(ticker, pb['price'], pb['qty'], pb['vol_ratio'])
del pending_buys[ticker]
continue
else:
state, avg_price = check_order_state(upbit_client, pb['uuid'])
if state == 'done':
actual_price = avg_price or pb['price']
actual_qty = upbit_client.get_balance(ticker.split('-')[1]) or pb['qty']
_activate_position(ticker, actual_price, actual_qty, pb['vol_ratio'])
del pending_buys[ticker]
continue
if elapsed >= BUY_TIMEOUT:
cancel_order(upbit_client, pb['uuid'], sim_mode=SIM_MODE)
log.info(f"[매수취소] {ticker} {elapsed:.0f}초 미체결 -> 취소")
tg(f"❌ <b>매수 취소</b> {ticker}\n{fp(pb['price'])}{elapsed:.0f}초 미체결")
sync_position(ticker, 'IDLE')
del pending_buys[ticker]
def _activate_position(
ticker: str, entry_price: float, qty: float, vol_ratio: float,
) -> None:
"""매수 체결 후 포지션 등록."""
positions[ticker] = {
'entry_price': entry_price,
'entry_ts': datetime.now(),
'running_peak': entry_price,
'qty': qty,
}
invested = int(qty * entry_price)
sync_position(ticker, 'PENDING_SELL', buy_price=entry_price,
qty=qty, invested_krw=invested)
log.info(f"[진입] {ticker} {fp(entry_price)}원 vol {vol_ratio:.1f}x 트레일 -{TRAIL_PCT*100:.1f}%")
tg(
f"🟢 <b>매수 체결</b> {ticker}\n"
f"체결가: {fp(entry_price)}원 투자: {invested:,}\n"
f"트레일: 고점 대비 -{TRAIL_PCT*100:.1f}% / 손절: -{STOP_LOSS_PCT*100:.1f}%\n"
f"{'[시뮬]' if SIM_MODE else '[실거래]'}"
)
# ── 포지션 관리 ───────────────────────────────────────────────────────────────
def _record_exit(ticker: str, exit_price: float, tag: str) -> None:
"""포지션 청산 기록 + 텔레그램 알림."""
pos = positions[ticker]
pnl = (exit_price - pos['entry_price']) / pos['entry_price'] * 100
krw = PER_POS * (pnl / 100) - PER_POS * FEE * 2
held = int((datetime.now() - pos['entry_ts']).total_seconds())
reason_tag = {
'trail': '트레일스탑', 'timeout': '타임아웃',
'stoploss': '손절', 'llm': 'LLM 매도',
}.get(tag, tag)
icon = "" if pnl > 0 else "🔴"
invested = int(pos['qty'] * pos['entry_price'])
log.info(f"[청산/{tag}] {ticker} {fp(exit_price)}원 PNL {pnl:+.2f}% {krw:+,.0f}{held}초 보유")
tg(
f"{icon} <b>청산</b> {ticker} [{reason_tag}]\n"
f"투자: {invested:,}\n"
f"진입: {fp(pos['entry_price'])}원 -> 청산: {fp(exit_price)}\n"
f"PNL: <b>{pnl:+.2f}%</b> ({krw:+,.0f}원) {held}초 보유\n"
f"{'[시뮬]' if SIM_MODE else '[실거래]'}"
)
sync_position(ticker, 'IDLE')
del positions[ticker]
def _try_exit(ticker: str, price: float) -> None:
"""청산 조건 체크 후 시장가 매도 실행."""
pos = positions[ticker]
pos['running_peak'] = max(pos['running_peak'], price)
tag = check_exit_conditions(
pos, price,
trail_pct=TRAIL_PCT, min_profit_pct=MIN_PROFIT_PCT,
stop_loss_pct=STOP_LOSS_PCT, timeout_secs=TIMEOUT_SECS,
)
if tag is None:
return
exit_price = sell_market(upbit_client, ticker, pos['qty'], sim_mode=SIM_MODE) or price
if tag == 'trail':
peak_pnl = (pos['running_peak'] - pos['entry_price']) / pos['entry_price'] * 100
drop = (pos['running_peak'] - price) / pos['running_peak'] * 100
log.info(f"[트레일] {ticker} 고점 {fp(pos['running_peak'])}원(+{peak_pnl:.1f}%) -> {fp(price)}원 drop {drop:.2f}%")
elif tag == 'stoploss':
profit = (price - pos['entry_price']) / pos['entry_price'] * 100
log.info(f"[손절] {ticker} {fp(price)}원 (진입 대비 {profit:+.2f}%)")
elif tag == 'timeout':
elapsed = (datetime.now() - pos['entry_ts']).total_seconds()
log.info(f"[타임아웃] {ticker} {elapsed:.0f}초 경과")
_record_exit(ticker, exit_price, tag)
def check_filled_positions() -> None:
"""20초마다 포지션 체크: 트레일링 스탑 / 손절 / 타임아웃."""
for ticker in list(positions.keys()):
if ticker not in positions:
continue
bar_list = list(bars.get(ticker, []))
if not bar_list:
continue
_try_exit(ticker, bar_list[-1]['close'])
def update_positions(current_prices: dict) -> None:
"""tick마다 실시간 peak 갱신 + 손절/트레일 체크."""
for ticker in list(positions.keys()):
if ticker not in current_prices:
continue
_try_exit(ticker, current_prices[ticker])
# ── 초기화 ────────────────────────────────────────────────────────────────────
def preload_bars() -> None:
"""REST API 1분봉으로 bars[] 사전 적재."""
need_min = (VOL_LOOKBACK + 10) // 3 + 1
log.info(f"[사전적재] REST API 1분봉 {need_min}개로 bars[] 초기화 중...")
loaded = 0
for ticker in TICKERS:
for attempt in range(3):
try:
df = pyupbit.get_ohlcv(ticker, interval='minute1', count=need_min)
if df is None or df.empty:
time.sleep(0.5)
continue
with bar_lock:
for _, row in df.iterrows():
o, h, l, c = float(row['open']), float(row['high']), float(row['low']), float(row['close'])
v3 = float(row['volume']) / 3
ts = row.name.to_pydatetime()
for _ in range(3):
bars[ticker].append({'open': o, 'high': h, 'low': l, 'close': c, 'volume': v3, 'ts': ts})
loaded += 1
break
except (ConnectionError, TimeoutError, ValueError) as e:
log.warning(f"[사전적재] {ticker} 시도{attempt+1} 실패: {e}")
time.sleep(1)
time.sleep(0.2)
log.info(f"[사전적재] 완료 {loaded}/{len(TICKERS)} 티커")
def restore_positions() -> None:
"""Upbit 잔고에서 포지션 + 미체결 매수 복구."""
if SIM_MODE:
return
try:
restore_from_upbit(
upbit_client, TICKERS, positions, pending_buys,
cancel_fn=lambda uuid: cancel_order(upbit_client, uuid, sim_mode=SIM_MODE),
fp_fn=fp, tg_fn=tg,
)
except (ConnectionError, TimeoutError, ValueError) as e:
log.warning(f"[복구] 잔고 조회 실패: {e}", exc_info=True)
# ── 메인 ──────────────────────────────────────────────────────────────────────
def main() -> None:
"""tick_trader 메인 루프."""
mode = "🔴 실거래" if not SIM_MODE else "🟡 시뮬레이션"
log.info(f"=== tick_trader 시작 ({mode}) ===")
log.info(f"봉주기: 20초 | VOL >= {VOL_MIN}x | 포지션 최대 {MAX_POS}개 | 1개당 {PER_POS:,}")
log.info(f"청산: 트레일 고점-{TRAIL_PCT*100:.1f}% (최소익 +{MIN_PROFIT_PCT*100:.1f}%) | 손절 -{STOP_LOSS_PCT*100:.1f}% | 타임아웃 {TIMEOUT_SECS//3600}h")
tg(
f"🚀 <b>tick_trader 시작</b> ({mode})\n"
f"예산: {MAX_BUDGET:,}원 | 최대 {MAX_POS}포지션 | 종목당 {PER_POS:,}\n"
f"VOL >= {VOL_MIN}x | 거래대금 >= {VOL_KRW_MIN/1e6:.0f}M | 연속양봉 >= 2\n"
f"트레일: 고점 -{TRAIL_PCT*100:.1f}% (최소 +{MIN_PROFIT_PCT*100:.1f}%)\n"
f"손절: -{STOP_LOSS_PCT*100:.1f}% | 타임아웃: {TIMEOUT_SECS//3600}h"
)
preload_bars()
restore_positions()
t = threading.Thread(target=finalize_bars, daemon=True)
t.start()
ws = pyupbit.WebSocketManager("trade", TICKERS)
log.info("WebSocket 연결됨")
last_pos_log = time.time()
while True:
try:
data = ws.get()
if data is None:
continue
ticker = data.get('code')
price = data.get('trade_price')
volume = data.get('trade_volume')
if not ticker or price is None or volume is None:
continue
on_tick(ticker, float(price), float(volume))
if positions:
update_positions({ticker: float(price)})
if time.time() - last_pos_log > 60:
warmed = sum(1 for t in TICKERS if len(bars[t]) >= VOL_LOOKBACK + 5)
if positions:
pos_lines = ' '.join(
f"{t.split('-')[1]} {p['entry_price']:,.0f}->{p['running_peak']:,.0f} ({(p['running_peak']-p['entry_price'])/p['entry_price']*100:+.1f}%)"
for t, p in positions.items()
)
log.info(f"[상태] 포지션 {len(positions)}/{MAX_POS} {pos_lines}")
else:
log.info(f"[상태] 포지션 없음 ({warmed}/{len(TICKERS)} 준비완료)")
last_pos_log = time.time()
except Exception as e:
log.error(f"루프 오류: {e}")
time.sleep(1)
if __name__ == '__main__':
main()

59
ecosystem.config.js Normal file
View File

@@ -0,0 +1,59 @@
module.exports = {
apps: [
{
name: "upbit-trader",
script: "main.py",
interpreter: ".venv/bin/python3",
cwd: "/Users/joungmin/workspaces/upbit-trader",
out_file: "logs/pm2.log",
error_file: "logs/pm2-error.log",
log_date_format: "YYYY-MM-DD HH:mm:ss",
autorestart: true,
watch: false,
},
{
name: "tick-collector",
script: "daemons/tick_collector.py",
interpreter: ".venv/bin/python3",
cwd: "/Users/joungmin/workspaces/upbit-trader",
out_file: "logs/tick-collector.log",
error_file: "logs/tick-collector-error.log",
log_date_format: "YYYY-MM-DD HH:mm:ss",
autorestart: true,
watch: false,
},
{
name: "tick-trader",
script: "daemons/tick_trader.py",
interpreter: ".venv/bin/python3",
cwd: "/Users/joungmin/workspaces/upbit-trader",
out_file: "logs/tick-trader.log",
error_file: "logs/tick-trader-error.log",
log_date_format: "YYYY-MM-DD HH:mm:ss",
autorestart: true,
watch: false,
},
{
name: "state-sync",
script: "daemons/state_sync.py",
interpreter: ".venv/bin/python3",
cwd: "/Users/joungmin/workspaces/upbit-trader",
out_file: "logs/state-sync.log",
error_file: "logs/state-sync-error.log",
log_date_format: "YYYY-MM-DD HH:mm:ss",
autorestart: true,
watch: false,
},
{
name: "context-collector",
script: "daemons/context_collector.py",
interpreter: ".venv/bin/python3",
cwd: "/Users/joungmin/workspaces/upbit-trader",
out_file: "logs/context-collector.log",
error_file: "logs/context-collector-error.log",
log_date_format: "YYYY-MM-DD HH:mm:ss",
autorestart: true,
watch: false,
},
],
};

View File

@@ -5,4 +5,6 @@ requires-python = ">=3.9"
dependencies = [
"pyupbit>=0.3.0",
"python-dotenv>=1.0",
"anthropic>=0.40",
"oracledb>=2.0",
]