feat: replace volatility breakout with DB-backed real-time trend check
- price_history table on Oracle ADB stores prices every 10 minutes - check_trend(): current price vs N hours ago (default 1h, +3% threshold) - check_momentum(): unchanged (MA20 + 2x volume still applies) - Ticker list cached 5 minutes to avoid 429 rate limits - Collector starts 30s after boot to avoid simultaneous API calls - Configurable: TREND_HOURS, TREND_MIN_GAIN_PCT in .env Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
91
core/price_db.py
Normal file
91
core/price_db.py
Normal file
@@ -0,0 +1,91 @@
|
||||
"""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)
|
||||
Reference in New Issue
Block a user