""" 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). 매도·손절은 항상 유지" ), "fields": [ _f( "DAILY_PROFIT_TARGET_ENABLED", "손익 감시 켜기 (일일익절)", "bool", hint="ON = 목표 달성 시 신규매수 자동 차단 · OFF = 목표와 무관하게 매수 계속", ), _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"), ], }, { "id": "ws_trigger_global", "title": "TRIGGER 필터 — WS 공통", "hint": "호가(0D)·프로그램(0w) · env_config(컬럼 없으면 env_config_ext)", "fields": [ _f("KIWOOM_WS_ORDERBOOK_ENABLED", "호가 WS(0D)", "bool", default=True), _f("KIWOOM_WS_PROGRAM_ENABLED", "프로그램 WS(0w)", "bool", default=True), _f("ORDERBOOK_FILTER_ENABLED", "호가 필터(TRIGGER)", "bool", default=True), _f("ORDERBOOK_MAX_SPREAD_PCT", "호가 최대 스프레드(%, 0.45=0.45%)", "float", default=0.45), _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), ], }, { "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("USE_MARKET_IOC", "시장가 IOC", "bool", default=True, hint="실전 시장가 주문 방식 · 모의는 일반 시장가(01) 고정"), _f("ORDER_FILL_WAIT_SEC", "시장가 체결 대기(초)", "int", default=2), _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 1~2.5초) 사용." ), "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=1.0, hint="비우면 전략별 기본값 · 한 바퀴 끝 대기"), _f("STRATEGY_LOOP_SLEEP_MAX", "루프 끝 sleep 최대(초)", "float", default=2.0), ], }) # 전략별 후보 하드캡 (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:25", hint="이 시각 이후 보유 전량 청산 · 예: 15:25"), ], }) 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:25", hint="이 시각 이후 보유 전량 청산 · 예: 15:25"), ], }) 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 제외"), ], }) 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()