From 17345ee47d6dd1d425e0a13e888756d6f4e3f03d Mon Sep 17 00:00:00 2001 From: Quant Trader Date: Fri, 10 Jul 2026 11:38:24 +0900 Subject: [PATCH] =?UTF-8?q?fix:=20positions=20=EA=B5=AC=EB=B2=84=EC=A0=84?= =?UTF-8?q?=20UNIQUE(symbol)=EB=A5=BC=20(account=5Fkey,=20symbol)=EB=A1=9C?= =?UTF-8?q?=20=EC=9E=AC=EA=B5=AC=EC=B6=95=20=E2=80=94=20=EC=9C=A0=EB=A0=B9?= =?UTF-8?q?=20-41%=20=EB=82=99=ED=8F=AD=EC=9D=98=20=EB=BF=8C=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 트랙 재시작 직후 첫 매수에서 매매 기록은 남고 포지션만 사라졌다. 스냅샷이 현금만 집계돼 -41% 낙폭으로 찍혔고, 이 유령 MDD가 리스크 가드를 발동시켜 이후 사이클의 매수를 전부 차단했다. 원인은 스냅샷 UNIQUE(date) 때와 같은 계열: account_key 도입 전 시절의 UNIQUE(symbol) 단독 제약이 물리 테이블에 잔존(모델은 이미 복합 제약 — create_all은 기존 테이블을 못 바꾼다). 아카이브 키에 069500이 남은 상태에서 본 키가 재매수하자 IntegrityError로 포지션 저장이 실패했다. 재시작만이 아니라 바스켓과 전략 트랙이 같은 종목을 겹쳐 드는 순간 언제든 터질 지뢰. - 표준 재구축(rename -> 생성 -> INSERT OR IGNORE 복사 -> 행수 검증 -> 삭제), 멱등·중단 재개 가능, 행수 감소 시 RuntimeError로 legacy 보존 - init_database에서 조용히 삼키지 않음. partial_tp_done 컬럼 마이그레이션 뒤에 실행해 복사 컬럼 보장 - 테스트 7종: 행 보존, 재구축 후 두 계좌 동일 종목 공존, 같은 계좌 중복 차단 유지, no-op 멱등, 중단 재개, 실측 사고 시나리오 재연 운영 DB에는 이미 적용됨(다음날 첫 사이클의 init이 자동 수행, 10행 보존 로그 확인). 잔해(고아 매매·깨진 스냅샷) 정리는 별도 운영 조치로 진행. --- database/models.py | 72 +++++++++++++ tests/test_position_unique_migration.py | 131 ++++++++++++++++++++++++ 2 files changed, 203 insertions(+) create mode 100644 tests/test_position_unique_migration.py 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