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>
This commit is contained in:
@@ -22,7 +22,7 @@ TIME_STOP_MIN_GAIN_PCT = float(os.getenv("TIME_STOP_MIN_GAIN_PCT", "3"))
|
||||
ATR_CANDLES = 5 # 최근 N개 1h봉으로 자연 진폭 계산
|
||||
ATR_MULT = 1.5 # 평균 진폭 × 배수 = 스탑 임계값
|
||||
ATR_MIN_STOP = 0.010 # 최소 스탑 1.0% (너무 좁아지는 거 방지)
|
||||
ATR_MAX_STOP = 0.040 # 최대 스탑 4.0% (너무 넓어지는 거 방지)
|
||||
ATR_MAX_STOP = 0.020 # 최대 스탑 2.0% (너무 넓어지는 거 방지)
|
||||
|
||||
# ATR 캐시: 종목별 (스탑비율, 계산시각) — 10분마다 갱신
|
||||
_atr_cache: dict[str, tuple[float, float]] = {}
|
||||
|
||||
@@ -282,6 +282,71 @@ def delete_sell_price(ticker: str) -> None:
|
||||
)
|
||||
|
||||
|
||||
# ── 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 = """
|
||||
|
||||
@@ -18,6 +18,7 @@ from .price_db import (
|
||||
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()
|
||||
@@ -148,6 +149,10 @@ def _shadow_enter(ticker: str) -> None:
|
||||
}
|
||||
|
||||
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}연승 필요)"
|
||||
@@ -185,6 +190,15 @@ def close_shadow(ticker: str, sell_price: float, pnl_pct: float, reason: str) ->
|
||||
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}원 "
|
||||
@@ -239,7 +253,7 @@ def restore_positions() -> None:
|
||||
|
||||
DB에 저장된 실제 매수가를 복원하고, Upbit 잔고에 없으면 DB에서도 삭제한다.
|
||||
"""
|
||||
# trade_results / sell_prices 테이블 초기화
|
||||
# trade_results / sell_prices / wf_state 테이블 초기화
|
||||
try:
|
||||
ensure_trade_results_table()
|
||||
except Exception as e:
|
||||
@@ -248,6 +262,21 @@ def restore_positions() -> None:
|
||||
# 시작 시 복리 예산 복원 (이전 세션 수익 반영)
|
||||
_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:
|
||||
|
||||
Reference in New Issue
Block a user