Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
72 changes: 72 additions & 0 deletions database/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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():
"""
데이터베이스 초기화
Expand Down Expand Up @@ -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
Expand Down
131 changes: 131 additions & 0 deletions tests/test_position_unique_migration.py
Original file line number Diff line number Diff line change
@@ -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
Loading