## 배경 "전략이 혼란스럽고 현재와 맞지 않는다"를 조사한 결과, 전략 설계가 아니라 전략을 검증했다는 도구와 파라미터 소스가 어긋나 있었다. ## 파라미터 소스 단일화 - 우선순위가 adaptive > strategy_cfg(DB) > config.py 인데 문서는 맨 마지막인 config.py 를 기술하고 있었다. 11곳이 불일치(BULL RSI 46 vs DB 30, SL -7% vs -6%). - DEFAULT_PARAMS 를 config.py 에 정합 + alembic 0012 로 기존 DB 행 갱신 (운영자가 UI 로 바꾼 값은 보존, 옛 기본값과 같은 행만 갱신, 테이블 가드·멱등) ## 백테스트를 실전 로직에 정합 - calculate_adx_simple 이 정확히 period 배(14.0×) 발산 → Aggressive 게이트 `ADX>25` 가 전 레짐 100% 통과. 전환 빈도 5.6%→2.0%(-65%), 성과 상방 편향 - 백테스트 RSI 는 SMA, 실전은 Wilder → 진입 판정 10~11% 불일치 - us_backtest_engine RSI 1봉 지연 제거 (룩어헤드 없음을 절단 테스트로 증명) - backend/backtest_results/ 25건은 폐기 대상 (§8.4) ## 리스크 가드 복원 - 4% 절대 상한이 try 블록 안에 있어 예외 시 적응형 6~7% 가 통과 → try 밖으로 - 청산을 매수보다 먼저 평가 (보유 종목이 추천에 들면 손절 대신 물타기가 나갔다) - 리스크 가드 4개를 매수 전용화 → MDD -10%·보유한도 도달 시에도 손절 작동 - MDD 갱신 5회 연속 실패 시 신규 진입 차단 fail-safe (청산은 계속 허용) - 스윙 청산이 DT 보유분까지 매도하던 문제, sell_pct 기준 혼재 정정 ## 죽은 코드 제거 (2,695줄) - short_term_strategy, kosdaq_scanner, app/strategy/ 패키지 - app/trading/ + korea_investment_api_adapter (real_market_api 정본은 korea_investment_api.py:1482, adapter 는 import 0건) ## 지표·사이징 단일화 (수치 무변경 증명) - indicators_core.py: 3,274건 비교 최대 오차 0.000e+00 - sizing_core.py: 42,105건 비교 불일치 0 - 미국 전략 상수 13개 config 편입 (hex 까지 동일, 3,168 케이스 전수 비교) ## 테스트 안전 (실 KIS 호출 차단) - 유닛 테스트가 실제 KIS 에 매도 주문을 2회 전송하고 있었다. 리팩터링이 close_stale_positions 의 대상을 _execute_exit → _execute_exit_stale 로 바꿨는데 목이 전자에 걸려 빗나갔다. test_api_endpoints 도 캡처된 싱글톤 참조 누수로 실제 잔고 조회를 타 141초를 소비했다. - backend/tests/conftest.py 신설: 주문·토큰·시세·잔고를 호출 즉시 예외로 차단. 예외는 BaseException 파생(프로덕션 except Exception 폴백에 삼켜지지 않게), 클래스 메서드에 적용(캡처된 참조까지 덮음), DATABASE_URL `_test` 가드 강제 - 전체 유닛 스위트: 365초/6 failed → 11.8초/0 failed (소켓 차단 하 559 passed) ## 신규 (둘 다 플래그 OFF, 백테스트 미검증) - range_trading_strategy.py — SIDEWAYS 담당 전략이 없던 빈 칸. 미배선 - check_bear_buy_signal_v2 — BEAR 4중 AND(발화율 2.3%) → 점수제(12.4%) ## 문서 - CLAUDE.md 를 실제 코드 기준으로 재작성. §0 파라미터 우선순위, §4.1 테스트 규약, §6.4 문서화되지 않았던 진입 차단 게이트 9종, §8.4 백테스트 폐기 근거, §8.5 미해결 19항목 - 루트 md 6개 → 2개(docs/ 이동), 중복 배치 문서 병합, 스테일 worktree 제거 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
155 lines
6.7 KiB
Python
155 lines
6.7 KiB
Python
"""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],
|
|
)
|