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

@@ -9,14 +9,16 @@ from __future__ import annotations
import logging
import os
import time
from typing import Any, Dict, Optional
logger = logging.getLogger("kis_trader.env")
# 순환 import 방지용 레이지 TradeDB 참조
_db_instance = None
# get_merged_env_snapshot() — 프로세스당 1회 (백테·파라서치 env 키 반복 조회 가속)
# get_merged_env_snapshot() — TTL 내 재사용 (실매: 웹에서 MAX_DAILY 등 변경 즉시 반영)
_merged_env_cache: Optional[Dict[str, str]] = None
_merged_env_cache_ts: float = 0.0
# env 캐시 세대 카운터 — invalidate 시 +1. 파생 캐시(예: whipsaw 파라미터)가
# 이 값으로 무효화를 감지해 안전하게 재계산한다(값은 그대로, 재계산 시점만 동일).
_env_generation: int = 0
@@ -43,11 +45,20 @@ def set_db(db_obj) -> None:
def invalidate_merged_env_cache() -> None:
"""env/config 저장 후 스냅샷 캐시 무효화 (insert_env_snapshot 등)."""
global _merged_env_cache, _env_generation
global _merged_env_cache, _merged_env_cache_ts, _env_generation
_merged_env_cache = None
_merged_env_cache_ts = 0.0
_env_generation += 1
def _merged_env_cache_ttl_sec() -> float:
"""DB env 스냅샷 메모리 TTL(초). os.environ 만 사용(순환 import 방지)."""
try:
return max(0.0, float(os.environ.get("ENV_MERGED_CACHE_TTL_SEC", "60")))
except (ValueError, TypeError):
return 60.0
def env_cache_generation() -> int:
"""현재 env 캐시 세대. invalidate 될 때마다 증가.
@@ -66,13 +77,15 @@ def _strip_comment(val: Any) -> Any:
def get_merged_env_dict() -> dict:
"""env_config + config_scalp/short/momentum/… 최신 행 병합 flat dict."""
global _merged_env_cache
if _merged_env_cache is not None:
global _merged_env_cache, _merged_env_cache_ts
ttl = _merged_env_cache_ttl_sec()
now = time.time()
if _merged_env_cache is not None and (ttl <= 0 or (now - _merged_env_cache_ts) < ttl):
return _merged_env_cache
db = _get_db()
if db is None:
return {}
return _merged_env_cache or {}
try:
if hasattr(db, "get_merged_env_snapshot"):
merged = db.get_merged_env_snapshot()
@@ -80,10 +93,11 @@ def get_merged_env_dict() -> dict:
row = db.get_latest_env()
merged = dict((row or {}).get("snapshot") or {})
_merged_env_cache = dict(merged or {})
_merged_env_cache_ts = now
return _merged_env_cache
except Exception as e:
logger.debug("merged env 조회 실패: %s", e)
return {}
return _merged_env_cache or {}
def get_strategy_env_dict(strategy_id: str) -> dict:

View File

@@ -0,0 +1,232 @@
#!/usr/bin/env python3
"""
kis_trader/utils/kr_trading_day.py — 한국 거래일(주말·휴장) 보정 공통
====================================================================
백테 웹 / CLI / API 날짜 기본값: 주말·휴장이면 **이전 장운영일**로 맞춤.
휴장일 목록: env ``KR_MARKET_HOLIDAYS`` (콤마구분 YYYY-MM-DD).
DB에 없으면 기본 CSV를 env_config_ext 에 1회 시드.
"""
from __future__ import annotations
from datetime import date, datetime, timedelta
from typing import List, Optional, Set, Tuple, Union
from kis_trader.utils.env import get_env_from_db, get_env_int
DateLike = Union[str, date, datetime]
# 한국거래소 휴장(주말 제외). DB/ env 로 갱신. get_env 기본값용.
_DEFAULT_KR_MARKET_HOLIDAYS = (
# 2025
"2025-01-01,2025-01-28,2025-01-29,2025-01-30,"
"2025-03-01,2025-05-05,2025-05-06,2025-06-06,"
"2025-08-15,2025-10-03,2025-10-06,2025-10-07,2025-10-08,2025-10-09,2025-12-25,"
# 2026
"2026-01-01,2026-02-16,2026-02-17,2026-02-18,"
"2026-03-01,2026-05-05,2026-05-24,2026-06-06,"
"2026-08-15,2026-09-24,2026-09-25,2026-09-26,"
"2026-10-03,2026-10-09,2026-12-25,"
# 2027 (주요)
"2027-01-01,2027-02-08,2027-02-09,2027-02-10,"
"2027-03-01,2027-05-05,2027-05-13,2027-06-06,"
"2027-08-15,2027-09-14,2027-09-15,2027-09-16,"
"2027-10-03,2027-10-09,2027-12-25"
)
_holidays_cache: Optional[Set[date]] = None
_holidays_cache_raw: Optional[str] = None
_seeded: bool = False
def _parse_ymd(raw: DateLike) -> date:
if isinstance(raw, datetime):
return raw.date()
if isinstance(raw, date):
return raw
s = str(raw or "").strip()
if not s:
raise ValueError("empty date")
if "-" in s and len(s) >= 10:
return datetime.strptime(s[:10], "%Y-%m-%d").date()
digits = "".join(c for c in s if c.isdigit())
if len(digits) >= 8:
return datetime.strptime(digits[:8], "%Y%m%d").date()
raise ValueError(f"bad date: {raw!r}")
def ymd(d: date) -> str:
return d.strftime("%Y-%m-%d")
def parse_holiday_csv(raw: str) -> Set[date]:
out: Set[date] = set()
for part in str(raw or "").replace(";", ",").split(","):
p = part.strip()
if not p or p.startswith("#"):
continue
try:
out.add(_parse_ymd(p))
except ValueError:
continue
return out
def ensure_kr_market_holidays_in_db() -> None:
"""KR_MARKET_HOLIDAYS / lookback 기본값을 DB/ext 에 없으면 1회 시드 (기존 값 유지)."""
global _seeded
if _seeded:
return
_seeded = True
try:
from kis_trader.utils.env import _get_db # noqa: PLC2701
db = _get_db()
if not db:
return
now = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
def _seed_if_missing(key: str, value: str) -> None:
cur = str(get_env_from_db(key, "") or "").strip()
if cur:
return
row = db.conn.execute(
"SELECT env_value FROM env_config_ext WHERE env_key=%s LIMIT 1",
(key,),
).fetchone()
if row and str((row["env_value"] if isinstance(row, dict) else row[0]) or "").strip():
return
db.conn.execute(
"INSERT INTO env_config_ext (env_key, env_value, updated_at) "
"VALUES (%s, %s, %s) "
"ON DUPLICATE KEY UPDATE env_key=env_key",
(key, value, now),
)
_seed_if_missing("KR_MARKET_HOLIDAYS", _DEFAULT_KR_MARKET_HOLIDAYS)
_seed_if_missing("PARAM_SEARCH_DEFAULT_LOOKBACK_DAYS", "7")
_seed_if_missing("KR_TRADING_DAY_MAX_BACK_DAYS", "14")
try:
from kis_trader.utils.env import invalidate_merged_env_cache
invalidate_merged_env_cache()
except Exception:
pass
except Exception:
pass
def get_kr_market_holiday_set(*, refresh: bool = False) -> Set[date]:
"""휴장일 set. env/DB → 없으면 코드 기본 CSV."""
global _holidays_cache, _holidays_cache_raw
ensure_kr_market_holidays_in_db()
raw = str(
get_env_from_db("KR_MARKET_HOLIDAYS", _DEFAULT_KR_MARKET_HOLIDAYS) or ""
).strip()
if not raw:
raw = _DEFAULT_KR_MARKET_HOLIDAYS
if (
not refresh
and _holidays_cache is not None
and _holidays_cache_raw == raw
):
return _holidays_cache
_holidays_cache = parse_holiday_csv(raw)
_holidays_cache_raw = raw
return _holidays_cache
def is_kr_trading_day(d: DateLike, *, holidays: Optional[Set[date]] = None) -> bool:
"""월~금 이고 휴장 목록에 없으면 True."""
day = _parse_ymd(d)
if day.weekday() >= 5:
return False
hol = holidays if holidays is not None else get_kr_market_holiday_set()
return day not in hol
def clamp_to_prev_kr_trading_day(
d: DateLike,
*,
holidays: Optional[Set[date]] = None,
max_back_days: Optional[int] = None,
) -> str:
"""
거래일이 아니면 하루씩 과거로 이동해 **가장 가까운 이전(또는 당일) 장운영일** 반환.
"""
day = _parse_ymd(d)
hol = holidays if holidays is not None else get_kr_market_holiday_set()
limit = int(
max_back_days
if max_back_days is not None
else get_env_int("KR_TRADING_DAY_MAX_BACK_DAYS", 14)
)
limit = max(1, limit)
for _ in range(limit + 1):
if is_kr_trading_day(day, holidays=hol):
return ymd(day)
day -= timedelta(days=1)
return ymd(day)
def default_kr_trading_end(*, as_of: Optional[DateLike] = None) -> str:
"""종료일 기본값 = as_of(기본 오늘) 을 이전 거래일로 보정."""
base = _parse_ymd(as_of) if as_of is not None else date.today()
return clamp_to_prev_kr_trading_day(base)
def default_kr_trading_start(
lookback_days: int = 7,
*,
as_of: Optional[DateLike] = None,
end: Optional[DateLike] = None,
) -> str:
"""시작일 기본값 = (종료일 lookback) 을 이전 거래일로 보정."""
end_d = _parse_ymd(end) if end is not None else _parse_ymd(
default_kr_trading_end(as_of=as_of)
)
lb = max(1, int(lookback_days))
start_raw = end_d - timedelta(days=lb)
return clamp_to_prev_kr_trading_day(start_raw)
def resolve_kr_backtest_date_defaults(
lookback_days: int = 7,
*,
as_of: Optional[DateLike] = None,
) -> Tuple[str, str]:
"""(start, end) 기본 구간. end·start 모두 거래일."""
end = default_kr_trading_end(as_of=as_of)
start = default_kr_trading_start(lookback_days, end=end)
if start > end:
start = end
return start, end
def clamp_to_prev_weekday(d: DateLike, *, max_back_days: int = 14) -> str:
"""주말만 보정 (해외장 등 공휴일 목록 없이 토·일만 스킵)."""
day = _parse_ymd(d)
for _ in range(max(1, int(max_back_days)) + 1):
if day.weekday() < 5:
return ymd(day)
day -= timedelta(days=1)
return ymd(day)
def holiday_list_ymd() -> List[str]:
return sorted(ymd(d) for d in get_kr_market_holiday_set())
def trading_dates_payload(lookback_days: int = 7) -> dict:
"""웹/API용 날짜 기본값 묶음."""
start, end = resolve_kr_backtest_date_defaults(lookback_days)
today = ymd(date.today())
return {
"today": today,
"end": end,
"start": start,
"trading_day": end, # 단일일 조회용 (= 보정된 종료일)
"holidays": holiday_list_ymd(),
"lookback_days": int(lookback_days),
"is_today_trading_day": is_kr_trading_day(today),
}

View File

@@ -0,0 +1,142 @@
"""
비본주(우선주·ETF·ETN·스팩) 판별 — 후보 매수 차단 공용.
키움 조건검색은 종목명 없이 code 만 주는 경우가 많아(name==code),
이름 끝 '' 만으로는 우선주를 못 막는다. 코드 끝자리 규칙으로 보완한다.
KRX 단축코드 끝자리(종목구분):
- 보통주: 대개 '0'
- 우선주: '5','7','9' (구형) + 'K','L','M'… (2013년 이후 알파벳)
"""
from __future__ import annotations
from typing import FrozenSet
from .env import get_env_bool, get_env_from_db
# ETF/ETN 브랜드·유형 (이름 부분일치). '파워' 단독은 뉴파워프라즈마 오탐 → 넣지 않음.
_DEFAULT_NON_STOCK_NAME_KEYWORDS = (
"ETN",
"ETF",
"레버리지",
"인버스",
"2X",
"3X",
"선물",
"KODEX",
"TIGER",
"KBSTAR",
"ARIRANG",
"HANARO",
"SOL ",
"KOSEF",
"ACE ",
"KINDEX",
"RISE ",
"PLUS ",
"TIMEFOLIO",
"히어로",
"SMART",
"TREX",
"WON",
"KOACT",
)
def _pref_digit_suffixes() -> FrozenSet[str]:
"""우선주 숫자 끝자리 — NON_STOCK_PREF_CODE_SUFFIXES (기본 5,7,9)."""
raw = get_env_from_db("NON_STOCK_PREF_CODE_SUFFIXES", "5,7,9") or "5,7,9"
return frozenset(s.strip().upper() for s in str(raw).split(",") if s.strip())
def is_preferred_code(code: str) -> bool:
"""종목코드만으로 우선주 여부 (name 없어도 판단)."""
c = (code or "").strip()
if len(c) < 1:
return False
last = c[-1].upper()
if last in _pref_digit_suffixes():
return True
# K,L,M… 알파벳 끝 = 신형 우선주 코드
if get_env_bool("NON_STOCK_EXCLUDE_ALPHA_SUFFIX", True) and last.isalpha():
return True
return False
def is_etn_code(code: str) -> bool:
c = (code or "").strip()
return len(c) == 6 and c[0] == "7"
def _name_keywords() -> tuple:
"""NON_STOCK_NAME_KEYWORDS — 콤마 구분. 비우면 기본 튜플."""
raw = (get_env_from_db("NON_STOCK_NAME_KEYWORDS", "") or "").strip()
if not raw:
return _DEFAULT_NON_STOCK_NAME_KEYWORDS
parts = tuple(s.strip() for s in raw.split(",") if s.strip())
return parts or _DEFAULT_NON_STOCK_NAME_KEYWORDS
def _name_hits_keyword(name: str) -> bool:
nm = name.upper()
for k in _name_keywords():
if not k:
continue
if k.isascii():
if k.upper() in nm:
return True
elif k in name:
return True
return False
def is_non_stock(name: str, code: str) -> bool:
"""ETN/ETF/스팩/우선주 등 매수 금지 종목이면 True.
name 이 비었거나 name==code 여도 코드 규칙으로 우선주·ETN 을 잡는다.
"""
code = (code or "").strip()
name = (name or "").strip()
if not code:
return True
if is_preferred_code(code):
return True
if is_etn_code(code):
return True
if name:
if _name_hits_keyword(name):
return True
nm = name.upper()
if "스팩" in name or "SPAC" in nm:
return True
# 우선주 표기 (우 / 우B / 우C …)
if name.endswith("") or "우B" in name or "우C" in name or name.endswith("(전환)"):
return True
return False
def is_unmanageable_holding(name: str, code: str) -> bool:
"""보유 동기화에서 빼서 봇이 매도하지 않을 종목 (시세 불가 ETF/ETN·스팩).
우선주는 시세·매도 가능 → False. (실수로 산 우선주는 매도로 정리)
"""
code = (code or "").strip()
name = (name or "").strip()
if is_etn_code(code):
return True
if not name or name == code:
return False
nm = name.upper()
for k in ("ETN", "ETF", "레버리지", "인버스", "KODEX", "TIGER", "KBSTAR"):
if k.isascii():
if k in nm:
return True
elif k in name:
return True
if "스팩" in name or "SPAC" in nm:
return True
return False

View File

@@ -0,0 +1,97 @@
"""
종목코드 → 표시용 종목명 (Mattermost·로그·DB 공통).
키움 조건검색 실시간 응답에는 종목명이 없어 code=이름 임시값이 들어오는 경우가 많다.
우선순위: 유효 fallback → 메모리 캐시 → stock_meta → target_candidates_history → 잔고 prdt_name
"""
from __future__ import annotations
import logging
from typing import Dict, Optional
logger = logging.getLogger(__name__)
# 프로세스 내 재조회 방지 (매수체크 루프·체결 알림 공용)
_NAME_CACHE: Dict[str, str] = {}
def resolve_stock_display_name(
db,
code: str,
fallback: str = "",
*,
holdings_map: Optional[Dict[str, Dict]] = None,
cache_to_meta: bool = True,
) -> str:
"""
종목코드에 대응하는 한글 종목명을 반환한다.
Args:
db: TradeDB / TradeDBExt 인스턴스
code: 6자리 종목코드
fallback: 전략·후보에서 넘어온 이름 (코드와 같으면 무시)
holdings_map: {code: {name, qty, ...}} — OrderManager 잔고 캐시 등
cache_to_meta: history/잔고에서 찾은 이름을 stock_meta 에 저장
"""
code = str(code or "").strip()
if not code:
return str(fallback or "").strip()
fb = str(fallback or "").strip()
if fb and fb != code:
_NAME_CACHE[code] = fb
return fb
cached = _NAME_CACHE.get(code)
if cached and cached != code:
return cached
# 1) stock_meta
try:
if hasattr(db, "get_stock_meta"):
meta = db.get_stock_meta(code)
if meta:
n = str(meta.get("name") or "").strip()
if n and n != code:
_NAME_CACHE[code] = n
return n
except Exception as exc:
logger.debug("get_stock_meta 실패(%s): %s", code, exc)
# 2) target_candidates_history (조건검색 스냅샷 — 실매 후보 적재)
try:
conn = getattr(db, "conn", None)
if conn is not None:
row = conn.execute(
"SELECT name FROM target_candidates_history "
"WHERE code = %s AND name IS NOT NULL AND name != '' AND name != code "
"ORDER BY COALESCE(event_time, scan_time) DESC, id DESC LIMIT 1",
(code,),
).fetchone()
if row:
n = str(row.get("name") or "").strip()
if n and n != code:
_NAME_CACHE[code] = n
if cache_to_meta and hasattr(db, "upsert_stock_meta"):
try:
db.upsert_stock_meta(code, name=n)
except Exception:
pass
return n
except Exception as exc:
logger.debug("target_candidates_history 이름 조회 실패(%s): %s", code, exc)
# 3) 실계좌 잔고 (보유 종목 prdt_name)
if holdings_map:
h = holdings_map.get(code) or {}
n = str(h.get("name") or "").strip()
if n and n != code:
_NAME_CACHE[code] = n
if cache_to_meta and hasattr(db, "upsert_stock_meta"):
try:
db.upsert_stock_meta(code, name=n)
except Exception:
pass
return n
return fb if fb else code

View File

@@ -23,11 +23,10 @@ KIS_TRADER_STRATEGY_IDS: List[str] = [
# 사용자 지정 — 웹·계산에서 완전히 숨길 전략 (비활성·중복·저성능).
# DB 에 기존 거래기록이 남아 있어도 실거래 분석·대시보드·보유탭·운영설정에서 표시·집계하지 않는다.
# SCALP : MOMENTUM 과 1분봉 슬롯 중복 (반등 vs 추세) → MOMENTUM 만 사용
# RANGE_BREAK : BREAKOUT 과 돌파 컨셉 중복 + 미검증 (파라서치 거래 0건)
# DBBAND : 표본 부족·미성숙 (백테 2~4건)
# ※ SCALP 는 실거래 분석·대시보드에 노출 (스캘핑 봇 운영 중)
HIDDEN_STRATEGY_IDS: List[str] = [
"SCALP",
"RANGE_BREAK",
"DBBAND",
]

View File

@@ -0,0 +1,32 @@
#!/usr/bin/env python3
"""
kis_trader/utils/trade_time.py — 실매·백테 시각 파싱 공통
========================================================
실매 buy_time: ``YYYY-MM-DD HH:MM:SS``
백테 봉/틱키: ``YYYYMMDDHHMM`` / ``YYYYMMDDHHMMSS``
엔진별 ``_t2dt`` 가 ``[:12]``+``%Y%m%d%H%M`` 만 쓰면 실매 시간컷이
``None`` 으로 무력화된다 → 전 전략이 이 함수를 쓰도록 통일.
"""
from __future__ import annotations
from datetime import datetime
def parse_trade_datetime(t: str) -> datetime:
"""실매·백테 시각 문자열 → datetime. 실패 시 ValueError."""
s = str(t or "").strip()
if not s:
raise ValueError("empty time")
if "-" in s or "T" in s or ":" in s:
compact = (
s.replace("-", "").replace(":", "").replace(" ", "").replace("T", "")
)
digits = "".join(c for c in compact if c.isdigit())
else:
digits = "".join(c for c in s if c.isdigit())
if len(digits) >= 14:
return datetime.strptime(digits[:14], "%Y%m%d%H%M%S")
if len(digits) >= 12:
return datetime.strptime(digits[:12], "%Y%m%d%H%M")
raise ValueError(f"bad time: {t!r}")