diff --git a/core/market.py b/core/market.py index db4b15c..435524a 100644 --- a/core/market.py +++ b/core/market.py @@ -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"} diff --git a/core/price_db.py b/core/price_db.py index 7159854..e3bca77 100644 --- a/core/price_db.py +++ b/core/price_db.py @@ -196,6 +196,56 @@ def load_recent_wins(ticker: str, n: int = 5) -> list[bool]: 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} + ) + + def load_positions() -> list[dict]: """저장된 전체 포지션 로드.""" sql = """ diff --git a/core/trader.py b/core/trader.py index 45f7c3c..73a866a 100644 --- a/core/trader.py +++ b/core/trader.py @@ -15,6 +15,7 @@ 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, ) load_dotenv() @@ -97,12 +98,26 @@ def restore_positions() -> None: DB에 저장된 실제 매수가를 복원하고, Upbit 잔고에 없으면 DB에서도 삭제한다. """ - # trade_results 테이블 초기화 + # trade_results / sell_prices 테이블 초기화 try: ensure_trade_results_table() except Exception as e: logger.warning(f"trade_results 테이블 생성 실패 (무시): {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()} @@ -291,6 +306,10 @@ def sell(ticker: str, reason: str = "") -> bool: notify_sell(ticker, current, pnl, reason) if current: _last_sell_prices[ticker] = current # 재매수 기준가 기록 + try: + upsert_sell_price(ticker, current) # DB 영구 저장 + except Exception as e: + logger.error(f"직전 매도가 DB 저장 실패 {ticker}: {e}") _update_history(ticker, pnl > 0, pnl) # walk-forward 이력 갱신 del _positions[ticker] try: