Files
upbit-trader/core/price_db.py
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

381 lines
13 KiB
Python

"""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
]