diff --git a/database/models.py b/database/models.py index f0adb931..da2369f9 100644 --- a/database/models.py +++ b/database/models.py @@ -654,6 +654,74 @@ def _migrate_snapshot_unique_constraint(engine): logger.info("portfolio_snapshots 재구축 완료: {}행, 복합 유니크(account_key, date)", after) +def _migrate_position_unique_constraint(engine): + """positions의 구버전 UNIQUE(symbol) 단독 제약을 (account_key, symbol)로 재구축. + + account_key 도입 전 스키마의 유산(스냅샷 UNIQUE(date)와 같은 계열): symbol 단독 + 유니크라 서로 다른 계좌가 같은 종목을 드는 순간 IntegrityError — 매매 기록은 + 남는데 포지션만 유실돼 평가액이 현금만 남는다(2026-07-07 실측: 트랙 재시작으로 + 아카이브 키에 069500이 남은 상태에서 본 키가 069500 재매수 → 스냅샷 -41%). + 아카이브/본 키 조합만이 아니라 바스켓·전략 트랙이 같은 종목을 겹쳐 들 수 없는 + 구조적 지뢰다. 모델은 이미 복합 제약인데 물리 테이블만 낡았다(create_all은 + 기존 테이블을 못 바꾼다). 표준 재구축(rename → 생성 → 복사 → 검증 → 삭제), + 멱등·중단 재개 가능 — 스냅샷 마이그레이션과 동일 절차. + """ + from sqlalchemy import text + + if engine.url.get_dialect().name != "sqlite": + return + cols = ( + "id, symbol, avg_price, quantity, total_invested, stop_loss_price, " + "take_profit_price, trailing_stop_price, highest_price, strategy, " + "bought_at, updated_at, account_key, partial_tp_done" + ) + legacy_name = "positions_legacy_uq" + + with engine.connect() as conn: + legacy_exists = conn.execute(text( + "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name=:n" + ), {"n": legacy_name}).scalar() + + if not legacy_exists: + ddl = conn.execute(text( + "SELECT sql FROM sqlite_master WHERE type='table' AND name='positions'" + )).scalar() + if not ddl: + return + normalized = " ".join(str(ddl).split()).lower() + is_legacy = ( + "unique (symbol)" in normalized + and "uq_positions_account_symbol" not in normalized + ) + if not is_legacy: + return + logger.warning( + "positions 구버전 UNIQUE(symbol) 감지 — (account_key, symbol) 복합 제약으로 재구축" + ) + conn.execute(text( + f"ALTER TABLE positions RENAME TO {legacy_name}" + )) + conn.commit() + + # 신 스키마 재생성 (rename으로 본 테이블이 사라졌으므로 create_all이 새로 만든다) + Base.metadata.create_all(engine) + + with engine.connect() as conn: + before = conn.execute(text(f"SELECT COUNT(*) FROM {legacy_name}")).scalar() + conn.execute(text( + f"INSERT OR IGNORE INTO positions ({cols}) SELECT {cols} FROM {legacy_name}" + )) + after = conn.execute(text("SELECT COUNT(*) FROM positions")).scalar() + if after < before: + conn.rollback() + raise RuntimeError( + f"포지션 재구축 검증 실패: 복사 후 {after} < 원본 {before} — legacy 테이블 보존" + ) + conn.execute(text(f"DROP TABLE {legacy_name}")) + conn.commit() + logger.info("positions 재구축 완료: {}행, 복합 유니크(account_key, symbol)", after) + + def init_database(): """ 데이터베이스 초기화 @@ -686,6 +754,10 @@ def init_database(): _migrate_positions_partial_tp_done(engine) except Exception: pass + # 구버전 UNIQUE(symbol) 재구축 — 스냅샷 UNIQUE(date)와 같은 이유로 조용히 넘기지 + # 않는다(계좌 간 동일 종목 보유가 막혀 매수 포지션이 유실되는 상태). legacy가 + # 아니면 no-op. partial_tp_done 컬럼 추가 이후에 실행해야 복사 컬럼이 갖춰진다. + _migrate_position_unique_constraint(engine) if "sqlite" in engine.url.drivername: from sqlalchemy import text diff --git a/tests/test_position_unique_migration.py b/tests/test_position_unique_migration.py new file mode 100644 index 00000000..d1878062 --- /dev/null +++ b/tests/test_position_unique_migration.py @@ -0,0 +1,131 @@ +"""positions 구버전 UNIQUE(symbol) → (account_key, symbol) 재구축 마이그레이션 테스트. + +배경(2026-07-07 실측): account_key 도입 전 스키마의 symbol 단독 유니크가 물리 +테이블에 남아 있어(모델은 이미 복합 제약 — create_all은 기존 테이블을 못 바꾼다), +트랙 재시작으로 아카이브 키에 069500이 남은 상태에서 본 키가 069500을 재매수하는 +순간 IntegrityError — 매매 기록은 남고 포지션만 유실돼 평가액이 현금만 남았다 +(스냅샷 -41%, 유령 MDD가 리스크 가드까지 발동). 아카이브 조합만이 아니라 바스켓· +전략 트랙이 같은 종목을 겹쳐 드는 순간 언제든 터질 지뢰였다. 스냅샷 UNIQUE(date) +재구축(#439)과 같은 계열·같은 절차. 멱등이고 행수를 보존해야 한다. +""" +from datetime import datetime + +import pytest +from sqlalchemy import create_engine, text + +from database.models import Base, _migrate_position_unique_constraint + +LEGACY_DDL = """ +CREATE TABLE positions ( + id INTEGER NOT NULL, + symbol VARCHAR(20) NOT NULL, + avg_price FLOAT NOT NULL, + quantity INTEGER NOT NULL, + total_invested FLOAT NOT NULL, + stop_loss_price FLOAT, + take_profit_price FLOAT, + trailing_stop_price FLOAT, + highest_price FLOAT, + strategy VARCHAR(50), + bought_at DATETIME, + updated_at DATETIME, account_key VARCHAR(64) DEFAULT '' NOT NULL, partial_tp_done BOOLEAN DEFAULT 0 NOT NULL, + PRIMARY KEY (id), + UNIQUE (symbol) +) +""" + + +def _legacy_engine(tmp_path, symbols=("005930", "069500")): + engine = create_engine(f"sqlite:///{tmp_path / 'legacy.db'}") + with engine.connect() as conn: + conn.execute(text(LEGACY_DDL)) + for i, sym in enumerate(symbols): + conn.execute(text( + "INSERT INTO positions " + "(symbol, avg_price, quantity, total_invested, strategy, account_key, bought_at, updated_at) " + "VALUES (:s, :p, 1, :p, 'basket_rebalance:kr_x', 'basket_rebalance:kr_x', :t, :t)" + ), {"s": sym, "p": 100000.0 + i, "t": datetime(2026, 7, 1 + i)}) + conn.commit() + return engine + + +class TestPositionUniqueMigration: + def test_rebuild_preserves_rows_and_allows_same_symbol_two_accounts(self, tmp_path): + engine = _legacy_engine(tmp_path) + _migrate_position_unique_constraint(engine) + + with engine.connect() as conn: + assert conn.execute(text("SELECT COUNT(1) FROM positions")).scalar() == 2 + # 데이터 보존 확인 + avg = conn.execute(text( + "SELECT avg_price FROM positions WHERE symbol = '069500'" + )).scalar() + assert avg == pytest.approx(100001.0) + # 핵심: 같은 종목을 다른 계좌(아카이브 키·전략 트랙)가 이제 들 수 있어야 한다 + conn.execute(text( + "INSERT INTO positions (symbol, avg_price, quantity, total_invested, account_key, partial_tp_done) " + "VALUES ('069500', 123810, 1, 123810, 'basket_rebalance:kr_pocket', 0)" + )) + conn.commit() + assert conn.execute(text("SELECT COUNT(1) FROM positions")).scalar() == 3 + # 같은 계좌·같은 종목은 여전히 차단(복합 유니크) + with pytest.raises(Exception): + conn.execute(text( + "INSERT INTO positions (symbol, avg_price, quantity, total_invested, account_key, partial_tp_done) " + "VALUES ('069500', 1, 1, 1, 'basket_rebalance:kr_pocket', 0)" + )) + + def test_idempotent_on_new_schema(self, tmp_path): + engine = create_engine(f"sqlite:///{tmp_path / 'new.db'}") + Base.metadata.create_all(engine) + _migrate_position_unique_constraint(engine) # no-op이어야 함 + _migrate_position_unique_constraint(engine) + with engine.connect() as conn: + ddl = conn.execute(text( + "SELECT sql FROM sqlite_master WHERE type='table' AND name='positions'" + )).scalar() + assert "uq_positions_account_symbol" in ddl + + def test_resume_after_interrupted_rename(self, tmp_path): + # rename 직후 중단된 상태(legacy 테이블 존재 + 본 테이블 부재)에서 재개 + engine = _legacy_engine(tmp_path) + with engine.connect() as conn: + conn.execute(text("ALTER TABLE positions RENAME TO positions_legacy_uq")) + conn.commit() + _migrate_position_unique_constraint(engine) + with engine.connect() as conn: + assert conn.execute(text("SELECT COUNT(1) FROM positions")).scalar() == 2 + legacy = conn.execute(text( + "SELECT COUNT(1) FROM sqlite_master WHERE type='table' AND name='positions_legacy_uq'" + )).scalar() + assert legacy == 0 + + def test_operational_incident_sequence(self, tmp_path): + """7/7 실측 시나리오 재연: 아카이브 키가 같은 종목을 든 상태에서 본 키 매수. + + 마이그레이션 전에는 IntegrityError(포지션 유실 — 유령 -41% 스냅샷의 뿌리), + 마이그레이션 후에는 두 행이 공존해야 한다. + """ + engine = _legacy_engine(tmp_path, symbols=("069500",)) + with engine.connect() as conn: + conn.execute(text( + "UPDATE positions SET account_key = 'basket_rebalance:kr_pocket@archived-20260707', " + "strategy = 'basket_rebalance:kr_pocket@archived-20260707'" + )) + conn.commit() + with pytest.raises(Exception): + conn.execute(text( + "INSERT INTO positions (symbol, avg_price, quantity, total_invested, account_key) " + "VALUES ('069500', 123810, 1, 123810, 'basket_rebalance:kr_pocket')" + )) + _migrate_position_unique_constraint(engine) + with engine.connect() as conn: + conn.execute(text( + "INSERT INTO positions (symbol, avg_price, quantity, total_invested, account_key, partial_tp_done) " + "VALUES ('069500', 123810, 1, 123810, 'basket_rebalance:kr_pocket', 0)" + )) + conn.commit() + n = conn.execute(text( + "SELECT COUNT(1) FROM positions WHERE symbol = '069500'" + )).scalar() + assert n == 2