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:
@@ -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(
|
||||
"""
|
||||
|
||||
Reference in New Issue
Block a user