feat(db): ENV 키·스키마 확장 및 거래일/포트폴리오 DB 헬퍼

OrderWorker·수집통계·영구구독 알람 env 등록과 TradeDB/db_manager 조회·마이그레이션을 보강한다.
kr_trading_day 헬퍼와 ERD 문서를 함께 갱신한다.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Your Name
2026-08-28 16:45:36 +09:00
parent bc2b1b642c
commit 2c915c9508
4 changed files with 456 additions and 63 deletions

View File

@@ -97,18 +97,25 @@ class TradeDBExt:
submitted_at VARCHAR(30) NOT NULL,
filled_at VARCHAR(30) DEFAULT NULL,
raw_json MEDIUMTEXT DEFAULT NULL,
is_mock TINYINT(1) NULL,
PRIMARY KEY (id),
UNIQUE KEY uq_ord_no_ctx (ord_no, strategy_id, code, side, ord_date),
UNIQUE KEY uq_ord_no_ctx (ord_no, strategy_id, code, side, ord_date, is_mock),
INDEX idx_strategy_date (strategy_id, ord_date),
INDEX idx_code_date (code, ord_date)
INDEX idx_code_date (code, ord_date),
INDEX idx_orders_mock_date (is_mock, ord_date)
) CHARACTER SET utf8mb4
""")
self._migrate_orders_drop_daily_side_unique()
self._migrate_orders_pk_scope()
self._migrate_orders_is_mock()
logger.info("📊 orders 테이블 확인/생성 완료")
except Exception as e:
logger.warning("orders 테이블 생성 실패(무시·폴백): %s", e)
def _resolve_is_mock(self, explicit=None) -> int:
"""모의=1 · 실전=0 — TradeDB.resolve_kis_is_mock 과 동일."""
return int(TradeDB.resolve_kis_is_mock(explicit))
def _migrate_orders_drop_daily_side_unique(self) -> None:
"""당일 1회 매수 UNIQUE 제거 — 재진입·삼성전자 누적매수 DB 미기록 방지."""
try:
@@ -162,6 +169,65 @@ class TradeDBExt:
except Exception as e:
logger.warning("orders PK 범위 마이그레이션 실패(무시·폴백): %s", e)
def _migrate_orders_is_mock(self) -> None:
"""orders.is_mock — 모의/실전 분리. UNIQUE 에 is_mock 포함(모의·실전 동시 허용)."""
try:
cols = self.conn.get_columns("orders")
if "is_mock" not in cols:
self.conn.execute(
"ALTER TABLE orders ADD COLUMN is_mock TINYINT(1) NULL"
)
logger.info("📌 orders.is_mock 컬럼 추가 (모의=1/실전=0)")
# 과거 이력은 모의 구간으로 1회 백필 (스냅샷 없음)
n_null = self.conn.execute(
"SELECT COUNT(*) AS c FROM orders WHERE is_mock IS NULL"
).fetchone()
n_left = int((n_null or {}).get("c") or 0)
if n_left > 0:
cur_mock = self._resolve_is_mock(None)
self.conn.execute(
"UPDATE orders SET is_mock=%s WHERE is_mock IS NULL",
(cur_mock,),
)
logger.info(
"📌 orders.is_mock 잔여 NULL→현재KIS_MOCK(%s) %d",
cur_mock, n_left,
)
# UNIQUE: (…, is_mock) — 모의 ODNO 와 실전 ODNO 가 같아도 공존
rows = self.conn.execute(
"SHOW INDEX FROM orders WHERE Key_name = %s",
("uq_ord_no_ctx",),
).fetchall()
idx_cols = []
for r in (rows or []):
col = r["Column_name"] if isinstance(r, dict) else r[4]
seq = int(r["Seq_in_index"] if isinstance(r, dict) else r[3])
idx_cols.append((seq, str(col)))
idx_cols = [c for _, c in sorted(idx_cols)]
want = ["ord_no", "strategy_id", "code", "side", "ord_date", "is_mock"]
if idx_cols != want:
if rows:
self.conn.execute("ALTER TABLE orders DROP INDEX uq_ord_no_ctx")
self.conn.execute(
"ALTER TABLE orders ADD UNIQUE INDEX uq_ord_no_ctx "
"(ord_no, strategy_id, code, side, ord_date, is_mock)"
)
logger.info(
"✅ orders.uq_ord_no_ctx → (ord_no,strategy_id,code,side,ord_date,is_mock)"
)
# 조회용 인덱스
mock_idx = self.conn.execute(
"SHOW INDEX FROM orders WHERE Key_name = %s",
("idx_orders_mock_date",),
).fetchall()
if not mock_idx:
self.conn.execute(
"ALTER TABLE orders ADD INDEX idx_orders_mock_date (is_mock, ord_date)"
)
logger.info("✅ orders.idx_orders_mock_date 추가")
except Exception as e:
logger.warning("orders.is_mock 마이그레이션 실패(무시·폴백): %s", e)
# ------------------------------------------------------------------
# orders CRUD
# ------------------------------------------------------------------
@@ -179,25 +245,26 @@ class TradeDBExt:
msg_cd: Optional[str] = None,
msg1: Optional[str] = None,
raw_json: Optional[str] = None,
is_mock=None,
) -> bool:
"""
주문 기록 INSERT.
중복 판정은 (ord_no, strategy_id, code, side, ord_date) 복합 UNIQUE
한정 — 다른 날짜·다른 전략·다른 종목이 같은 ODNO 를 받아도(모의투자
서버 ODNO 재사용 버그) 오탐 차단하지 않는다. 실패 시 False 반환.
중복 판정은 (ord_no, strategy_id, code, side, ord_date, is_mock) 복합 UNIQUE.
다른 날짜·전략·종목·모의/실전이 같은 ODNO 를 받아도 오탐 차단하지 않는다.
side: 'BUY' | 'SELL'
"""
side = (side or "").upper()
assert side in ("BUY", "SELL"), f"side must be BUY|SELL, got {side}"
now = datetime.datetime.now()
mock_flag = self._resolve_is_mock(is_mock)
try:
with self.conn:
self.conn.execute(
"""
INSERT INTO orders (
ord_no, strategy_id, code, name, side, qty, price,
status, msg_cd, msg1, ord_date, submitted_at, raw_json
) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
status, msg_cd, msg1, ord_date, submitted_at, raw_json, is_mock
) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
""",
(
ord_no, strategy_id, code, name, side, qty, price,
@@ -205,6 +272,7 @@ class TradeDBExt:
now.strftime("%Y-%m-%d"),
now.strftime("%Y-%m-%d %H:%M:%S"),
raw_json,
mock_flag,
),
)
return True
@@ -213,9 +281,9 @@ class TradeDBExt:
msg = str(e)
if "1062" in msg or "Duplicate entry" in msg:
logger.warning(
"⚠️ [주문중복차단] strategy=%s code=%s side=%s ord_no=%s "
"(같은 날짜·전략·종목·방향로 이미 DB 존재)",
strategy_id, code, side, ord_no,
"⚠️ [주문중복차단] strategy=%s code=%s side=%s ord_no=%s is_mock=%s "
"(같은 날짜·전략·종목·방향·계좌모드로 이미 DB 존재)",
strategy_id, code, side, ord_no, mock_flag,
)
return False
logger.error("insert_order 실패 (ord_no=%s): %s", ord_no, e)
@@ -240,6 +308,7 @@ class TradeDBExt:
now_dt = datetime.datetime.now()
now = now_dt.strftime("%Y-%m-%d %H:%M:%S")
od = ord_date or now_dt.strftime("%Y-%m-%d")
mock_flag = self._resolve_is_mock(None)
try:
with self.conn:
self.conn.execute(
@@ -247,9 +316,10 @@ class TradeDBExt:
UPDATE orders
SET filled_qty=%s, filled_avg_price=%s, status=%s, filled_at=%s
WHERE ord_no=%s AND strategy_id=%s AND code=%s AND ord_date=%s
AND is_mock=%s
""",
(filled_qty, filled_avg_price, status, now,
ord_no, strategy_id, code, od),
ord_no, strategy_id, code, od, mock_flag),
)
return True
except Exception as e:
@@ -266,16 +336,18 @@ class TradeDBExt:
msg1: str = "",
ord_date: Optional[str] = None,
) -> None:
"""주문 실패/거부 시 상태 REJECTED 처리 (strategy_id·code·ord_date 로 행 한정)."""
"""주문 실패/거부 시 상태 REJECTED 처리 (strategy_id·code·ord_date·is_mock 로 행 한정)."""
od = ord_date or datetime.datetime.now().strftime("%Y-%m-%d")
mock_flag = self._resolve_is_mock(None)
try:
with self.conn:
self.conn.execute(
"""
UPDATE orders SET status='REJECTED', msg_cd=%s, msg1=%s
WHERE ord_no=%s AND strategy_id=%s AND code=%s AND ord_date=%s
AND is_mock=%s
""",
(msg_cd, msg1, ord_no, strategy_id, code, od),
(msg_cd, msg1, ord_no, strategy_id, code, od, mock_flag),
)
except Exception as e:
logger.debug("mark_order_rejected 실패 (%s): %s", ord_no, e)
@@ -289,14 +361,16 @@ class TradeDBExt:
status: str,
ord_date: Optional[str] = None,
) -> bool:
"""체결 대기 등 — filled_qty 없이 status 만 갱신 (strategy_id·code·ord_date 로 행 한정)."""
"""체결 대기 등 — filled_qty 없이 status 만 갱신 (strategy_id·code·ord_date·is_mock 로 행 한정)."""
od = ord_date or datetime.datetime.now().strftime("%Y-%m-%d")
mock_flag = self._resolve_is_mock(None)
try:
with self.conn:
self.conn.execute(
"UPDATE orders SET status=%s "
"WHERE ord_no=%s AND strategy_id=%s AND code=%s AND ord_date=%s",
(status, ord_no, strategy_id, code, od),
"WHERE ord_no=%s AND strategy_id=%s AND code=%s AND ord_date=%s "
"AND is_mock=%s",
(status, ord_no, strategy_id, code, od, mock_flag),
)
return True
except Exception as e:
@@ -309,16 +383,18 @@ class TradeDBExt:
heartbeat ``poll_pending_fills`` 재조회용.
"""
today = datetime.datetime.now().strftime("%Y-%m-%d")
mock_flag = self._resolve_is_mock(None)
try:
rows = self.conn.execute(
"""
SELECT * FROM orders
WHERE ord_date=%s
AND is_mock=%s
AND status IN ('SUBMITTED', 'PENDING_FILL', 'PARTIAL')
AND COALESCE(filled_qty, 0) < qty
ORDER BY submitted_at ASC
""",
(today,),
(today, mock_flag),
).fetchall()
return [dict(r) for r in rows]
except Exception as e:
@@ -330,11 +406,13 @@ class TradeDBExt:
) -> Optional[Dict]:
"""동일 전략·종목 미체결 매도 1건 (중복 주문 방지용)."""
today = datetime.datetime.now().strftime("%Y-%m-%d")
mock_flag = self._resolve_is_mock(None)
try:
row = self.conn.execute(
"""
SELECT * FROM orders
WHERE ord_date=%s
AND is_mock=%s
AND strategy_id=%s
AND code=%s
AND side='SELL'
@@ -343,7 +421,7 @@ class TradeDBExt:
ORDER BY submitted_at DESC
LIMIT 1
""",
(today, strategy_id, code),
(today, mock_flag, strategy_id, code),
).fetchone()
return dict(row) if row else None
except Exception as e:
@@ -360,11 +438,13 @@ class TradeDBExt:
만료 시 poll_pending_fills 가 status 를 CANCELLED 로 바꿔 자동 해제된다.
"""
today = datetime.datetime.now().strftime("%Y-%m-%d")
mock_flag = self._resolve_is_mock(None)
try:
row = self.conn.execute(
"""
SELECT * FROM orders
WHERE ord_date=%s
AND is_mock=%s
AND strategy_id=%s
AND code=%s
AND side='BUY'
@@ -373,7 +453,7 @@ class TradeDBExt:
ORDER BY submitted_at DESC
LIMIT 1
""",
(today, strategy_id, code),
(today, mock_flag, strategy_id, code),
).fetchone()
return dict(row) if row else None
except Exception as e:
@@ -389,19 +469,21 @@ class TradeDBExt:
) -> Optional[Dict]:
"""ODNO 로 주문 조회. ODNO 는 더 이상 전역 유일하지 않으므로
(모의투자 ODNO 재사용 버그) strategy_id·code 를 함께 주면 정확히
그 행만, 안 주면 최신 순 1건을 반환한다."""
그 행만, 안 주면 최신 순 1건을 반환한다. 현재 KIS_MOCK 우선."""
mock_flag = self._resolve_is_mock(None)
try:
if strategy_id and code:
row = self.conn.execute(
"SELECT * FROM orders WHERE ord_no=%s AND strategy_id=%s AND code=%s "
"AND is_mock=%s "
"ORDER BY submitted_at DESC LIMIT 1",
(ord_no, strategy_id, code),
(ord_no, strategy_id, code, mock_flag),
).fetchone()
else:
row = self.conn.execute(
"SELECT * FROM orders WHERE ord_no=%s "
"SELECT * FROM orders WHERE ord_no=%s AND is_mock=%s "
"ORDER BY submitted_at DESC LIMIT 1",
(ord_no,),
(ord_no, mock_flag),
).fetchone()
return dict(row) if row else None
except Exception as e:
@@ -412,24 +494,25 @@ class TradeDBExt:
self, strategy_id: Optional[str] = None
) -> List[Dict]:
today = datetime.datetime.now().strftime("%Y-%m-%d")
mock_flag = self._resolve_is_mock(None)
try:
if strategy_id:
rows = self.conn.execute(
"""
SELECT * FROM orders
WHERE ord_date=%s AND strategy_id=%s
WHERE ord_date=%s AND strategy_id=%s AND is_mock=%s
ORDER BY submitted_at DESC
""",
(today, strategy_id),
(today, strategy_id, mock_flag),
).fetchall()
else:
rows = self.conn.execute(
"""
SELECT * FROM orders
WHERE ord_date=%s
WHERE ord_date=%s AND is_mock=%s
ORDER BY submitted_at DESC
""",
(today,),
(today, mock_flag),
).fetchall()
return [dict(r) for r in rows]
except Exception as e:
@@ -498,6 +581,9 @@ class TradeDBExt:
"(strategy_id, event_time)"),
("idx_tch_slot_sid",
"(slot_key, strategy_id)"),
# resolve_stock_display_name · 조건 REAL 편입 시 code→name (206만+행 풀스캔 방지)
("idx_tch_code_id",
"(code, id)"),
):
try:
self.conn.execute(