fix: 하드닝 루프 8회차 — 체결 보정·고아 감지·청산 경보·실시간/차트·리스크 가드 일괄 수정
- 스윙 체결가 확인 전 executed_price NULL + fill_sweep, 진입가 resolve(ActiveTrade↔Portfolio), 0체결/부분체결 매수 장부 원복 - DT 고아 감지(근거 한정)·0체결 매도 복원·stale 점검 확대, 시간손절 -0.3% 원복 - 폴백 손절 ATR 캐시, 거래량부족-차단 신호 매수 차단, 현금 5% 유보 재적용, MDD peak/reset 수정 - KIS 서킷브레이커 청산 우회·경보, db_worker 재큐잉 순서, heartbeat·health-watch 사이드카 - 실시간 WS 재구독·41건 우선순위·다건 프레임 분할, 차트 샘플 제거·당일봉 합성, WS 서브프로토콜 인증 - 프론트: 미관리 그룹·확인 모달·차트 실시간 반영·예수금 표시 정정 - 전문가 에이전트 6종 추가(.claude/agents) - 유닛 스위트 2667 passed (소켓 차단 하), tsc OK Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Y83Za98XsNw9zuA4FTUytN
This commit is contained in:
@@ -108,7 +108,7 @@ DEFAULT_PARAMS: Dict[str, tuple] = {
|
||||
"DAY_TRADE_CAPITAL_RATIO": ("DAY_TRADE", "전체 자본 비율 상한", "데이트레이딩에 투입 가능한 총자본 비율", "float", "0.30", "0.05","1.0"),
|
||||
# [2026-09-27] config.py 정합: 기본값 0.10 → 0.06 (min 0.01 이므로 범위 조정 불필요)
|
||||
"DAY_TRADE_PER_STOCK_RATIO": ("DAY_TRADE", "종목당 자본 비율", "1종목당 최대 투입 자본 비율", "float", "0.06", "0.01","0.5"),
|
||||
"DAY_TRADE_STOP_LOSS_PCT": ("DAY_TRADE", "데이트레이딩 손절 비율", "종목 손절선 (%)", "float", "0.02", "0.005","0.1"),
|
||||
"DAY_TRADE_STOP_LOSS_PCT": ("DAY_TRADE", "복원 폴백 손절", "재시작 복원 폴백 손절선 (실제 진입 손절은 전략별 ORB/REVERSION 손절)", "float", "0.02", "0.005","0.1"),
|
||||
"DAY_TRADE_DAILY_LOSS_LIMIT": ("DAY_TRADE", "일일 손실 한도", "일일 손실 한도 초과 시 데이트레이딩 중단 (%)", "float", "-0.03", "-0.2", "-0.005"),
|
||||
"DAY_TRADE_EOD_FLAT_HOUR": ("DAY_TRADE", "EOD 강제 청산 시 (시)", "장 마감 전 강제 청산 시작 시각 (시)", "int", "15", "13", "15"),
|
||||
"DAY_TRADE_EOD_FLAT_MIN": ("DAY_TRADE", "EOD 강제 청산 시 (분)", "장 마감 전 강제 청산 시작 시각 (분)", "int", "20", "0", "59"),
|
||||
@@ -144,16 +144,102 @@ DEFAULT_PARAMS: Dict[str, tuple] = {
|
||||
"SHORT_TERM_VOLUME_MULTIPLIER": ("GENERAL", "단기 전략 거래량 배수", "단기 전략 진입 조건: 거래량 배수", "float", "1.3", "1.0", "5.0"),
|
||||
}
|
||||
|
||||
# [2026-10-09] DAY_TRADE 그룹은 **settings(config.py) 단일 소스**다. DT 엔진
|
||||
# (market_schedule._dt_eod_flat, day_trading_strategy, daytrading/*)은 strategy_cfg 를 읽지 않으므로
|
||||
# UI 에서 값을 바꿔도 아무 효과가 없었다(예: RSI_BREAKEVEN 40 표시 vs 실제 RSI_BB 게이트 35).
|
||||
# 효과 없는 편집이 "적용된 것처럼" 보이는 것을 막기 위해 쓰기·캐시 노출·시드를 차단한다.
|
||||
# 배선하려면 §0 우선순위 결정과 백테스트 근거가 필요하다.
|
||||
READ_ONLY_GROUPS = frozenset({"DAY_TRADE"})
|
||||
# [2026-10-09 R5] RISK 그룹 중 실매매 경로가 strategy_cfg 를 읽지 않는 키(settings 단일 소스).
|
||||
# UI 에서 낮춰도(예: MAX_POSITION_SIZE 0.04→0.02) 실매매는 settings 값을 쓰므로 "위험을 줄였다"는 거짓 믿음을 만든다.
|
||||
# 실매매에 배선된 RISK 키(STOP_LOSS_*, ATR_*_MULTIPLIER)와 MDD_*(swing/scheduler 가 불일치 경보)는 제외한다.
|
||||
# 배선하려면 §0 우선순위 결정이 필요하며 MAX_POSITION_SIZE 는 min(strategy_cfg, settings) 로만 허용해야 한다.
|
||||
# 백테스트/최적화(strategy_cfg.get)는 이 키들에 대해 settings 폴백을 받는다(실전과 동일 값).
|
||||
READ_ONLY_RISK_KEYS = frozenset({
|
||||
"MAX_POSITION_SIZE", "MAX_DAILY_TRADES", "MAX_HOLDINGS_COUNT",
|
||||
"MIN_PRICE_KRW", "MIN_PRICE_USD",
|
||||
"CONSECUTIVE_LOSS_BAN_COUNT", "CONSECUTIVE_LOSS_BAN_DAYS",
|
||||
"MEAN_REVERSION_MAX_CAPITAL", "TAKE_PROFIT_PERCENT",
|
||||
})
|
||||
READ_ONLY_KEYS = frozenset(
|
||||
{k for k, v in DEFAULT_PARAMS.items() if v[0] in READ_ONLY_GROUPS} | READ_ONLY_RISK_KEYS
|
||||
)
|
||||
|
||||
|
||||
# [2026-10-09] 표시 키 -> 엔진이 실제로 읽는 settings 키. 별칭 키는 소비자가 없어
|
||||
# settings 값을 그대로 보여주면 엔진 실효값과 어긋난다(RSI_BB 40 표시 vs 게이트 35).
|
||||
DT_EFFECTIVE_KEY_ALIASES: Dict[str, str] = {
|
||||
"DAY_TRADE_RSI_BREAKEVEN_THRESHOLD": "DAY_TRADE_RSI_BB_RSI_THRESHOLD",
|
||||
"DAY_TRADE_EOD_FLAT_HOUR": "DAY_TRADE_EOD_HOUR",
|
||||
"DAY_TRADE_EOD_FLAT_MIN": "DAY_TRADE_EOD_MIN",
|
||||
"DAY_TRADE_PER_STOCK_RATIO": "DAY_TRADE_MAX_CAPITAL_RATIO",
|
||||
"DAY_TRADE_CAPITAL_RATIO": "DAY_TRADE_MAX_RATIO_DT",
|
||||
"DAY_TRADE_ORB_VOL_MULT": "DAY_TRADE_ORB_BREAKOUT_VOL_MULT",
|
||||
}
|
||||
# DB 행 라벨은 시드 후 갱신되지 않으므로 직렬화 시 덮어쓴다.
|
||||
DT_LABEL_OVERRIDES: Dict[str, str] = {
|
||||
"DAY_TRADE_STOP_LOSS_PCT": "복원 폴백 손절",
|
||||
}
|
||||
|
||||
|
||||
def _effective_settings_key_v2(key: str) -> str:
|
||||
return DT_EFFECTIVE_KEY_ALIASES.get(key, key)
|
||||
|
||||
|
||||
def is_read_only_key(key: str, group: Optional[str] = None) -> bool:
|
||||
return key in READ_ONLY_KEYS or (group in READ_ONLY_GROUPS) or key.startswith("DAY_TRADE_") # READ_ONLY_KEYS 에 RISK 미배선 키 포함
|
||||
|
||||
|
||||
def get_effective_dt_params_v2() -> Dict[str, Any]:
|
||||
"""DT 엔진이 실제로 쓰는 값(settings). UI 표시용 — strategy_configs 값이 아니다."""
|
||||
from app.core.config import settings
|
||||
out: Dict[str, Any] = {}
|
||||
for k in sorted(READ_ONLY_KEYS):
|
||||
ek = _effective_settings_key_v2(k)
|
||||
if hasattr(settings, ek):
|
||||
out[k] = getattr(settings, ek)
|
||||
return out
|
||||
|
||||
|
||||
def serialize_config_v2(cfg: Any) -> Dict[str, Any]:
|
||||
"""API 응답용 직렬화. read_only 행은 DB 값 대신 settings 실효값을 표시한다(거짓 표시 방지)."""
|
||||
from app.core.config import settings
|
||||
d = {
|
||||
"id": cfg.id, "key": cfg.key, "group": cfg.group, "label": cfg.label,
|
||||
"description": cfg.description, "value_type": cfg.value_type,
|
||||
"value": cfg.value, "default_value": cfg.default_value,
|
||||
"min_value": cfg.min_value, "max_value": cfg.max_value,
|
||||
"is_active": cfg.is_active, "updated_by": cfg.updated_by,
|
||||
"read_only": False,
|
||||
}
|
||||
if is_read_only_key(cfg.key, cfg.group):
|
||||
d["read_only"] = True
|
||||
ek = _effective_settings_key_v2(cfg.key)
|
||||
if cfg.key in DT_LABEL_OVERRIDES:
|
||||
d["label"] = DT_LABEL_OVERRIDES[cfg.key]
|
||||
if hasattr(settings, ek):
|
||||
eff = str(getattr(settings, ek))
|
||||
d["value"] = eff
|
||||
d["default_value"] = eff
|
||||
d["updated_by"] = "settings"
|
||||
return d
|
||||
|
||||
|
||||
class StrategyConfigService:
|
||||
"""전략 파라미터 DB 캐시 서비스."""
|
||||
|
||||
CACHE_TTL = 30 # seconds
|
||||
STALE_ERROR_SEC = 300 # 갱신 실패 지속 시 error 로그/health 경보 기준
|
||||
|
||||
def __init__(self):
|
||||
self._cache: Dict[str, Any] = {}
|
||||
self._cache_ts: float = 0.0
|
||||
self._initialized = False
|
||||
# 동시 갱신 1건만 허용(in-flight 가드) + 태스크 참조 보관(GC 방지)
|
||||
self._refresh_inflight: bool = False
|
||||
self._refresh_task: Optional["asyncio.Future"] = None
|
||||
self._last_success_ts: float = 0.0 # monotonic, 마지막 성공 시각
|
||||
self._consecutive_failures: int = 0
|
||||
|
||||
# ── public API ────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -165,7 +251,13 @@ class StrategyConfigService:
|
||||
try:
|
||||
loop = asyncio.get_event_loop()
|
||||
if loop.is_running():
|
||||
asyncio.ensure_future(self._refresh())
|
||||
if not self._refresh_inflight:
|
||||
self._refresh_inflight = True
|
||||
try:
|
||||
self._refresh_task = asyncio.ensure_future(self._refresh())
|
||||
except Exception:
|
||||
self._refresh_inflight = False
|
||||
raise
|
||||
else:
|
||||
loop.run_until_complete(self._refresh())
|
||||
except Exception as _swallowed:
|
||||
@@ -189,6 +281,8 @@ class StrategyConfigService:
|
||||
try:
|
||||
async with AsyncSessionLocal() as session:
|
||||
for key, (group, label, desc, vtype, default, vmin, vmax) in DEFAULT_PARAMS.items():
|
||||
if is_read_only_key(key, group):
|
||||
continue # DT 는 settings 단일 소스 — 무효 행을 새로 시드하지 않는다
|
||||
stmt = pg_insert(StrategyConfig).values(
|
||||
key=key,
|
||||
group=group,
|
||||
@@ -219,6 +313,8 @@ class StrategyConfigService:
|
||||
|
||||
async def upsert(self, key: str, value: str, updated_by: str = "api") -> Optional[StrategyConfig]:
|
||||
"""파라미터 값을 업데이트하고 캐시를 무효화."""
|
||||
if is_read_only_key(key):
|
||||
raise ValueError(f"{key}: DAY_TRADE 파라미터는 config.py(settings) 단일 소스이며 UI 로 변경할 수 없습니다")
|
||||
try:
|
||||
async with AsyncSessionLocal() as session:
|
||||
result = await session.execute(
|
||||
@@ -252,6 +348,8 @@ class StrategyConfigService:
|
||||
|
||||
async def reset_to_default(self, key: str) -> Optional[StrategyConfig]:
|
||||
"""파라미터를 기본값으로 리셋."""
|
||||
if is_read_only_key(key):
|
||||
raise ValueError(f"{key}: DAY_TRADE 파라미터는 settings 단일 소스라 리셋 대상이 아닙니다")
|
||||
async with AsyncSessionLocal() as session:
|
||||
result = await session.execute(
|
||||
select(StrategyConfig).where(StrategyConfig.key == key)
|
||||
@@ -280,9 +378,27 @@ class StrategyConfigService:
|
||||
def _is_cache_stale(self) -> bool:
|
||||
return (time.monotonic() - self._cache_ts) > self.CACHE_TTL
|
||||
|
||||
def get_cache_health_v2(self) -> Dict[str, Any]:
|
||||
"""/health 노출용 캐시 상태. 마지막 성공 후 STALE_ERROR_SEC 초과면 stale_alarm=True."""
|
||||
age = (time.monotonic() - self._last_success_ts) if self._last_success_ts else None
|
||||
return {
|
||||
"age_sec": None if age is None else round(age, 1),
|
||||
"consecutive_failures": self._consecutive_failures,
|
||||
"stale_alarm": age is None or age > self.STALE_ERROR_SEC,
|
||||
"keys": len(self._cache),
|
||||
}
|
||||
|
||||
async def _refresh(self):
|
||||
"""DB에서 전체 파라미터를 읽어 캐시 갱신."""
|
||||
"""DB에서 전체 파라미터를 읽어 캐시 갱신. 실패 시 _cache_ts 를 밀어 TTL 만큼 백오프."""
|
||||
self._refresh_inflight = True
|
||||
try:
|
||||
await self._refresh_impl()
|
||||
finally:
|
||||
self._refresh_inflight = False
|
||||
|
||||
async def _refresh_impl(self):
|
||||
try:
|
||||
# DB-READ-EXCEPTION: SELECT 전용(쓰기 없음), in-flight 1건 + TTL 백오프로 호출 폭주 차단
|
||||
async with AsyncSessionLocal() as session:
|
||||
result = await session.execute(
|
||||
select(StrategyConfig).where(StrategyConfig.is_active == True)
|
||||
@@ -291,6 +407,8 @@ class StrategyConfigService:
|
||||
|
||||
new_cache: Dict[str, Any] = {}
|
||||
for row in rows:
|
||||
if is_read_only_key(row.key, row.group):
|
||||
continue # settings 단일 소스 — strategy_cfg.get 으로 새어 나가지 않게 한다
|
||||
try:
|
||||
new_cache[row.key] = self._cast(row.value, row.value_type)
|
||||
except Exception:
|
||||
@@ -299,8 +417,17 @@ class StrategyConfigService:
|
||||
|
||||
self._cache = new_cache
|
||||
self._cache_ts = time.monotonic()
|
||||
self._last_success_ts = self._cache_ts
|
||||
self._consecutive_failures = 0
|
||||
except Exception as e:
|
||||
logger.warning(f"[StrategyConfig] 캐시 갱신 실패 (기존 캐시 유지): {e}")
|
||||
self._consecutive_failures += 1
|
||||
# 실패해도 _cache_ts 를 현재로 밀어 다음 시도를 CACHE_TTL 뒤로 미룬다(백오프)
|
||||
self._cache_ts = time.monotonic()
|
||||
logger.warning(f"[StrategyConfig] 캐시 갱신 실패 (기존 캐시 유지, 연속 {self._consecutive_failures}회): {e}")
|
||||
if self._last_success_ts:
|
||||
age = time.monotonic() - self._last_success_ts
|
||||
if age > self.STALE_ERROR_SEC:
|
||||
logger.error(f"[StrategyConfig] 전략 파라미터 캐시가 {age:.0f}초째 낡음 — 옛 값으로 매매 중")
|
||||
|
||||
@staticmethod
|
||||
def _cast(value: str, value_type: str) -> Any:
|
||||
|
||||
Reference in New Issue
Block a user