feat: 새로운 안전 규칙 및 최적화 적용을 통한 트레이딩 시스템 개선

변경 사항 (Changes):

구문 오류(Syntax error) 및 토큰 낭비를 방지하기 위해 에이전트 쉘(Agent shell)과 파이썬 코드 스니펫에 다수의 신규 안전 규칙(Safety rules)을 추가함.

스키마 검증 및 적절한 SQL 포맷팅을 보장하기 위해 임시(Ad-hoc) 데이터베이스 쿼리 작성 가이드라인을 도입함.

코드 수정 후 UI 기능이 정상 작동하는지 확인하기 위해, 백테스트 웹 서비스 재시작 및 브라우저 검증에 대한 새로운 규칙을 구현함.

시스템 전반의 무결성(Integrity)을 유지하기 위해 실전 매매(Live trading), 웹 백테스팅, 파라미터 탐색(Parameter searches) 간의 일관성 검사(Consistency checks) 체계를 확립함.

기대 효과 (Impact):

이러한 개선 사항들은 트레이딩 시스템의 견고성(Robustness)과 신뢰성을 향상시키며, 에러 발생을 최소화하고 다양한 시스템 컴포넌트 간의 원활한 상호작용을 보장함.
This commit is contained in:
Your Name
2026-07-17 01:09:09 +09:00
parent a4626e0351
commit fc27e726f9
151 changed files with 20718 additions and 6450 deletions

View File

