Files
kis_bot/kis_trader/web/live_config_schema.py
Your Name fc27e726f9 feat: 새로운 안전 규칙 및 최적화 적용을 통한 트레이딩 시스템 개선
변경 사항 (Changes):

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

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

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

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

기대 효과 (Impact):

이러한 개선 사항들은 트레이딩 시스템의 견고성(Robustness)과 신뢰성을 향상시키며, 에러 발생을 최소화하고 다양한 시스템 컴포넌트 간의 원활한 상호작용을 보장함.
2026-07-17 01:09:09 +09:00

745 lines
39 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
kis_trader/web/live_config_schema.py — 실매 운영 설정 웹 탭 필드 정의
======================================================================
backtest_web ``/api/live_config`` 가 이 스키마로 폼을 생성·저장한다.
키는 env/DB 와 1:1 — 하드코딩 임계값 없음.
"""
from __future__ import annotations
from typing import Any, Dict, List, Optional, Tuple
# 웹 탭에 노출할 전략 ID (kis_trader 봇)
# ※ SCALP·RANGE_BREAK·DBBAND 는 사용자 지정으로 숨김 (strategy_ids.HIDDEN_STRATEGY_IDS)
# → 운영설정 ON/OFF·운용한도·일일익절·휩쏘 폼에서 제외. 실거래 중인 SHORT·BREAKOUT·MOMENTUM + UPDOW 만 노출.
LIVE_STRATEGY_IDS: Tuple[str, ...] = (
"SHORT",
"MOMENTUM",
"BREAKOUT",
"UPDOW",
)
LIVE_STRATEGY_LABELS: Dict[str, str] = {
"SCALP": "스캘핑",
"SHORT": "꼬리잡기",
"MOMENTUM": "모멘텀",
"BREAKOUT": "돌파",
"RANGE_BREAK": "박스권돌파",
"UPDOW": "UPDOWN 박스",
"DBBAND": "더블BB",
}
# 전략 ID → 실제 env 키 (SHORT 는 TAIL_* 가 canonical — tail_engine·config_short)
STRATEGY_BUDGET_ENV_KEY: Dict[str, Dict[str, str]] = {
"SHORT": {
"TOTAL_BUDGET_KRW": "TAIL_TOTAL_BUDGET_KRW",
"SLOT_MONEY": "TAIL_SLOT_MONEY",
"MAX_STOCKS": "TAIL_MAX_STOCKS",
},
}
# UI 조회 시 이전 키·별칭 폴백 (표시만 — 저장은 canonical)
BUDGET_READ_FALLBACKS: Dict[str, Tuple[str, ...]] = {
"TAIL_SLOT_MONEY": (
"SLOT_MONEY_DEFAULT",
"SHORT_SLOT_MONEY",
"MAX_BUY_AMOUNT_PER_STOCK",
),
"TAIL_TOTAL_BUDGET_KRW": ("SHORT_TOTAL_BUDGET_KRW",),
"TAIL_MAX_STOCKS": ("SHORT_MAX_STOCKS", "MAX_STOCKS"),
}
# TAIL canonical 저장 시 실매 루프가 읽는 별칭에 동기화
TAIL_BUDGET_SAVE_MIRROR: Dict[str, str] = {
"TAIL_SLOT_MONEY": "SLOT_MONEY_DEFAULT",
"TAIL_MAX_STOCKS": "SHORT_MAX_STOCKS",
"TAIL_TOTAL_BUDGET_KRW": "SHORT_TOTAL_BUDGET_KRW",
}
# type: bool | int | float | pct | text
FieldDef = Dict[str, Any]
GroupDef = Dict[str, Any]
def _f(key: str, label: str, ftype: str = "float", **extra: Any) -> FieldDef:
d: FieldDef = {"key": key, "label": label, "type": ftype}
d.update(extra)
return d
def _sid_key(sid: str, suffix: str) -> str:
aliases = STRATEGY_BUDGET_ENV_KEY.get(sid.upper(), {})
if suffix in aliases:
return aliases[suffix]
return f"{sid}_{suffix}"
def read_snap_value(snap: Dict[str, Any], key: str, ftype: str) -> Any:
"""canonical 키 + 폴백 키 순으로 UI 값 해석."""
keys = (key,) + BUDGET_READ_FALLBACKS.get(key, ())
for k in keys:
v = snap_value_to_ui(snap.get(k), ftype)
if v is not None:
return v
return None
def expand_live_config_save_patch(patch: Dict[str, str]) -> Dict[str, str]:
"""TAIL_* 저장 시 tail_catch·BaseStrategy 가 읽는 별칭 키 동기화."""
out = dict(patch)
for src, dst in TAIL_BUDGET_SAVE_MIRROR.items():
if src in out and dst not in out:
out[dst] = out[src]
return out
def build_live_config_groups() -> List[GroupDef]:
"""정적 그룹 + 전략 반복 그룹 메타."""
groups: List[GroupDef] = [
{
"id": "daily_profit_global",
"title": "일일 익절 — 총합 (규칙·수동중단)",
"hint": (
"손익 감시 ON/OFF 는 전략별(*_DAILY_PROFIT_TARGET_ENABLED)만 사용. "
"여기 마스터는 총합 목표금액/%·수동 매수잠금·알림·트레일 규칙. "
"① 신규매수 수동 중단 = 목표·손익과 무관 매수만 잠금(긴급용, 기본 OFF). "
"매도·손절은 항상 유지"
),
"fields": [
_f("DAILY_PROFIT_TARGET_KRW", "목표 금액(원)", "int", default=0),
_f("DAILY_PROFIT_TARGET_PCT", "목표 수익률(%)", "float", default=0,
hint="퍼센트 그대로 (예: 2.5 = 2.5%) · 0=미사용"),
_f(
"DAILY_PROFIT_TARGET_BUDGET_KRW",
"분모 운용한도(원)",
"int",
default=0,
hint="0 = ON 전략 TOTAL_BUDGET 합 자동",
),
_f(
"DAILY_PROFIT_HALT_NEW_BUYS",
"신규매수 수동 중단",
"bool",
default=False,
hint=(
"목표·손익과 무관한 수동 매수 잠금. "
"ON=즉시 신규매수 전면 차단 / OFF=정상 (기본)"
),
),
_f("DAILY_PROFIT_NOTIFY_MM", "달성 시 MM 알림", "bool", default=True),
_f("DAILY_PROFIT_MODE", "익절 방식", "text", default="fixed",
hint="fixed=고정목표 / trailing=고점대비 되돌림 / both=둘 중 먼저"),
_f("DAILY_PROFIT_TRAIL_TIERS", "다단 트레일(수익원:컷%,…)", "text", default="",
hint="벌수록 타이트하게 사수 · 예: 30000:50,100000:35,300000:25 · 비우면 단일 되돌림 사용"),
_f("DAILY_PROFIT_TRAIL_DROP_PCT", "단일 트레일 되돌림(%, 고점대비)", "float", default=0,
hint="다단 비었을 때만 · 예: 35 = 고점 대비 -35% 꺾이면 중단 · 0=미사용"),
_f("DAILY_PROFIT_TRAIL_ARM_KRW", "트레일 발동 최소수익(원)", "int", default=0,
hint="이 이상 벌어야 트레일 감시 시작 · 0=트레일 비활성(과민발동 방지)"),
_f("DAILY_PROFIT_TRAIL_ARM_PCT", "트레일 발동 최소수익(%)", "float", default=0,
hint="운용한도 대비 % · KRW와 둘 중 큰 값"),
],
},
{
"id": "risk_global",
"title": "리스크 — 계정 공통",
"hint": "env_config",
"fields": [
_f("DAILY_STOP_LOSS_PCT", "일일 손실 한도(분율, -0.05=-5%)", "float", default=0),
_f("CONSECUTIVE_LOSS_LIMIT", "연속 손실 제한(회)", "int", default=0),
_f("USE_RISK_CHECK", "리스크 체크 사용", "bool"),
_f("EXCLUDE_NON_STOCK", "비본주(우선주·ETF·ETN) 매수 제외", "bool", default=True,
hint="후보 name==code 여도 코드 끝 5/7/9·K/L… 로 우선주 차단"),
_f("NON_STOCK_PREF_CODE_SUFFIXES", "우선주 코드 끝자리(숫자)", "str", default="5,7,9",
hint="KRX 구형 우선주. 콤마 구분"),
_f("NON_STOCK_EXCLUDE_ALPHA_SUFFIX", "코드 끝 알파벳=우선주 제외", "bool", default=True,
hint="00088K·00781K 등"),
],
},
{
"id": "ws_trigger_global",
"title": "TRIGGER 필터 — WS 공통",
"hint": (
"호가(0D)·프로그램(0w) · env_config(컬럼 없으면 env_config_ext) · "
"COLLECT=판정순간1장(filter_eval) / SAVE=후보~3초시계열(kiwoom_0d) — 둘은 다름"
),
"fields": [
_f("KIWOOM_WS_ORDERBOOK_ENABLED", "호가 WS(0D) 구독", "bool", default=True,
hint="키움 실시간 호가 수신(RAM) · 꺼면 필터·수집·시계열 모두 불가"),
_f("KIWOOM_WS_PROGRAM_ENABLED", "프로그램 WS(0w) 구독", "bool", default=True,
hint="키움 프로그램매매 수신(RAM)"),
_f(
"WS_TRIGGER_EVAL_SAVE_ENABLED",
"TRIGGER 판정 DB 저장(마스터)",
"bool",
default=True,
hint="OFF면 아래 COLLECT(판정 스냅) 전부 안 씀 · SAVE(시계열)와 무관",
),
_f(
"WS_ORDERBOOK_COLLECT_ENABLED",
"호가 판정스냅(FILTER_EVAL) — 매수체크 순간 1장",
"bool",
default=True,
hint="≠ 연속시계열. TRIGGER 판정 때만 ws_orderbook source=filter_eval · 필터 OFF여도 저장 가능",
),
_f(
"WS_ORDERBOOK_SAVE_ENABLED",
"호가 시계열(KIWOOM_0D) — 구독후보 ~3초 덤프",
"bool",
default=False,
hint="틱처럼 후보·보유만 연속 저장(source=kiwoom_0d) · 파람서치 호가축용 · 봇 재시작 필요 · 필터와 무관",
),
_f(
"WS_TRIGGER_SNAPSHOT_DB_INTERVAL_SEC",
"호가/프로그램 시계열 간격(초)",
"float",
default=3.0,
hint="SAVE ON일 때만 · 종목당 최소 저장 간격 · 봇 재시작 필요",
),
# 글로벌 ORDERBOOK_FILTER / MAX_SPREAD 폐기 → 아래「호가필터 — 전략별」
_f(
"WS_PROGRAM_COLLECT_ENABLED",
"프로그램 판정스냅 — 매수체크 순간 1장",
"bool",
default=True,
hint="≠ 연속시계열. 판정 때만 ws_program · 필터 OFF여도 가능",
),
_f(
"WS_PROGRAM_SAVE_ENABLED",
"프로그램 시계열 — 구독후보 ~3초 덤프",
"bool",
default=False,
hint="SAVE와 동일 Recorder · 봇 재시작 필요 · 필터와 무관",
),
_f("PROGRAM_FILTER_ENABLED", "프로그램 필터(TRIGGER) — 실매 탈락", "bool", default=True),
_f("WHIPSAW_FILTER_ENABLED", "휩쏘 필터(글로벌)", "bool", default=True),
_f("WHIPSAW_DIP_PCT", "휩쏘 하락폭(분율, 0.003=0.3%)", "float", default=0.003),
_f("WHIPSAW_RECOVERY_TOL_PCT", "휩쏘 회복 허용(분율, 0.001=0.1%)", "float", default=0.001),
],
},
]
# 호가필터 — 전략별 (글로벌 ORDERBOOK_* 수치 폐기)
# SCALP 는 LIVE_STRATEGY_IDS 에서 숨김이어도 config_scalp 키는 노출
from kis_trader.engine.orderbook_env import (
OB_DEFAULT_ASK_WALL_MAX_QTY,
OB_DEFAULT_ENTRY_ASK_MAX_MULT,
OB_DEFAULT_ENTRY_BID_DEPTH_MULT,
OB_DEFAULT_ENTRY_BID_LEVELS,
OB_DEFAULT_FILTER_ENABLED,
OB_DEFAULT_MAX_SPREAD_PCT,
OB_DEFAULT_MIN_BID_ASK_RATIO,
OB_STRATEGY_PREFIXES,
)
ob_fields: List[FieldDef] = []
_ob_lbl = {
"SCALP": "스캘핑",
"TAIL": "꼬리잡기",
"MOMENTUM": "모멘텀",
"BREAKOUT": "돌파",
}
for pfx in OB_STRATEGY_PREFIXES:
lbl = _ob_lbl.get(pfx, pfx)
ob_fields.extend([
_f(
f"{pfx}_ORDERBOOK_FILTER_ENABLED",
f"{lbl} 호가필터 ON",
"bool",
default=OB_DEFAULT_FILTER_ENABLED,
hint="ON=스프레드·잔량비·얇은호가·매도벽으로 매수 차단 · OFF=저장만",
),
_f(
f"{pfx}_ORDERBOOK_MAX_SPREAD_PCT",
f"{lbl} 스프레드상한(%)",
"float",
default=OB_DEFAULT_MAX_SPREAD_PCT,
hint="0.45=0.45% · 그리드 max_spread_pct 앵커",
),
_f(
f"{pfx}_ORDERBOOK_MIN_BID_ASK_RATIO",
f"{lbl} 매수/매도잔량비 하한",
"float",
default=OB_DEFAULT_MIN_BID_ASK_RATIO,
hint="그리드 min_bid_ask_ratio",
),
_f(
f"{pfx}_ORDERBOOK_ENTRY_ASK_MAX_MULT",
f"{lbl} 매도벽 허용배수",
"float",
default=OB_DEFAULT_ENTRY_ASK_MAX_MULT,
hint="필요수량×N · 그리드 ask_max_mult",
),
_f(
f"{pfx}_ORDERBOOK_ENTRY_BID_DEPTH_MULT",
f"{lbl} 매수호가깊이 배수",
"float",
default=OB_DEFAULT_ENTRY_BID_DEPTH_MULT,
hint="얇은호가 탈락 · 필요수량×N",
),
_f(
f"{pfx}_ORDERBOOK_ENTRY_BID_LEVELS",
f"{lbl} 호가단계(N호가)",
"int",
default=OB_DEFAULT_ENTRY_BID_LEVELS,
),
])
if pfx == "BREAKOUT":
ob_fields.append(
_f(
"BREAKOUT_ORDERBOOK_ASK_WALL_MAX_QTY",
"돌파 매도벽 상한(주)",
"int",
default=OB_DEFAULT_ASK_WALL_MAX_QTY,
hint="그리드 ask_wall_max_qty",
),
)
groups.append({
"id": "orderbook_strategy",
"title": "호가필터 — 전략별 (수치)",
"hint": (
"글로벌 ORDERBOOK_MAX_SPREAD_PCT 등 폐기 · "
"파람서치 apply-best ↔ *_ORDERBOOK_* 정합"
),
"fields": ob_fields,
})
groups.append({
"id": "strategy_switch",
"title": "전략 ON/OFF",
"hint": "config_{strategy} · 봇 재시작 없이 다음 루프 반영",
"fields": [
_f(f"STRATEGY_{sid}_ENABLED", LIVE_STRATEGY_LABELS.get(sid, sid), "bool")
for sid in LIVE_STRATEGY_IDS
],
})
# 전략별 운용한도
budget_fields: List[FieldDef] = []
for sid in LIVE_STRATEGY_IDS:
lbl = LIVE_STRATEGY_LABELS.get(sid, sid)
budget_fields.extend([
_f(_sid_key(sid, "TOTAL_BUDGET_KRW"), f"{lbl} 총 한도(원)", "int"),
_f(_sid_key(sid, "SLOT_MONEY"), f"{lbl} 슬롯(원)", "int"),
_f(_sid_key(sid, "MAX_STOCKS"), f"{lbl} 동시 종목", "int"),
])
groups.append({
"id": "strategy_budget",
"title": "전략별 운용한도",
"hint": "config_{strategy}",
"fields": budget_fields,
})
# 전략별 일일 익절 서브
dp_fields: List[FieldDef] = []
for sid in LIVE_STRATEGY_IDS:
lbl = LIVE_STRATEGY_LABELS.get(sid, sid)
dp_fields.extend([
_f(
_sid_key(sid, "DAILY_PROFIT_TARGET_ENABLED"),
f"{lbl} 손익 감시 켜기",
"bool",
hint="OFF = 해당 전략 일일익절 비활성 (매수 차단 없음)",
),
_f(
_sid_key(sid, "DAILY_PROFIT_TARGET_KRW"),
f"{lbl} 목표(원)",
"int",
),
_f(
_sid_key(sid, "DAILY_PROFIT_TARGET_PCT"),
f"{lbl} 목표(%)",
"float",
hint="퍼센트 그대로 (예: 2.5 = 2.5%) · 0=미사용",
),
_f(
_sid_key(sid, "DAILY_PROFIT_MODE"),
f"{lbl} 익절방식",
"text",
hint="비우면 마스터 상속 · fixed/trailing/both",
),
_f(
_sid_key(sid, "DAILY_PROFIT_TRAIL_TIERS"),
f"{lbl} 다단 트레일",
"text",
hint="수익원:컷%,… · 비우면 마스터 상속 · 예: 30000:50,100000:35,300000:25",
),
_f(
_sid_key(sid, "DAILY_PROFIT_TRAIL_DROP_PCT"),
f"{lbl} 단일 트레일 되돌림(%)",
"float",
hint="다단 비었을 때만 · 고점 대비 % · 비우면 마스터 상속 · 0=미사용",
),
_f(
_sid_key(sid, "DAILY_PROFIT_TRAIL_ARM_KRW"),
f"{lbl} 트레일 발동수익(원)",
"int",
hint="이 이상 벌어야 트레일 감시 · 비우면 마스터 상속",
),
_f(
_sid_key(sid, "DAILY_PROFIT_HALT_NEW_BUYS"),
f"{lbl} 신규매수 수동 중단",
"bool",
default=False,
hint="목표 무관 수동 잠금 · 비우면 마스터 상속 · OFF=정상",
),
])
groups.append({
"id": "daily_profit_strategy",
"title": "일일 익절 — 전략별 (서브)",
"hint": (
"전략별 손익만 따로 감시. 비어 있으면 마스터 값 상속. "
"손익 감시 ON이면 목표 달성 시 해당 전략 신규매수 자동 차단"
),
"fields": dp_fields,
})
# 전략별 휩쏘 ON/OFF (간단)
ws_fields: List[FieldDef] = []
for sid in LIVE_STRATEGY_IDS:
if sid == "SCALP":
continue
lbl = LIVE_STRATEGY_LABELS.get(sid, sid)
pfx = "TAIL" if sid == "SHORT" else sid
ws_fields.append(
_f(f"{pfx}_WHIPSAW_FILTER_ENABLED", f"{lbl} 휩쏘 필터", "bool"),
)
groups.append({
"id": "whipsaw_strategy",
"title": "휩쏘 — 전략별 ON/OFF",
"fields": ws_fields,
})
groups.append({
"id": "execution_fill",
"title": "체결 검증 (실전·모의 훈련)",
"hint": "실전은 항상 fill 확인. 모의는 STRICT_FILL_VERIFY=true 시 동일",
"fields": [
_f("STRICT_FILL_VERIFY", "모의 엄격 체결 검증", "bool", default=False,
hint="모의 실매매만 영향 · ON=체결 API 필수(모의 API 한계로 매수 0건 가능) · 실전은 항상 엄격"),
_f("BUY_DEDUP_PENDING", "매수 미체결 중복주문 방지", "bool", default=True,
hint="동일 전략·종목 PENDING_FILL 있으면 재주문 스킵"),
_f("BACKTEST_SLIP_PCT", "백테 체결 슬리피지(편도 %)", "float", default=0.0,
hint="STRICT_FILL_VERIFY ON 일 때만 백테/파람서치에 적용 · 전 전략 공통 · 0=순수 백테 · 0.2=0.2%"),
_f("BACKTEST_VOL_FILL_CAP_PCT", "백테 체결량 상한(진입봉 거래량×%)", "float", default=0.0,
hint="STRICT_FILL_VERIFY ON 일 때만 · 전 전략 공통 · 0=무제한 · 소형주 IOC 미체결 근사"),
_f("TAIL_BACKTEST_TICK_FALLBACK_OHLC", "꼬리 OHLC 폴백", "bool", default=False,
hint="OFF=틱 없는 구간 체결 스킵(실매 정합) · ON=3분봉 OHLC 추측(유령거래)"),
_f("TAIL_BACKTEST_USE_TICK_EXIT", "꼬리 틱 청산", "bool", default=True,
hint="ON=ws_ticks 시간순 청산(실매 폴링 정합) · OFF=OHLC intrabar"),
_f("TAIL_BACKTEST_POLL_MS", "꼬리 틱청산 폴링(ms)", "int", default=100,
hint="실매 STRATEGY_LOOP_SLEEP≈0.1초 정합 · 하한 50ms"),
_f("MOMENTUM_BACKTEST_TICK_FALLBACK_OHLC", "모멘텀 OHLC 폴백", "bool", default=False,
hint="OFF 권장 — 틱 없으면 청산/진입 스킵"),
_f("MOMENTUM_BACKTEST_POLL_MS", "모멘텀 틱청산 폴링(ms)", "int", default=100,
hint="실매 STRATEGY_LOOP_SLEEP≈0.1초 정합 · 틱 사이 청산 검사 간격 · 하한 50ms"),
_f("BREAKOUT_BACKTEST_USE_TICK_EXIT", "돌파 틱 청산", "bool", default=True,
hint="ON=ws_ticks 시간순 청산 · OFF=OHLC intrabar"),
_f("BREAKOUT_BACKTEST_TICK_FALLBACK_OHLC", "돌파 OHLC 폴백", "bool", default=False,
hint="OFF 권장 — B안 틱 없으면 high 폴백 안 함"),
_f("BREAKOUT_BACKTEST_POLL_MS", "돌파 틱청산 폴링(ms)", "int", default=100,
hint="실매 폴링 정합 · 하한 50ms"),
_f("SCALP_BACKTEST_USE_TICK_EXIT", "스캘핑 틱 청산", "bool", default=True,
hint="ON=ws_ticks 시간순 청산(실매 폴링 정합) · OFF=OHLC intrabar"),
_f("SCALP_BACKTEST_USE_TICK_ENTRY", "스캘핑 틱 진입", "bool", default=True,
hint="ON=예약 체결 시 해당 분 첫 틱가 · OFF=분봉 시가"),
_f("SCALP_BACKTEST_TICK_FALLBACK_OHLC", "스캘핑 OHLC 폴백", "bool", default=False,
hint="OFF 권장 — 틱 없으면 청산 스킵"),
_f("SCALP_BACKTEST_POLL_MS", "스캘핑 틱청산 폴링(ms)", "int", default=100,
hint="실매 STRATEGY_LOOP_SLEEP≈0.1초 정합 · 하한 50ms"),
_f("RANGE_BREAK_BACKTEST_USE_TICK_EXIT", "박스돌파 틱 청산", "bool", default=True,
hint="ON=ws_ticks 시간순 청산 · OFF=OHLC intrabar"),
_f("RANGE_BREAK_BACKTEST_TICK_FALLBACK_OHLC", "박스돌파 OHLC 폴백", "bool", default=False,
hint="OFF 권장 — 틱 없으면 청산 스킵"),
_f("RANGE_BREAK_BACKTEST_POLL_MS", "박스돌파 틱청산 폴링(ms)", "int", default=100,
hint="실매 폴링 정합 · 하한 50ms"),
_f("DBBAND_BACKTEST_USE_TICK_EXIT", "더블BB 틱 청산", "bool", default=True,
hint="ON=ws_ticks 시간순 청산 · OFF=봉 OHLC 1회"),
_f("DBBAND_BACKTEST_TICK_FALLBACK_OHLC", "더블BB OHLC 폴백", "bool", default=False,
hint="OFF 권장 — 틱 없으면 청산 스킵"),
_f("DBBAND_BACKTEST_POLL_MS", "더블BB 틱청산 폴링(ms)", "int", default=100,
hint="실매 폴링 정합 · 하한 50ms"),
_f("TAIL_PARAM_SEARCH_MIN_START", "꼬리 파라서치 최소 시작일", "str", default="2026-07-07",
hint="조건식 변경일 이후만 탐색 (YYYY-MM-DD) · CLI --start 와 max 적용"),
_f("USE_MARKET_IOC", "시장가 IOC", "bool", default=True,
hint="실전 시장가 주문 방식 · 모의는 일반 시장가(01) 고정"),
_f("ORDER_FILL_WAIT_SEC", "시장가 체결 대기(초)", "int", default=2),
_f("DUPLICATE_ORDER_FILL_RECOVERY_ENABLED", "주문DB중복 체결복구", "bool", default=True,
hint="insert 실패(주문DB중복) 시 inquire-daily-ccld 1회 → active_trades 복구 · 실패 시에만 REST"),
_f("DUPLICATE_ORDER_RECOVERY_WAIT_SEC", "중복복구 체결대기(초)", "int", default=2,
hint="주문DB중복 복구 전용 · 미설정 시 ORDER_FILL_WAIT_SEC 사용"),
_f("LIMIT_ORDER_FILL_WAIT_SEC", "지정가 체결 대기(초)", "int", default=1),
_f("PENDING_FILL_POLL_INTERVAL_SEC", "미체결 재조회 주기(초)", "int", default=10),
_f("PENDING_FILL_POLL_SEC", "재조회 1회 대기(초)", "int", default=1),
_f("PENDING_POLL_BATCH_FETCH", "미체결 체결 일괄조회", "bool", default=True,
hint="실매 heartbeat 전용 · ON=poll 1회당 inquire-daily-ccld 1건(미체결 N건) · OFF=건별 조회 · 백테 무관"),
_f("PENDING_BUY_MAX_AGE_SEC", "매수 미체결 만료(초)", "int", default=120),
_f("PENDING_SELL_MAX_AGE_SEC", "익절 매도 미체결 만료(초)", "int", default=60),
_f("PENDING_SELL_STOP_MAX_AGE_SEC", "손절 매도 미체결 만료(초)", "int", default=15),
_f("SELL_PENDING_REORDER_ON_EXPIRE", "손절 만료 후 즉시 재매도", "bool", default=True),
_f("AUTO_CANCEL_PARTIAL_BUY_REMAINDER", "부분체결 잔량 자동취소", "bool", default=True),
],
})
groups.append({
"id": "rest_throttle",
"title": "REST 유량 제한 (한투 API)",
"hint": (
"한투 유량(계좌·앱키 단위): 실전 18건/초 · 모의 1건/초 · "
"신규 앱키는 첫 3일 3건/초 후 자동 상향 · 토큰발급 1건/초 · "
"동시호출 100~150ms 텀 권장. "
"※ 모의: 거래는 모의앱키(1건/초), 시세·랭킹·조건검색은 실전앱키(18건/초)로 분리. "
"운영(실전) 전환 시 거래+시세가 한 앱키로 합쳐져 18건/초를 공유."
),
"fields": [
_f("KIS_MIN_INTERVAL_SEC_MOCK", "모의 REST 최소간격(초)", "float", default=0.22,
hint="모의 도메인(거래: 주문·체결·잔고) · 조회·랭킹·조건은 실키라 무관 · 429 로그 없으면 0.22 유지 · 한투 공지 1건/초 엄수 필요 시 1.0 · 재시작 없이 반영(1초 TTL)"),
_f("KIS_MIN_INTERVAL_SEC_REAL", "실전 REST 최소간격(초)", "float", default=0.12,
hint="실전 도메인(시세 · 운영 시 거래+조회+취소+재주문 통합 한 앱키) · 18건/초 한도 → 0.12=약8건/초 · 신규앱키 첫 3일은 0.34(3건/초) · 재시작 없이 반영"),
_f("KIS_MIN_INTERVAL_SEC", "공통 폴백 최소간격(초)", "float", default=0.22,
hint="위 도메인별 키 미설정 시 폴백"),
_f("BALANCE_MAX_PAGES", "잔고 연속조회 최대 페이지", "int", default=20,
hint="잔고조회는 1페이지에 실전 50종목/모의 20종목만 내려옴. 보유가 많으면 다음 페이지를 이어 조회(누적 병합). 20p=최대 약 1000종목. 보유 종목이 50개를 넘으면 필수 — 부족하면 51번째부터 매도 검증에서 누락됨"),
],
})
groups.append({
"id": "scan_loop_sleep",
"title": "스캔 루프 회전율 (WS 따라가기)",
"hint": (
"REST 유량과 별개 — 전략 매수체크 루프의 sleep. 짧을수록 WS 실시간 신호를 빨리 잡지만 CPU↑. "
"탈락 경로는 REST 0건(WS·DB·연산만)이라 줄여도 한투 유량 무관. 재시작 없이 반영. "
"비우면 전략별 기본값(loop 0.1초) 사용."
),
"fields": [
_f("SCAN_REJECT_SLEEP_MIN", "탈락 후 sleep 최소(초)", "float", default=0.2,
hint="check_buy 탈락 종목마다 · REST 0건 · 0.05~0.1 로 낮추면 한 바퀴 대폭 단축"),
_f("SCAN_REJECT_SLEEP_MAX", "탈락 후 sleep 최대(초)", "float", default=0.5),
_f("SCAN_BUY_OK_SLEEP_MIN", "매수성공 후 sleep 최소(초)", "float", default=1.0,
hint="1루프 1매수 후 대기 · 포지션 과집중 방지"),
_f("SCAN_BUY_OK_SLEEP_MAX", "매수성공 후 sleep 최대(초)", "float", default=2.0),
_f("SCAN_BUY_FAIL_SLEEP_MIN", "주문실패 후 sleep 최소(초)", "float", default=0.3),
_f("SCAN_BUY_FAIL_SLEEP_MAX", "주문실패 후 sleep 최대(초)", "float", default=0.8),
_f("STRATEGY_LOOP_SLEEP_MIN", "루프 끝 sleep 최소(초)", "float", default=0.1,
hint="한 바퀴 끝 대기 · WS 구독 캐시 폴링 · 0.1=실매 반응 빠르게"),
_f("STRATEGY_LOOP_SLEEP_MAX", "루프 끝 sleep 최대(초)", "float", default=0.1),
],
})
# 전략별 후보 하드캡 (WS 100 구독 한도 안전 + 매수체크 회전율)
cand_fields: List[FieldDef] = []
for sid in LIVE_STRATEGY_IDS:
lbl = LIVE_STRATEGY_LABELS.get(sid, sid)
cand_fields.append(
_f(f"{sid}_CAND_LIMIT", f"{lbl} 후보 상한", "int", default=0,
hint="0=무제한 · 조건검색/랭킹이 N개 줘도 상위 N개만 매수체크·WS구독 · "
"전 전략 합산이 키움 WS 100종 한도를 넘지 않게 조절"),
)
groups.append({
"id": "candidate_limit",
"title": "전략별 후보 상한 (WS 100 구독 분배)",
"hint": (
"env_config · 키움 WS 실시간 구독은 계좌당 100종목이 한도. "
"전략별 후보 상한 합 + 영구구독 + 보유 여유 ≤ 100 이 되도록 분배. "
"UPDOW 는 추가로 watchlist(UPDOWN_WATCH_MAX) 안에서만 유지됨."
),
"fields": cand_fields,
})
# UPDOWN 박스권 전략 — SCAN/판별/진입/청산 파라미터 (env_config UPDOWN_BOX_*)
groups.append({
"id": "updown_box",
"title": "UPDOWN 박스권 — 판별·진입·손절 스택",
"hint": (
"env_config · 횡보(박스권) 종목을 하단에서 줍고 상단/익절·박스이탈·패닉·래칫으로 청산. "
"파라서치(param_search_updown_box) 1위와 동일 키 · 재시작 없이 다음 루프 반영."
),
"fields": [
_f("UPDOWN_SCAN_INTERVAL_SEC", "SCAN 주기(초)", "int", default=300,
hint="조건검색→박스필터→watchlist 충전 주기 · 박스는 느리게 변하므로 300(5분) 권장"),
_f("UPDOWN_SCAN_TF_MIN", "분봉(분)", "int", default=15,
hint="박스 판별·진입에 쓰는 분봉 · 수집/백테/파라서치와 동일해야 함"),
_f("UPDOWN_WATCH_MAX", "관심종목 최대 수", "int", default=30,
hint="sticky watchlist 최대 보유 · REST 1콜 30종(intstock-multprice)과 정합"),
_f("UPDOWN_BOX_LOOKBACK_BARS", "박스 lookback(봉)", "int", default=20,
hint="박스 상·하단 산출에 쓸 최근 봉 수"),
_f("UPDOWN_BOX_RANGE_MAX_PCT", "박스 최대 높이(%)", "float", default=8.0,
hint="(상단-하단)/하단 · 이보다 넓으면 박스 아님(변동성 과대)"),
_f("UPDOWN_BOX_BB_BW_MAX", "밴드폭 상한(%)", "float", default=12.0,
hint="볼린저 밴드폭(스퀴즈) · 작을수록 횡보 · 초과 시 박스 아님"),
_f("UPDOWN_BOX_MA_SLOPE_TOL_PCT", "MA 기울기 허용(%)", "float", default=0.5,
hint="|MA 기울기| 이내여야 추세없음(횡보) 인정"),
_f("UPDOWN_BOX_NEAR_LOW_PCT", "하단 근접 진입(%)", "float", default=2.0,
hint="현재가가 박스 하단에서 이 % 이내로 내려오면 매수 후보(싸게 줍기)"),
_f("UPDOWN_BOX_BODY_DROP_MIN_PCT", "직전봉 음봉 최소(%)", "float", default=0.2,
hint="되돌림 확인 · 직전봉 음봉 몸통 하락률 하한 · 0=무시"),
_f("UPDOWN_BOX_TP_PCT", "익절(%)", "float", default=3.0,
hint="진입가 대비 · 박스 상단 도달 기대치 근방"),
_f("UPDOWN_BOX_STOP_LOSS_PCT", "하드 손절(%)", "float", default=2.5,
hint="진입가 대비 · 박스이탈·패닉이 안 걸릴 때 최종 방어"),
_f("UPDOWN_BOX_BREACH_BUFFER_PCT", "박스이탈 여유(%)", "float", default=0.3,
hint="현재가 < 하단×(1-여유%) 면 구조 붕괴 즉시 손절 · 노이즈 이탈 방지"),
_f("UPDOWN_PANIC_FROM_HIGH_PCT", "패닉 손절(고점대비 %)", "float", default=3.0,
hint="보유 중 고점 대비 이 % 급락 시 무조건 긴급컷(진입가 무관)"),
_f("UPDOWN_BOX_SHOULDER_MIN_HIGH_PCT", "래칫 무장(+%)", "float", default=1.0,
hint="고점이 진입가 대비 +이 % 도달해야 어깨컷(트레일) 무장"),
_f("UPDOWN_BOX_SHOULDER_CUT_PCT", "래칫 컷(고점대비 -%)", "float", default=1.0,
hint="무장 후 고점 대비 이 % 꺾이면 청산(어깨컷)"),
_f("UPDOWN_BOX_RATCHET_TIERS", "다단 래칫(수익%:컷%,…)", "text", default="",
hint="TAIL·BREAKOUT 동일 형식. 예 '2:1.5,5:1.0' = +2%부터 고점-1.5%컷, +5%부터 -1.0%컷. "
"비우면 OFF(위 단일 어깨컷 사용)"),
_f("UPDOWN_BOX_MAX_HOLD_BARS", "보유 한도(봉)", "int", default=64,
hint="이만큼 봉이 지나면 무조건 청산"),
_f("UPDOWN_FEED_MODE", "피드 모드", "text", default="rest",
hint="ws=키움 실시간(100한도 공유) / rest=한투 30배치 폴링 · watchlist 30종이면 rest 권장"),
_f("UPDOWN_REST_POLL_SEC", "REST 폴링 주기(초)", "int", default=5,
hint="rest 모드에서 현재가 스냅샷 갱신 주기"),
],
})
groups.append({
"id": "breakout_exit",
"title": "돌파(BREAKOUT) — EOD·시간컷",
"hint": (
"env_config · 저장 후 다음 매도 루프부터 반영(재시작 불필요). "
"EOD OFF 시 당일 15:15 강제청산 없음 — 손절·익절·어깨·시간컷만."
),
"fields": [
_f("BREAKOUT_EOD_ENABLED", "EOD 당일청산 사용", "bool", default=True,
hint="ON=설정 시각 이후 보유 전량 eod 청산 · OFF=익절·손절·시간컷만"),
_f("BREAKOUT_EOD_HM", "EOD 시각 (HH:MM)", "text", default="15:15",
hint="이 시각 이후 보유 전량 청산 · 예: 15:15"),
_f("BREAKOUT_MAX_HOLD_BARS", "최대보유(분)", "int", default=0,
hint="0=OFF · 진입 후 N분 경과 시 max_hold 청산"),
],
})
groups.append({
"id": "momentum_exit",
"title": "모멘텀(MOMENTUM) — EOD",
"hint": "env_config · 저장 후 다음 매도 루프부터 반영(재시작 불필요).",
"fields": [
_f("MOMENTUM_EOD_ENABLED", "EOD 당일청산 사용", "bool", default=True,
hint="ON=설정 시각 이후 보유 전량 장마감청산 · OFF=익절·손절·시간컷만"),
_f("MOMENTUM_EOD_HM", "EOD 시각 (HH:MM)", "text", default="15:20",
hint="정규장 15:30 마감 기준 최소 10분 전 · 순차매도 여유"),
],
})
groups.append({
"id": "tail_exit",
"title": "꼬리(SHORT/TAIL) — EOD",
"hint": "env_config · 저장 후 다음 매도 루프부터 반영(재시작 불필요).",
"fields": [
_f("TAIL_EOD_ENABLED", "EOD 당일청산 사용", "bool", default=True,
hint="ON=설정 시각 이후 보유 전량 장마감 청산 · OFF=익절·손절·시간컷만"),
_f("TAIL_EOD_HM", "EOD 시각 (HH:MM)", "text", default="15:20",
hint="정규장 15:30 마감 기준 최소 10분 전 · 순차매도 여유"),
],
})
groups.append({
"id": "error_watch_mm",
"title": "오류감시 → Mattermost",
"hint": (
"별도 프로세스 scripts/kis_error_watch_mm.py 가 journalctl -f 로 "
"kis_trader_main 을 감시 → Traceback/유닛다운 시 MM. 실매 봇과 분리."
),
"fields": [
_f("ERROR_WATCH_ENABLED", "오류감시 ON", "bool", default=True,
hint="감시 프로세스 시작 시 읽음 · OFF면 감시자 즉시 종료"),
_f("ERROR_WATCH_UNIT", "감시 systemd 유닛", "text",
default="kis_trader_main.service",
hint="journalctl -u 대상"),
_f("ERROR_WATCH_MM_CHANNEL", "알림 MM alias", "text", default="",
hint="비우면 KIS_SYSTEM_MM_CHANNEL(default=키스명령봇)"),
_f("ERROR_WATCH_COOLDOWN_SEC", "동일오류 쿨다운(초)", "int", default=180,
hint="같은 Traceback 지문 재알림 간격"),
_f("ERROR_WATCH_HEALTH_CHECK_SEC", "유닛생존 점검(초)", "int", default=60,
hint="systemctl is-active 주기"),
_f("ERROR_WATCH_TRACEBACK_EXTRA_LINES", "Traceback 추가줄", "int", default=12,
hint="Traceback 이후 스택 수집 줄 수"),
_f("ERROR_WATCH_STARTUP_NOTIFY", "감시자 기동 MM", "bool", default=True,
hint="감시 프로세스 시작 시 1회 알림"),
_f("ERROR_WATCH_MM_JITTER", "MM jitter", "bool", default=False,
hint="오류 알림은 즉시 권장(OFF)"),
],
})
groups.append({
"id": "portfolio_hold",
"title": "보유·매도 / 고아복구",
"hint": (
"보유·매도 탭 일괄매도·수동 보호·장마감 후 DB 동기화. "
"MANUAL_HOLD_CODES 에 넣은 종목은 일괄매도·고아복구 모두 제외."
),
"fields": [
_f("MANUAL_HOLD_CODES", "수동 보유 보호 종목", "text", default="",
hint="쉼표(,) 구분 종목코드 예: 005930,000660 · HTS 직접 매수·장기 보유분 보호 · 일괄매도·고아복구 제외"),
_f("BULK_SELL_INTERVAL_SEC", "일괄매도 종목 간격(초)", "float", default=0.3,
hint="미등록 봇고아 일괄 시장가매도 시 REST 429 방지 · 종목마다 sleep"),
_f("ORPHAN_RECONCILE_ENABLED", "장마감 고아복구", "bool", default=True,
hint="매일 15:36~16:00 1회 · 실계좌 잔고↔active_trades 대조 · orders BUY 있는 미기록분만 복구 · 수동·MANUAL_HOLD 제외"),
_f("ORPHAN_RECONCILE_PRE_EOD_ENABLED", "Pre-EOD 고아복구", "bool", default=True,
hint="활성 전략 중 가장 이른 EOD N분 1회 · EOD 청산 전 active_trades 반영"),
_f("ORPHAN_RECONCILE_PRE_EOD_LEAD_MIN", "Pre-EOD 선행(분)", "int", default=7,
hint="예: BREAKOUT EOD 15:15 → 15:08 실행 · REST 잔고 1회/일"),
_f("GHOST_PURGE_ON_RECONCILE", "고아복구 시 유령잔고 삭제", "bool", default=True,
hint="ON=브로커 0주인데 active_trades 남은 종목 삭제(수동보호 제외) · Pre/Post EOD 동일 잔고조회에서 처리"),
_f("INTRADAY_HOLDINGS_DRIFT_ENABLED", "장중 qty 드리프트 감시", "bool", default=False,
hint="기본 OFF · ON=N분마다 잔고 REST · 알림/자동복구는 별도 키"),
_f("INTRADAY_HOLDINGS_DRIFT_INTERVAL_SEC", "드리프트 조회주기(초)", "int", default=300,
hint="INTRADAY_HOLDINGS_DRIFT_ENABLED=ON 일 때만"),
_f("INTRADAY_HOLDINGS_DRIFT_AUTO_RECOVER", "드리프트 자동복구", "bool", default=False,
hint="OFF=알림만 · ON=active_trades qty 보정(기본 OFF)"),
],
})
return groups
def all_live_config_keys() -> List[str]:
keys: List[str] = []
for g in build_live_config_groups():
for f in g.get("fields") or []:
k = f.get("key")
if k:
keys.append(str(k))
return keys
def snap_value_to_ui(raw: Any, ftype: str) -> Any:
"""DB 문자열 → 웹 입력값."""
if raw is None or str(raw).strip() in ("", "None"):
return None
s = str(raw).strip().split("#", 1)[0].strip()
if ftype == "bool":
return s.lower() in ("1", "true", "yes", "on")
try:
if ftype == "int":
return int(float(s))
if ftype in ("float", "pct"):
v = float(s)
if ftype == "pct" and 0 < abs(v) < 0.5:
return round(abs(v) * 100, 4)
return round(abs(v), 6) if ftype == "pct" else v
except (ValueError, TypeError):
return None
return s
def ui_value_to_db(val: Any, ftype: str) -> str:
"""웹 입력 → DB 저장 문자열."""
if ftype == "bool":
if val in (True, "true", "1", 1, "on", "yes"):
return "true"
return "false"
if val is None or (isinstance(val, str) and not val.strip()):
return ""
if ftype == "int":
return str(int(float(val)))
if ftype == "pct":
return str(float(val))
if ftype == "float":
return str(float(val))
return str(val).strip()