#!/usr/bin/env python3 """kis_quant_db 전 테이블·전 컬럼 MariaDB COMMENT 채우기 (타입·값 변경 없음). 우선순위: 1) 기존 COMMENT 유지 2) live_config_schema label+hint (env/config 키) 3) 공통 컬럼명 사전 + 테이블 문맥 4) 키/컬럼명 휴리스틱 실행: cd /home/hoon/kis_bot && python3 scripts/sql/fill_all_db_column_comments.py --apply """ from __future__ import annotations import argparse import re import sys from pathlib import Path from typing import Dict, List, Tuple ROOT = Path(__file__).resolve().parents[2] sys.path.insert(0, str(ROOT)) # 공통 컬럼명 → 설명 (테이블 무관) _COL_EXACT: Dict[str, str] = { "id": "PK · AUTO_INCREMENT", "code": "종목코드 (KR 6자리 / US 티커)", "name": "종목명", "strategy": "전략 ID (SHORT/MOMENTUM/BREAKOUT/US_MOMENTUM 등)", "strategy_id": "전략 ID", "avg_buy_price": "평균 매수가", "current_price": "최근 평가/현재가 스냅샷", "stop_price": "손절가", "target_price": "목표가", "max_price": "보유 중 고점(트레일·어깨컷용)", "atr_entry": "진입 시점 ATR", "target_qty": "목표 수량", "current_qty": "현재 보유 수량", "total_invested": "총 투자금액", "status": "상태", "buy_date": "매수 시각/일자", "sell_date": "매도 시각/일자", "updated_at": "마지막 갱신 시각", "created_at": "생성 시각", "size_class": "사이즈 등급(소/중/대 등)", "rsi": "진입/기록 시점 RSI", "volume_ratio": "거래량 배수(기록 스냅샷)", "tail_length_pct": "꼬리 길이%(기록 스냅샷)", "ma5_gap_pct": "MA5 괴리%(기록 스냅샷)", "ma20_gap_pct": "MA20 괴리%(기록 스냅샷)", "foreign_net_buy": "외국인 순매수(기록 스냅샷)", "institution_net_buy": "기관 순매수(기록 스냅샷)", "market_hour": "장중 시각 메타", "buy_price": "매수가", "sell_price": "매도가", "qty": "수량(주)", "profit_rate": "수익률(비율)", "realized_pnl": "실현손익", "hold_minutes": "보유 분", "sell_reason": "매도 사유", "env_snapshot": "체결 시점 env/파라미터 JSON 스냅샷", "ord_no": "증권사 주문번호", "side": "매수/매도 구분", "price": "주문/체결 가격", "filled_qty": "체결 수량", "filled_avg_price": "체결 평균가", "msg_cd": "응답 코드", "msg1": "응답 메시지", "ord_date": "주문일", "submitted_at": "주문 제출 시각", "filled_at": "체결 시각", "raw_json": "원본 응답 JSON", "market": "시장 구분 (KR|US 등)", "exchange": "거래소 (NASD/NYSE/AMEX/KRX 등)", "symbol": "심볼/티커", "tick_time": "틱 시각 YYYYMMDDHHMMSS", "candle_time": "봉 시각 YYYYMMDDHHMM", "snap_time": "스냅샷 시각 YYYYMMDDHHMMSS", "volume": "거래량(체결량/봉거래량)", "tick_seq": "틱 일련번호", "session": "세션 태그", "currency": "통화 (KRW|USD)", "source": "데이터 소스 (kis/kiwoom/ls 등)", "recv_ts": "수신 시각", "timeframe": "분봉 주기(분)", "tf_min": "분봉 주기(분)", "open": "시가", "high": "고가", "low": "저가", "close": "종가", "is_confirmed": "봉 확정 여부 (1=확정)", "holding_peak": "보유 중 트레일 고점(WS 틱 max_price 스냅샷)", "best_bid": "최우선 매수호가", "best_ask": "최우선 매도호가", "total_bid_qty": "총 매수잔량", "total_ask_qty": "총 매도잔량", "bid_qty_l3": "매수 1~3호가 잔량합", "ask_qty_l3": "매도 1~3호가 잔량합", "levels_json": "호가 단계 JSON", "enabled": "활성 여부 (1=ON)", "note": "메모", "market_type": "시장유형 (KR|US)", "k": "키", "v": "값", "env_key": "설정 키", "env_value": "설정 값", "score": "점수/랭크", "scan_time": "스캔 시각", "slot_key": "슬롯/히스토리 키", "sector": "섹터", "theme": "테마", } _TABLE_SCOPE: Dict[str, str] = { "active_trades": "실보유", "active_trades_paper": "페이퍼보유", "orders": "실주문", "orders_paper": "페이퍼주문", "trade_history": "실청산이력", "trade_history_paper": "페이퍼청산이력", "ws_ticks": "국장틱", "ws_ticks_us": "해외틱", "ws_candles": "분봉", "ws_orderbook": "호가스냅", "ws_program": "프로그램매매", "ls_ws_ticks": "LS틱", "ls_ws_candles": "LS분봉", "ls_ws_orderbook": "LS호가", "ls_ws_vi": "LS VI", "permanent_subscriptions": "영구구독", "target_candidates": "현재후보", "target_candidates_history": "후보이력", "ls_candidates_history": "LS후보이력", "ls_universe_history": "LS유니버스이력", } _TOKEN_KO: List[Tuple[str, str]] = [ ("STRATEGY", "전략"), ("STOP_LOSS", "손절"), ("TAKE_PROFIT", "익절"), ("TRAIL", "트레일"), ("SHOULDER", "어깨"), ("RATCHET", "래칫"), ("ORDERBOOK", "호가"), ("PROGRAM", "프로그램"), ("WHIPSAW", "휩쏘"), ("ENABLED", "ON/OFF"), ("HOLDING_PEAK", "보유고점"), ("IS_CONFIRMED", "봉확정"), ("CANDLE_TIME", "봉시각"), ("TICK_TIME", "틱시각"), ("SNAP_TIME", "스냅시각"), ("BEST_BID", "최우선매수"), ("BEST_ASK", "최우선매도"), ("TOTAL_BID", "총매수잔량"), ("TOTAL_ASK", "총매도잔량"), ("LEVELS_JSON", "호가단계JSON"), ("RAW_JSON", "원본JSON"), ("FILLED_AVG", "체결평균"), ("FILLED_QTY", "체결수량"), ("FILLED_AT", "체결시각"), ("SUBMITTED", "제출"), ("REALIZED_PNL", "실현손익"), ("PROFIT_RATE", "수익률"), ("HOLD_MINUTES", "보유분"), ("SELL_REASON", "매도사유"), ("ENV_SNAPSHOT", "env스냅샷"), ("AVG_BUY", "평균매수"), ("CURRENT_QTY", "현재수량"), ("TARGET_QTY", "목표수량"), ("CURRENT_PRICE", "현재가"), ("STOP_PRICE", "손절가"), ("TARGET_PRICE", "목표가"), ("MAX_PRICE", "고점"), ("MARKET_TYPE", "시장유형"), ("TIMEFRAME", "분봉주기"), ("TF_MIN", "분봉분"), ("RECV_TS", "수신시각"), ("TICK_SEQ", "틱일련"), ("CURRENCY", "통화"), ("EXCHANGE", "거래소"), ("SESSION", "세션"), ("VOLUME", "거래량"), ("SOURCE", "소스"), ("STATUS", "상태"), ("SIDE", "매매방향"), ("PRICE", "가격"), ("QTY", "수량"), ("CODE", "종목코드"), ("NAME", "종목명"), ("SYMBOL", "심볼"), ("MARKET", "시장"), ("RSI", "RSI"), ("EMA", "EMA"), ("ATR", "ATR"), ("PCT", "%"), ("SEC", "초"), ("MS", "ms"), ("JSON", "JSON"), ] def _esc_comment(s: str) -> str: s = re.sub(r"\s+", " ", (s or "").strip()) s = s.replace("\\", "\\\\").replace("'", "''") s = s.replace("%", "%%") if len(s) > 240: s = s[:237] + "..." return s def _heuristic(col: str, table: str) -> str: if col in _COL_EXACT: base = _COL_EXACT[col] scope = _TABLE_SCOPE.get(table) if scope and scope not in base: return f"{scope}: {base}" return base # rsi_14 / ema_9 style m = re.match(r"^(rsi|ema)_(\d+)$", col, re.I) if m: return f"{m.group(1).upper()}({m.group(2)}) materialized" k = col.upper() parts: List[str] = [] rest = k while rest: matched = False for tok, ko in _TOKEN_KO: if rest.startswith(tok): parts.append(ko) rest = rest[len(tok):] if rest.startswith("_"): rest = rest[1:] matched = True break if not matched: if "_" in rest: chunk, rest = rest.split("_", 1) else: chunk, rest = rest, "" parts.append(chunk) body = " ".join(parts) if parts else col scope = _TABLE_SCOPE.get(table, table) return f"{scope}: {body} ({col})" def _load_live_labels() -> Dict[str, str]: out: Dict[str, str] = {} try: from kis_trader.web.live_config_schema import build_live_config_groups except Exception: return out for g in build_live_config_groups(): for f in g.get("fields") or []: k = f.get("key") if not k: continue lab = str(f.get("label") or "").strip() hint = str(f.get("hint") or "").strip() bits = [b for b in (lab, hint) if b] if bits: out[str(k)] = " · ".join(bits) return out def _describe(col: str, table: str, labels: Dict[str, str], existing: str) -> str: if (existing or "").strip(): return existing.strip() if col in labels: return labels[col] return _heuristic(col, table) def _col_type_clause(row: dict) -> str: typ = row["Type"] null = "NULL" if row["Null"] == "YES" else "NOT NULL" default = row["Default"] extra = row.get("Extra") or "" parts = [typ, null] if default is None: if row["Null"] == "YES": parts.append("DEFAULT NULL") else: draw = str(default).strip() dnorm = draw.upper().replace(" ", "") # MariaDB SHOW 가 current_timestamp() 문자열로 주는 경우 if dnorm in ( "CURRENT_TIMESTAMP", "CURRENT_TIMESTAMP()", "NOW()", "CURRENT_TIMESTAMPONUPDATECURRENT_TIMESTAMP", ) or dnorm.startswith("CURRENT_TIMESTAMP"): # ON UPDATE 는 Extra 에 있을 수 있음 parts.append("DEFAULT CURRENT_TIMESTAMP") if "on update" in extra.lower(): parts.append("ON UPDATE CURRENT_TIMESTAMP") elif re.match( r"^(tinyint|smallint|mediumint|int|bigint|decimal|float|double|bit)", typ, re.I, ): parts.append(f"DEFAULT {draw}") else: d = draw.replace("\\", "\\\\").replace("'", "''") parts.append(f"DEFAULT '{d}'") if "auto_increment" in extra.lower(): parts.append("AUTO_INCREMENT") return " ".join(parts) def _list_tables(db) -> List[str]: rows = db.conn.execute( "SELECT TABLE_NAME AS t FROM information_schema.TABLES " "WHERE TABLE_SCHEMA=DATABASE() AND TABLE_TYPE='BASE TABLE' " "ORDER BY TABLE_NAME" ).fetchall() return [r["t"] for r in rows] def _sync_erd(db, tables: List[str]) -> int: path = ROOT / "docs/like_mcp.md/db_erd.md" if not path.exists(): return 0 comments = {} for t in tables: for r in db.conn.execute(f"SHOW FULL COLUMNS FROM `{t}`").fetchall(): comments[(t, r["Field"])] = (r.get("Comment") or "").strip() text = path.read_text(encoding="utf-8") current = None out = [] row_re = re.compile( r"^\| `([^`]+)`( 🔑)? \| `([^`]+)` \| ([^|]*) \| ([^|]*) \| (.*) \|$" ) n_upd = 0 for line in text.splitlines(): m = re.match(r"^#### `([^`]+)`\s*$", line) if m: current = m.group(1) out.append(line) continue m = row_re.match(line) if m and current: col = m.group(1) key = (current, col) if key in comments and comments[key]: cmt = comments[key].replace("|", "/") keymark = m.group(2) or "" typ = m.group(3) nul = m.group(4).strip() dflt = m.group(5).strip() newline = f"| `{col}`{keymark} | `{typ}` | {nul} | {dflt} | {cmt} |" if newline != line: n_upd += 1 out.append(newline) continue out.append(line) path.write_text("\n".join(out) + "\n", encoding="utf-8") return n_upd def main() -> int: ap = argparse.ArgumentParser() ap.add_argument("--apply", action="store_true", default=True) ap.add_argument("--sql-only", action="store_true") ap.add_argument( "--out", default=str(ROOT / "scripts/sql/all_db_column_comments.generated.sql"), ) ap.add_argument("--no-erd", action="store_true") args = ap.parse_args() if args.sql_only: args.apply = False from database import TradeDB db = TradeDB() labels = _load_live_labels() tables = _list_tables(db) print(f"tables={len(tables)} live_labels={len(labels)}") sql_chunks: List[str] = [ "-- AUTO-GENERATED: 전 DB 테이블 컬럼 COMMENT (타입·값 불변)", "-- python3 scripts/sql/fill_all_db_column_comments.py --apply", "", ] filled = kept = 0 for table in tables: rows = db.conn.execute(f"SHOW FULL COLUMNS FROM `{table}`").fetchall() mods = [] for r in rows: field = r["Field"] existing = (r.get("Comment") or "").strip() desc = _describe(field, table, labels, existing) if existing: kept += 1 else: filled += 1 type_clause = _col_type_clause(r) cmt = _esc_comment(desc) mods.append( f" MODIFY COLUMN `{field}`\n {type_clause}\n COMMENT '{cmt}'" ) if args.apply and existing != desc: stmt = ( f"ALTER TABLE `{table}` MODIFY COLUMN `{field}` " f"{type_clause} COMMENT '{cmt}'" ) db.conn.execute(stmt) sql_chunks.append(f"-- ── {table} ({len(rows)} cols) ──") sql_chunks.append(f"ALTER TABLE `{table}`") sql_chunks.append(",\n".join(mods) + ";\n") print(f"{'applied' if args.apply else 'planned'} {table} ({len(rows)})") out = Path(args.out) out.write_text("\n".join(sql_chunks) + "\n", encoding="utf-8") print(f"wrote {out} ({out.stat().st_size} bytes)") print(f"kept_existing={kept} newly_filled={filled}") if args.apply: empty = 0 for table in tables: for r in db.conn.execute(f"SHOW FULL COLUMNS FROM `{table}`").fetchall(): if not (r.get("Comment") or "").strip(): empty += 1 print("EMPTY", table, r["Field"]) print("remaining empty:", empty) if not args.no_erd: n = _sync_erd(db, tables) print(f"db_erd.md rows updated: {n}") return 0 if __name__ == "__main__": raise SystemExit(main())