@@ -65,14 +65,23 @@ class TradeDBExt:
submitted_at : 주문 접수 시각
filled_at : 체결 확인 시각
중복 차단은 ord_no(PK) 만 사용.
PK: 서로게이트 id(auto_increment). 중복 차단은
(ord_no, strategy_id, code, side, ord_date) 복합 UNIQUE 로 한정한다.
(구) UNIQUE(strategy,code,side,ord_date) 는 당일 재매수·부분체결 재주문 시
HTS 에는 체결됐는데 orders/active_trades 미기록 버그 유발 → 제거.
※ ord_no 단독 PK 였던 과거엔 "다른 영업일·다른 전략" 주문이 같은 ODNO 를
받아도(모의투자 서버가 과거 이미 쓴 ODNO 를 재발급하는 사례 확인됨) DB가
전역 중복으로 오판 → insert 실패 → 그 매수 시도를 통째로 포기해
active_trades 미기록(장마감 고아복구 전까지 손절 무방비) 사고가 발생했다.
복합 UNIQUE 로 좁혀 "진짜 같은 날·같은 전략·같은 종목·같은 방향의 재전송"
차단하고, 그 외 ODNO 재사용은 정상 신규 주문으로 기록되게 한다.
"""
try:
self.conn.execute("""
CREATE TABLE IF NOT EXISTS orders (
ord_no VARCHAR(30) NOT NULL PRIMARY KEY,
id BIGINT NOT NULL AUTO_INCREMENT,
ord_no VARCHAR(30) NOT NULL,
strategy_id VARCHAR(40) NOT NULL,
code VARCHAR(20) NOT NULL,
name VARCHAR(100) NOT NULL DEFAULT '',
@@ -88,11 +97,14 @@ class TradeDBExt:
submitted_at VARCHAR(30) NOT NULL,
filled_at VARCHAR(30) DEFAULT NULL,
raw_json MEDIUMTEXT DEFAULT NULL,
PRIMARY KEY (id),
UNIQUE KEY uq_ord_no_ctx (ord_no, strategy_id, code, side, ord_date),
INDEX idx_strategy_date (strategy_id, ord_date),
INDEX idx_code_date (code, ord_date)
) CHARACTER SET utf8mb4
""")
self._migrate_orders_drop_daily_side_unique()
self._migrate_orders_pk_scope()
logger.info("📊 orders 테이블 확인/생성 완료")
except Exception as e:
logger.warning("orders 테이블 생성 실패(무시·폴백): %s", e)
@@ -115,6 +127,41 @@ class TradeDBExt:
except Exception as e:
logger.debug("orders UNIQUE 마이그레이션 스킵: %s", e)
def _migrate_orders_pk_scope(self) -> None:
"""기존(구버전) 테이블: ord_no 단독 PK → surrogate id PK + 복합 UNIQUE 로 전환.
모의투자 서버의 ODNO 재사용 버그로 다른 날짜/전략 주문이 같은 ODNO 를
받으면 종전 PK(ord_no 단독)에서는 무조건 "중복"으로 차단됐다. 이미
운영 중인 DB(구 스키마)를 새 스키마로 안전 전환한다.
"""
try:
cols = self.conn.get_columns("orders")
if "id" not in cols:
self.conn.execute(
"ALTER TABLE orders "
"ADD COLUMN id BIGINT NOT NULL AUTO_INCREMENT FIRST, "
"DROP PRIMARY KEY, "
"ADD PRIMARY KEY (id)"
)
logger.info(
"✅ orders.id(surrogate PK) 추가 — ord_no 단독 PK 제거"
)
rows = self.conn.execute(
"SHOW INDEX FROM orders WHERE Key_name = %s",
("uq_ord_no_ctx",),
).fetchall()
if not rows:
self.conn.execute(
"ALTER TABLE orders ADD UNIQUE INDEX uq_ord_no_ctx "
"(ord_no, strategy_id, code, side, ord_date)"
)
logger.info(
"✅ orders.uq_ord_no_ctx 추가 — 중복 판정을 "
"(ord_no, strategy_id, code, side, ord_date) 로 한정"
)
except Exception as e:
logger.warning("orders PK 범위 마이그레이션 실패(무시·폴백): %s", e)
# ------------------------------------------------------------------
# orders CRUD
# ------------------------------------------------------------------
@@ -134,7 +181,10 @@ class TradeDBExt:
raw_json: Optional[str] = None,
) -> bool:
"""
주문 기록 INSERT. PK(ord_no) 중복이면 False 반환 (서버단 차단).
주문 기록 INSERT.
중복 판정은 (ord_no, strategy_id, code, side, ord_date) 복합 UNIQUE 로
한정 — 다른 날짜·다른 전략·다른 종목이 같은 ODNO 를 받아도(모의투자
서버 ODNO 재사용 버그) 오탐 차단하지 않는다. 실패 시 False 반환.
side: 'BUY' | 'SELL'
"""
side = (side or "").upper()
@@ -163,7 +213,8 @@ 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=%s code=%s side=%s ord_no=%s "
"(같은 날짜·전략·종목·방향으로 이미 DB 존재)",
strategy_id, code, side, ord_no,
)
return False
@@ -174,21 +225,31 @@ class TradeDBExt:
self,
*,
ord_no: str,
strategy_id: str,
code: str,
filled_qty: int,
filled_avg_price: float,
status: str = "FILLED",
ord_date: Optional[str] = None,
) -> bool:
"""주문 체결 확인 후 체결가/체결수량/상태 갱신."""
now = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
"""주문 체결 확인 후 체결가/체결수량/상태 갱신.
strategy_id·code·ord_date 로 정확히 그 행만 갱신 — ODNO 재사용 시
다른 날짜/전략의 동일 ODNO 행을 잘못 덮어쓰는 사고 방지.
"""
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")
try:
with self.conn:
self.conn.execute(
"""
UPDATE orders
SET filled_qty=%s, filled_avg_price=%s, status=%s, filled_at=%s
WHERE ord_no=%s
WHERE ord_no=%s AND strategy_id=%s AND code=%s AND ord_date=%s
""",
(filled_qty, filled_avg_price, status, now, ord_no),
(filled_qty, filled_avg_price, status, now,
ord_no, strategy_id, code, od),
)
return True
except Exception as e:
@@ -196,28 +257,46 @@ class TradeDBExt:
return False
def mark_order_rejected(
self, *, ord_no: str, msg_cd: str = "", msg1: str = ""
self,
*,
ord_no: str,
strategy_id: str,
code: str,
msg_cd: str = "",
msg1: str = "",
ord_date: Optional[str] = None,
) -> None:
"""주문 실패/거부 시 상태 REJECTED 처리."""
"""주문 실패/거부 시 상태 REJECTED 처리 (strategy_id·code·ord_date 로 행 한정)."""
od = ord_date or datetime.datetime.now().strftime("%Y-%m-%d")
try:
with self.conn:
self.conn.execute(
"""
UPDATE orders SET status='REJECTED', msg_cd=%s, msg1=%s
WHERE ord_no=%s
WHERE ord_no=%s AND strategy_id=%s AND code=%s AND ord_date=%s
""",
(msg_cd, msg1, ord_no),
(msg_cd, msg1, ord_no, strategy_id, code, od),
)
except Exception as e:
logger.debug("mark_order_rejected 실패 (%s): %s", ord_no, e)
def update_order_status(self, *, ord_no: str, status: str) -> bool:
"""체결 대기 등 — filled_qty 없이 status 만 갱신."""
def update_order_status(
self,
*,
ord_no: str,
strategy_id: str,
code: str,
status: str,
ord_date: Optional[str] = None,
) -> bool:
"""체결 대기 등 — filled_qty 없이 status 만 갱신 (strategy_id·code·ord_date 로 행 한정)."""
od = ord_date or datetime.datetime.now().strftime("%Y-%m-%d")
try:
with self.conn:
self.conn.execute(
"UPDATE orders SET status=%s WHERE ord_no=%s",
(status, ord_no),
"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),
)
return True
except Exception as e:
@@ -301,11 +380,29 @@ class TradeDBExt:
logger.error("get_pending_buy_order 실패 (%s/%s): %s", strategy_id, code, e)
return None
def get_order_by_odno(self, ord_no: str) -> Optional[Dict]:
def get_order_by_odno(
self,
ord_no: str,
*,
strategy_id: Optional[str] = None,
code: Optional[str] = None,
) -> Optional[Dict]:
"""ODNO 로 주문 조회. ODNO 는 더 이상 전역 유일하지 않으므로
(모의투자 ODNO 재사용 버그) strategy_id·code 를 함께 주면 정확히
그 행만, 안 주면 최신 순 1건을 반환한다."""
try:
row = self.conn.execute(
"SELECT * FROM orders WHERE ord_no=%s", (ord_no,)
).fetchone()
if strategy_id and code:
row = self.conn.execute(
"SELECT * FROM orders WHERE ord_no=%s AND strategy_id=%s AND code=%s "
"ORDER BY submitted_at DESC LIMIT 1",
(ord_no, strategy_id, code),
).fetchone()
else:
row = self.conn.execute(
"SELECT * FROM orders WHERE ord_no=%s "
"ORDER BY submitted_at DESC LIMIT 1",
(ord_no,),
).fetchone()
return dict(row) if row else None
except Exception as e:
logger.error("get_order_by_odno 실패: %s", e)
@@ -437,7 +534,16 @@ class TradeDBExt:
code = (it.get("code") or "").strip()
if not code:
continue
name = (it.get("name") or code)[:100]
raw_name = (it.get("name") or code)[:100]
name = raw_name
if not name or name == code:
try:
from kis_trader.utils.stock_name import resolve_stock_display_name
name = resolve_stock_display_name(
self, code, fallback=code, cache_to_meta=True,
)[:100]
except Exception:
name = code
try:
self.conn.execute(
"""