"""refresh stale strategy_configs parameter values (data-only migration) Revision ID: 0012 Revises: 0011 Create Date: 2026-09-27 [배경] `strategy_config_service.auto_init()` 은 `on_conflict_do_nothing(index_elements=["key"])` 이므로 이미 존재하는 행의 `value` / `default_value` / `min_value` / `max_value` 를 절대 갱신하지 않는다. 또한 `reset_to_default()` 는 `cfg.value = cfg.default_value`, 즉 **DB 컬럼** `default_value` 를 사용한다. 따라서 코드의 `DEFAULT_PARAMS` 를 수정해도 DB에 행이 있는 한 옛 값이 계속 런타임에 적용된다. 이 마이그레이션이 그 간극을 메운다. [갱신 규칙] - `default_value` (및 MEAN_REVERSION_RSI_THRESHOLD 의 `max_value`) 는 코드에서 파생된 값이므로 **항상** 새 값으로 덮어쓴다. - `value` 는 **옛 기본값과 수치적으로 동일할 때만** 새 값으로 갱신한다. 운영자가 UI 로 직접 바꾼 값(옛 기본값과 다른 값)은 보존한다. - 모든 값 컬럼이 `String` 이므로 `"0.10"` vs `"0.1"`, `"-6.0"` vs `"-6"` 같은 표기 차이를 흡수하기 위해 `numeric` 캐스팅 후 비교한다. 숫자로 파싱 불가한 값은 정규식 가드로 제외해 캐스팅 예외를 방지한다. - 해당 key 의 행이 없으면 아무것도 하지 않는다 (`auto_init` 이 새 값으로 seed 한다). [downgrade 한계] upgrade() 가 "실제로 바꾼 행" 은 별도 저장 없이 식별할 수 없다. downgrade() 는 대칭 규칙(값이 새 기본값과 수치적으로 같을 때만 옛 값으로 되돌림 + default_value/ max_value 는 항상 옛 값 복원)을 적용한다. 그 결과 **운영자가 UI 로 새 기본값과 동일한 값을 직접 설정했던 경우, downgrade 시 그 설정이 옛 값으로 되돌아간다.** 이는 데이터 전용 마이그레이션의 본질적 한계이며 스키마에는 영향이 없다. 스키마 변경(DDL)은 전혀 없다. """ from alembic import op import sqlalchemy as sa # revision identifiers, used by Alembic. revision = "0012" down_revision = "0011" branch_labels = None depends_on = None # (key, 옛 기본값, 새 기본값) # 값은 모두 문자열 컬럼(strategy_configs.value / .default_value)에 저장되는 표기이며, # 비교는 numeric 캐스팅 후 수행하므로 표기 차이는 문제되지 않는다. _PARAM_UPDATES = [ ("ANALYSIS_STOCK_COUNT", "150", "300"), ("DAY_TRADE_CAPITAL_RATIO", "0.25", "0.30"), ("DAY_TRADE_MAX_POSITIONS", "3", "5"), ("DAY_TRADE_ORB_STOP_LOSS_PCT", "0.02", "0.03"), ("DAY_TRADE_PER_STOCK_RATIO", "0.10", "0.06"), ("DAY_TRADE_REVERSION_STOP_LOSS_PCT", "0.025", "0.035"), ("DAY_TRADE_TRAILING_TRIGGER", "0.015", "0.01"), ("MEAN_REVERSION_RSI_THRESHOLD", "30.0", "46.0"), ("SHORT_TERM_VOLUME_MULTIPLIER", "1.5", "1.3"), ("STOP_LOSS_PERCENT", "-6.0", "-7.0"), # [L2 2026-09-27] 연속 손실 진입금지 기간: 30거래일 ≈ 42캘린더일로 변경됐으나 # 기존 DB 행에는 "30" 이 남아 UI 표시/reset_to_default() 가 코드(settings=42)와 어긋난다. # strategy_config_service.DEFAULT_PARAMS: ("RISK", ..., "int", "42", min="1", max="180") # → min/max 범위(1~180)가 42를 포함하므로 max_value 갱신은 불필요하다. ("CONSECUTIVE_LOSS_BAN_DAYS", "30", "42"), ] # max_value 도 함께 넓혀야 하는 키: (key, 옛 max, 새 max) _MAX_VALUE_UPDATES = [ ("MEAN_REVERSION_RSI_THRESHOLD", "50", "60"), ] _TABLE = "strategy_configs" # 숫자로 안전하게 캐스팅 가능한 문자열만 통과시키는 가드 _NUMERIC_GUARD = r"^\s*[+-]?[0-9]+(\.[0-9]+)?\s*$" # value 는 "기존 값이 기대한 기본값과 수치적으로 같을 때만" 갱신 _UPDATE_VALUE_SQL = sa.text( """ UPDATE strategy_configs SET value = :new_value WHERE key = :key AND value ~ :guard AND CAST(trim(value) AS numeric) = CAST(:expected AS numeric) AND CAST(trim(value) AS numeric) <> CAST(:new_value AS numeric) """ ) # default_value 는 코드 파생값이므로 무조건 갱신 (불필요한 write 는 회피) _UPDATE_DEFAULT_SQL = sa.text( """ UPDATE strategy_configs SET default_value = :new_value WHERE key = :key AND default_value IS DISTINCT FROM :new_value """ ) _UPDATE_MAX_SQL = sa.text( """ UPDATE strategy_configs SET max_value = :new_max WHERE key = :key AND max_value IS DISTINCT FROM :new_max """ ) def _has_table(conn, table: str) -> bool: """0011_backfill_legacy_owner._has_table 와 동일한 가드 패턴.""" return conn.execute(sa.text( "SELECT 1 FROM information_schema.tables WHERE table_name=:t" ), {"t": table}).fetchone() is not None def _apply(pairs, max_pairs): """pairs: (key, expected_old_value, target_value) 순서로 value/default_value 갱신.""" conn = op.get_bind() # [M2 2026-09-27] `strategy_configs` 는 alembic 이 만들지 않고 앱 기동 시 # `Base.metadata.create_all` 로만 생성된다. 그런데 배포 순서는 # "alembic upgrade head → 앱 기동" 이므로 신규 볼륨에서는 이 테이블이 아직 없다. # 가드 없이 UPDATE 하면 UndefinedTableError → 마이그레이션 전체 롤백 → 배포 중단. # 테이블이 없으면 조용히 no-op (앱 기동 시 auto_init 이 새 값으로 seed 한다). if not _has_table(conn, _TABLE): print(f"[0012] '{_TABLE}' 테이블 없음 — 데이터 갱신 스킵 (auto_init 이 새 값으로 seed)") return for key, expected, target in pairs: # 1) 운영자가 손대지 않은 행의 value 만 갱신 (default_value 갱신보다 먼저!) conn.execute( _UPDATE_VALUE_SQL, {"key": key, "expected": expected, "new_value": target, "guard": _NUMERIC_GUARD}, ) # 2) default_value 는 항상 코드 값으로 동기화 (reset_to_default 가 이 컬럼을 사용) conn.execute(_UPDATE_DEFAULT_SQL, {"key": key, "new_value": target}) for key, new_max in max_pairs: conn.execute(_UPDATE_MAX_SQL, {"key": key, "new_max": new_max}) def upgrade() -> None: _apply( [(key, old, new) for key, old, new in _PARAM_UPDATES], [(key, new_max) for key, _old_max, new_max in _MAX_VALUE_UPDATES], ) def downgrade() -> None: # 대칭 복원: 새 기본값과 동일한 value 만 옛 값으로 되돌리고, # default_value / max_value 는 옛 값으로 복원한다 (docstring 의 한계 참조). _apply( [(key, new, old) for key, old, new in _PARAM_UPDATES], [(key, old_max) for key, old_max, _new_max in _MAX_VALUE_UPDATES], )