Add ProofLine criteria documentation and Strategy Catalog v1 implementation

- Add acceptance criteria documentation (AC-0013 through AC-0023)
- Add Line-0003 discovery documentation
- Implement Strategy Catalog v1 with new database migration
- Update portfolio API endpoints and schemas
- Refactor strategy and portfolio services
- Add strategy management CLI commands
- Update domain models with strategy catalog support
- Add comprehensive tests for v1 strategies
- Update design documentation (data model, interfaces, runtime flows)
This commit is contained in:
2026-08-15 11:18:31 +09:00
parent 5fc389dea8
commit 6e02d92282
48 changed files with 2114 additions and 393 deletions
+11 -3
View File
@@ -583,8 +583,11 @@ class _PortfolioServiceFakeRepo:
"""FakeRepo for PortfolioService catalog validation tests."""
def __init__(self, asset: Asset | None = None) -> None:
from app.domain.models import StrategySet
self._asset = asset
self._strategy_sets: dict = {}
strategy = StrategySet(id="set-1", name="target_value", config="{}", nick="target_value:default")
self._strategy_sets: dict = {strategy.id: strategy}
self._portfolios: dict = {}
def get_asset_by_id(self, asset_id: str) -> Asset | None:
@@ -625,7 +628,7 @@ class _PortfolioServiceFakeRepo:
return p
def get_strategy_set(self, id: str):
return None
return self._strategy_sets.get(id)
def list_recent_trades(self, portfolio_id: str, *, limit: int = 5):
return []
@@ -633,7 +636,7 @@ class _PortfolioServiceFakeRepo:
def list_comments(self, portfolio_id: str):
return []
def list_strategy_sets(self):
def list_strategy_sets(self, *, include_inactive: bool = True):
return []
def list_portfolios(self, **kwargs):
@@ -669,6 +672,7 @@ def test_portfolio_create_with_active_asset() -> None:
name="Test_Portfolio",
asset_id=_CATALOG_ASSET_ID,
broker="kis",
strategy_id="set-1",
),
owner_user_id="user-1",
)
@@ -688,6 +692,7 @@ def test_portfolio_create_unknown_asset() -> None:
name="Test_Portfolio",
asset_id="nonexistent-uuid",
broker="kis",
strategy_id="set-1",
),
owner_user_id="user-1",
)
@@ -706,6 +711,7 @@ def test_portfolio_create_inactive_asset() -> None:
name="Test_Portfolio",
asset_id=_CATALOG_ASSET_ID,
broker="kis",
strategy_id="set-1",
),
owner_user_id="user-1",
)
@@ -745,6 +751,8 @@ def test_portfolio_create_by_symbol() -> None:
"portfolio", "create", "Test_Portfolio",
"--user", "alice",
"--symbol", "005930",
"--strategy-id",
"set-1",
"--force",
])
assert result.exit_code == 0, result.output
+68 -4
View File
@@ -469,9 +469,40 @@ class FakeRepo:
def get_strategy_set(self, id: str) -> StrategySet | None:
return self.strategy_set if self.strategy_set.id == id else None
def list_strategy_sets(self) -> list[StrategySet]:
def list_strategy_sets(self, *, include_inactive: bool = True) -> list[StrategySet]:
from datetime import datetime, timezone
return sorted(self._strategy_sets.values(), key=lambda s: s.created_at or datetime.now(timezone.utc), reverse=True)
values = list(self._strategy_sets.values())
if not include_inactive:
values = [item for item in values if item.status == "active"]
return sorted(values, key=lambda s: s.created_at or datetime.now(timezone.utc), reverse=True)
def update_strategy_set(
self,
id: str,
*,
name: str | None = None,
config: str | None = None,
status: str | None = None,
) -> StrategySet:
strategy = self._strategy_sets.get(id)
if strategy is None:
raise ValueError("전략을 찾을 수 없습니다")
final_name = name if name is not None else strategy.name
final_config = config if config is not None else strategy.config
final_status = status if status is not None else strategy.status
if (final_name, final_config, final_status) == (
strategy.name,
strategy.config,
strategy.status,
):
return strategy
strategy.name = final_name
strategy.config = final_config
strategy.status = final_status
strategy.nick = f"{strategy.name}:updated"
strategy.revision += 1
strategy.updated_at = datetime.now(timezone.utc)
return strategy
def delete_strategy_set(self, id: str) -> None:
if id in self._strategy_sets:
@@ -670,8 +701,8 @@ def test_cli_create_buy_and_delete_flow() -> None:
_TEST_ASSET_ID,
"--cash-balance",
"1000000",
"--strategy-params",
'{"target_asset_ratio": 0.5}',
"--strategy-id",
"set-1",
"--force",
],
)
@@ -717,6 +748,8 @@ def test_cli_write_command_prompts_before_create() -> None:
_TEST_ASSET_ID,
"--cash-balance",
"1000000",
"--strategy-id",
"set-1",
],
input="y\n",
)
@@ -740,6 +773,8 @@ def test_cli_write_command_can_abort() -> None:
_TEST_ASSET_ID,
"--cash-balance",
"1000000",
"--strategy-id",
"set-1",
],
input="n\n",
)
@@ -1434,3 +1469,32 @@ def test_cli_service_token_rebind_rejects_cross_user_daemon() -> None:
assert result.exit_code == 1
assert "해당 토큰 사용자에 속하지 않습니다" in result.stdout
def test_cli_strategy_commands_show_status_revision_and_preserve_id() -> None:
repo = FakeRepo()
runner = build_runner(repo)
listed = runner.invoke(runner.app, ["strategy", "list"])
updated = runner.invoke(
runner.app,
["strategy", "update", "set-1", "--status", "inactive", "--name", "target_value_v2"],
)
no_change = runner.invoke(runner.app, ["strategy", "update", "set-1", "--status", "inactive"])
assert listed.exit_code == 0
assert "Status" in listed.output and "Revision" in listed.output
assert updated.exit_code == 0
assert "ID: set-1" in updated.output
assert "Status: inactive" in updated.output
assert "Revision: 2" in updated.output
assert no_change.exit_code == 0
assert "Revision: 2" in no_change.output
def test_cli_strategy_update_requires_a_field() -> None:
repo = FakeRepo()
runner = build_runner(repo)
result = runner.invoke(runner.app, ["strategy", "update", "set-1"])
assert result.exit_code == 1
assert "at least one" in result.output
+1 -1
View File
@@ -90,7 +90,7 @@ class FakeRepo:
self._strategy_sets[self.strategy_set.id] = self.strategy_set
return self.strategy_set
def list_strategy_sets(self) -> list[StrategySet]:
def list_strategy_sets(self, *, include_inactive: bool = True) -> list[StrategySet]:
return sorted(self._strategy_sets.values(), key=lambda s: s.created_at or datetime.now(timezone.utc), reverse=True)
def delete_strategy_set(self, id: str) -> None:
+1 -1
View File
@@ -70,7 +70,7 @@ class FakeRepo:
def get_strategy_set(self, id: str) -> StrategySet | None:
return self.strategy_set if id == self.strategy_set.id else None
def list_strategy_sets(self):
def list_strategy_sets(self, *, include_inactive: bool = True):
return [self.strategy_set]
def delete_strategy_set(self, id: str) -> None:
+1 -1
View File
@@ -164,7 +164,7 @@ def test_db_enabled_env_token_without_user_context_is_rejected(monkeypatch) -> N
response = client.post(
"/portfolios",
headers={"Authorization": "Bearer trade-env-token"},
json={"name": "new-1", "asset_id": "00000000-0000-0000-0000-000000000001"},
json={"name": "new-1", "asset_id": "00000000-0000-0000-0000-000000000001", "strategy_id": "set-1"},
)
app.dependency_overrides.clear()
+17 -17
View File
@@ -201,7 +201,7 @@ class FakeRepo:
def get_strategy_set(self, id: str) -> StrategySet | None:
return self._strategy_sets.get(id)
def list_strategy_sets(self) -> list[StrategySet]:
def list_strategy_sets(self, *, include_inactive: bool = True) -> list[StrategySet]:
return list(self._strategy_sets.values())
def delete_strategy_set(self, id: str) -> None:
@@ -350,7 +350,7 @@ def test_create_portfolio_assigns_uuid_id() -> None:
result = runner.invoke(
runner.app,
["portfolio", "create", "SAMSUNG", "--asset-id", _TEST_ASSET_ID, "--user", "alice", "--force"],
["portfolio", "create", "SAMSUNG", "--asset-id", _TEST_ASSET_ID, "--user", "alice", "--strategy-id", "set-1", "--force"],
)
assert result.exit_code == 0, result.output
@@ -371,7 +371,7 @@ def test_create_returns_uuid_in_response() -> None:
result = runner.invoke(
runner.app,
["--format", "json", "portfolio", "create", "MY_PORTFOLIO", "--asset-id", _TEST_ASSET_ID, "--user", "alice", "--force"],
["--format", "json", "portfolio", "create", "MY_PORTFOLIO", "--asset-id", _TEST_ASSET_ID, "--user", "alice", "--strategy-id", "set-1", "--force"],
)
assert result.exit_code == 0, result.output
@@ -389,7 +389,7 @@ def test_status_resolves_by_name_and_user() -> None:
create_result = runner.invoke(
runner.app,
["--format", "json", "portfolio", "create", "MY_PORT", "--asset-id", _TEST_ASSET_ID, "--user", "alice", "--force"],
["--format", "json", "portfolio", "create", "MY_PORT", "--asset-id", _TEST_ASSET_ID, "--user", "alice", "--strategy-id", "set-1", "--force"],
)
assert create_result.exit_code == 0, create_result.output
@@ -437,7 +437,7 @@ def test_create_without_user_fails() -> None:
result = runner.invoke(
runner.app,
["portfolio", "create", "MY_PORT", "--asset-id", _TEST_ASSET_ID, "--force"],
["portfolio", "create", "MY_PORT", "--asset-id", _TEST_ASSET_ID, "--strategy-id", "set-1", "--force"],
)
assert result.exit_code != 0
@@ -450,7 +450,7 @@ def test_create_with_unknown_user_fails() -> None:
result = runner.invoke(
runner.app,
["portfolio", "create", "MY_PORT", "--asset-id", _TEST_ASSET_ID, "--user", "ghost", "--force"],
["portfolio", "create", "MY_PORT", "--asset-id", _TEST_ASSET_ID, "--user", "ghost", "--strategy-id", "set-1", "--force"],
)
assert result.exit_code == 1
@@ -464,7 +464,7 @@ def test_name_validation_rejects_invalid_characters() -> None:
result = runner.invoke(
runner.app,
["portfolio", "create", "invalid name!", "--asset-id", _TEST_ASSET_ID, "--user", "alice", "--force"],
["portfolio", "create", "invalid name!", "--asset-id", _TEST_ASSET_ID, "--user", "alice", "--strategy-id", "set-1", "--force"],
)
assert result.exit_code == 1
@@ -478,7 +478,7 @@ def test_name_validation_rejects_empty_name() -> None:
result = runner.invoke(
runner.app,
["portfolio", "create", "", "--asset-id", _TEST_ASSET_ID, "--user", "alice", "--force"],
["portfolio", "create", "", "--asset-id", _TEST_ASSET_ID, "--user", "alice", "--strategy-id", "set-1", "--force"],
)
assert result.exit_code != 0
@@ -492,11 +492,11 @@ def test_alice_and_bob_can_have_same_portfolio_name() -> None:
alice_result = runner.invoke(
runner.app,
["portfolio", "create", "SAMSUNG", "--asset-id", _TEST_ASSET_ID, "--user", "alice", "--force"],
["portfolio", "create", "SAMSUNG", "--asset-id", _TEST_ASSET_ID, "--user", "alice", "--strategy-id", "set-1", "--force"],
)
bob_result = runner.invoke(
runner.app,
["portfolio", "create", "SAMSUNG", "--asset-id", _TEST_ASSET_ID, "--user", "bob", "--force"],
["portfolio", "create", "SAMSUNG", "--asset-id", _TEST_ASSET_ID, "--user", "bob", "--strategy-id", "set-1", "--force"],
)
assert alice_result.exit_code == 0, alice_result.output
@@ -513,7 +513,7 @@ def test_buy_resolves_portfolio_by_name_and_user() -> None:
runner.invoke(
runner.app,
["portfolio", "create", "MY_PORT", "--asset-id", _TEST_ASSET_ID, "--cash-balance", "1000000", "--user", "alice", "--force"],
["portfolio", "create", "MY_PORT", "--asset-id", _TEST_ASSET_ID, "--cash-balance", "1000000", "--user", "alice", "--strategy-id", "set-1", "--force"],
)
buy_result = runner.invoke(
runner.app,
@@ -532,7 +532,7 @@ def test_delete_resolves_portfolio_by_name_and_user() -> None:
runner.invoke(
runner.app,
["portfolio", "create", "MY_PORT", "--asset-id", _TEST_ASSET_ID, "--user", "alice", "--force"],
["portfolio", "create", "MY_PORT", "--asset-id", _TEST_ASSET_ID, "--user", "alice", "--strategy-id", "set-1", "--force"],
)
delete_result = runner.invoke(
runner.app,
@@ -552,7 +552,7 @@ def test_alice_cannot_delete_bobs_portfolio() -> None:
runner.invoke(
runner.app,
["portfolio", "create", "SAMSUNG", "--asset-id", _TEST_ASSET_ID, "--user", "bob", "--force"],
["portfolio", "create", "SAMSUNG", "--asset-id", _TEST_ASSET_ID, "--user", "bob", "--strategy-id", "set-1", "--force"],
)
delete_result = runner.invoke(
runner.app,
@@ -575,7 +575,7 @@ def test_name_validation_accepts_korean() -> None:
result = runner.invoke(
runner.app,
["portfolio", "create", "삼성전자 포트폴리오", "--asset-id", _TEST_ASSET_ID, "--user", "alice", "--force"],
["portfolio", "create", "삼성전자 포트폴리오", "--asset-id", _TEST_ASSET_ID, "--user", "alice", "--strategy-id", "set-1", "--force"],
)
assert result.exit_code == 0, result.output
@@ -589,7 +589,7 @@ def test_name_validation_accepts_ascii_unchanged() -> None:
result = runner.invoke(
runner.app,
["portfolio", "create", "my-portfolio_v1:test.01", "--asset-id", _TEST_ASSET_ID, "--user", "alice", "--force"],
["portfolio", "create", "my-portfolio_v1:test.01", "--asset-id", _TEST_ASSET_ID, "--user", "alice", "--strategy-id", "set-1", "--force"],
)
assert result.exit_code == 0, result.output
@@ -602,7 +602,7 @@ def test_name_validation_rejects_over_100_chars() -> None:
long_name = "가" * 101
with pytest.raises(Exception):
CreatePortfolioRequest(name=long_name, asset_id="some-uuid")
CreatePortfolioRequest(name=long_name, asset_id="some-uuid", strategy_id="set-1")
def test_update_new_name_renames_portfolio() -> None:
@@ -612,7 +612,7 @@ def test_update_new_name_renames_portfolio() -> None:
runner.invoke(
runner.app,
["portfolio", "create", "OLD_NAME", "--asset-id", _TEST_ASSET_ID, "--user", "alice", "--force"],
["portfolio", "create", "OLD_NAME", "--asset-id", _TEST_ASSET_ID, "--user", "alice", "--strategy-id", "set-1", "--force"],
)
rename_result = runner.invoke(
runner.app,
+74
View File
@@ -0,0 +1,74 @@
"""Idempotency contract for the Issue #26 strategy-catalog migration."""
from __future__ import annotations
import importlib.util
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import patch
_MIGRATION_PATH = (
Path(__file__).parents[1]
/ "migrations/versions/20260814_0029_strategy_catalog_v1.py"
)
def _load_migration():
spec = importlib.util.spec_from_file_location("strategy_catalog_0029", _MIGRATION_PATH)
assert spec is not None and spec.loader is not None
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
class _Bind:
def __init__(self) -> None:
self.executed: list[str] = []
self.params: list[dict | None] = []
def execute(self, statement, params=None):
self.executed.append(str(statement))
self.params.append(params)
return SimpleNamespace()
class _Inspector:
def __init__(self, columns: set[str]) -> None:
self.columns = columns
def get_table_names(self) -> list[str]:
return ["strategy_sets"]
def get_columns(self, table_name: str) -> list[dict[str, str]]:
assert table_name == "strategy_sets"
return [{"name": name} for name in sorted(self.columns)]
def test_upgrade_is_repeatable_and_uses_data_and_constraint_guards() -> None:
migration = _load_migration()
columns = {"id", "name", "config", "nick", "created_at"}
inspector = _Inspector(columns)
bind = _Bind()
added: list[str] = []
def add_column(table_name: str, column) -> None:
assert table_name == "strategy_sets"
columns.add(column.name)
added.append(column.name)
with (
patch.object(migration.op, "get_bind", return_value=bind),
patch.object(migration.op, "add_column", side_effect=add_column),
patch.object(migration, "inspect", return_value=inspector),
):
migration.upgrade()
migration.upgrade()
assert added == ["status", "revision", "updated_at"]
sql = "\n".join(bind.executed)
assert "WHERE NOT EXISTS" in sql
assert "IF NOT EXISTS" in sql
assert "ck_strategy_sets_status" in sql
assert "ck_strategy_sets_revision_positive" in sql
assert any(params and params.get("nick") == "target_value:bf21a9e8fbc5" for params in bind.params)
@@ -0,0 +1,328 @@
"""Real-PostgreSQL concurrency and migration verification for Issue #26.
Run with PORTFOLIOHUB_RUN_POSTGRES_INTEGRATION=1 against a disposable/local
PostgreSQL endpoint. Normal unit-test environments skip this module.
"""
from __future__ import annotations
import hashlib
import os
import subprocess
import threading
import uuid
from pathlib import Path
import psycopg
import pytest
from psycopg import sql
from app.domain.errors import StrategyNotFoundError
from app.repositories.postgres import PortfolioDBConfig, PostgresPortfolioRepository
pytestmark = pytest.mark.skipif(
os.getenv("PORTFOLIOHUB_RUN_POSTGRES_INTEGRATION") != "1",
reason="set PORTFOLIOHUB_RUN_POSTGRES_INTEGRATION=1 to run real PostgreSQL tests",
)
_ROOT = Path(__file__).parents[1]
def _db_values(dbname: str | None = None) -> dict[str, object]:
return {
"host": os.getenv("TIMESCALEDB_HOST", "127.0.0.1"),
"port": int(os.getenv("TIMESCALEDB_PORT", "5432")),
"dbname": dbname or os.getenv("TIMESCALEDB_NAME", "portfolio_hub_migration_test"),
"user": os.getenv("TIMESCALEDB_USER", "portfolio_hub_user"),
"password": os.getenv("TIMESCALEDB_PASSWORD", "change-me"),
"sslmode": os.getenv("TIMESCALEDB_SSLMODE", "prefer"),
}
def _repo(dbname: str | None = None) -> PostgresPortfolioRepository:
values = _db_values(dbname)
return PostgresPortfolioRepository(
PortfolioDBConfig(
host=str(values["host"]),
port=int(values["port"]),
dbname=str(values["dbname"]),
user=str(values["user"]),
password=str(values["password"]),
sslmode=str(values["sslmode"]),
)
)
def _prepare_catalog_fixture(repo: PostgresPortfolioRepository) -> tuple[str, str, str]:
repo.ensure_schema()
strategy = repo.create_strategy_set(f"concurrency-{uuid.uuid4().hex}", "{}")
asset_id = str(uuid.uuid4())
owner_user_id = f"qc-user-{uuid.uuid4().hex}"
with repo._connect().cursor() as cur:
cur.execute(
"INSERT INTO users (id, username, permission, is_active) VALUES (%s, %s, 'trade-write', TRUE)",
(owner_user_id, owner_user_id),
)
cur.execute(
"""
INSERT INTO assets (asset_id, symbol, markets, status)
VALUES (%s, %s, ARRAY['KRX'], 'active')
""",
(asset_id, f"QC{uuid.uuid4().hex[:10]}"),
)
return strategy.id, asset_id, owner_user_id
def test_concurrent_strategy_patch_keeps_derived_nick_consistent() -> None:
owner = _repo()
strategy_id, asset_id, owner_user_id = _prepare_catalog_fixture(owner)
renamed = f"concurrent-renamed-{uuid.uuid4().hex}"
barrier = threading.Barrier(3)
errors: list[BaseException] = []
def patch_name() -> None:
repo = _repo()
try:
barrier.wait()
repo.update_strategy_set(strategy_id, name=renamed)
except BaseException as exc: # pragma: no cover - only populated on failure
errors.append(exc)
finally:
repo.close()
def patch_config() -> None:
repo = _repo()
try:
barrier.wait()
repo.update_strategy_set(strategy_id, config='{"window":21}')
except BaseException as exc: # pragma: no cover - only populated on failure
errors.append(exc)
finally:
repo.close()
threads = [threading.Thread(target=patch_name), threading.Thread(target=patch_config)]
for thread in threads:
thread.start()
barrier.wait()
for thread in threads:
thread.join(timeout=10)
assert not thread.is_alive()
final = owner.get_strategy_set(strategy_id)
assert errors == []
assert final is not None
assert final.name == renamed
assert final.config == '{"window":21}'
assert final.revision == 3
expected_nick = f"{final.name}:{hashlib.sha1(final.config.encode()).hexdigest()[:12]}"
assert final.nick == expected_nick
portfolio_id = str(uuid.uuid4())
owner.create_portfolio(
portfolio_id,
owner_user_id=owner_user_id,
broker="kis",
status="enabled",
name="latest-revision-check",
asset_id=asset_id,
cash_balance=0,
asset_quantity=0,
asset_price=0,
profit_buffer=0,
active_strategy_id=strategy_id,
avabile_strategies=[strategy_id],
)
portfolio = owner.get(portfolio_id)
assert portfolio.strategy_params == {"window": 21}
assert portfolio.strategy_revision == 3
with owner._connect().cursor() as cur:
cur.execute("DELETE FROM portfolios WHERE portfolio_id = %s", (portfolio_id,))
owner.delete_strategy_set(strategy_id)
with owner._connect().cursor() as cur:
cur.execute("DELETE FROM assets WHERE asset_id = %s", (asset_id,))
cur.execute("DELETE FROM users WHERE id = %s", (owner_user_id,))
owner.close()
def test_portfolio_selection_and_strategy_delete_never_create_orphan() -> None:
owner = _repo()
strategy_id, asset_id, owner_user_id = _prepare_catalog_fixture(owner)
portfolio_id = str(uuid.uuid4())
barrier = threading.Barrier(3)
outcomes: list[str] = []
def select_strategy() -> None:
repo = _repo()
try:
barrier.wait()
repo.create_portfolio(
portfolio_id,
owner_user_id=owner_user_id,
broker="kis",
status="enabled",
name="race-check",
asset_id=asset_id,
cash_balance=0,
asset_quantity=0,
asset_price=0,
profit_buffer=0,
active_strategy_id=strategy_id,
avabile_strategies=[strategy_id],
)
outcomes.append("selected")
except StrategyNotFoundError:
outcomes.append("selection-rejected")
finally:
repo.close()
def delete_strategy() -> None:
repo = _repo()
try:
barrier.wait()
repo.delete_strategy_set(strategy_id)
outcomes.append("deleted")
except ValueError as exc:
assert "in use" in str(exc)
outcomes.append("delete-rejected")
finally:
repo.close()
threads = [threading.Thread(target=select_strategy), threading.Thread(target=delete_strategy)]
for thread in threads:
thread.start()
barrier.wait()
for thread in threads:
thread.join(timeout=10)
assert not thread.is_alive()
with owner._connect().cursor() as cur:
cur.execute("SELECT COUNT(*) FROM portfolios WHERE portfolio_id = %s", (portfolio_id,))
portfolio_exists = cur.fetchone()[0] == 1
cur.execute("SELECT COUNT(*) FROM strategy_sets WHERE id = %s", (strategy_id,))
strategy_exists = cur.fetchone()[0] == 1
assert not (portfolio_exists and not strategy_exists)
assert sorted(outcomes) in (["delete-rejected", "selected"], ["deleted", "selection-rejected"])
with owner._connect().cursor() as cur:
cur.execute("DELETE FROM portfolios WHERE portfolio_id = %s", (portfolio_id,))
cur.execute("DELETE FROM strategy_sets WHERE id = %s", (strategy_id,))
cur.execute("DELETE FROM assets WHERE asset_id = %s", (asset_id,))
cur.execute("DELETE FROM users WHERE id = %s", (owner_user_id,))
owner.close()
def test_stale_trade_save_cannot_restore_a_deleted_strategy_reference() -> None:
owner = _repo()
strategy_a, asset_id, owner_user_id = _prepare_catalog_fixture(owner)
strategy_b = owner.create_strategy_set(f"replacement-{uuid.uuid4().hex}", "{}")
portfolio_id = str(uuid.uuid4())
owner.create_portfolio(
portfolio_id,
owner_user_id=owner_user_id,
broker="kis",
status="enabled",
name="stale-trade-check",
asset_id=asset_id,
cash_balance=1_000,
asset_quantity=0,
asset_price=100,
profit_buffer=0,
active_strategy_id=strategy_a,
avabile_strategies=[strategy_a],
)
stale = owner.get(portfolio_id)
concurrent = _repo()
concurrent.update_portfolio(
portfolio_id,
active_strategy_id=strategy_b.id,
avabile_strategies=[strategy_b.id],
)
concurrent.delete_strategy_set(strategy_a)
concurrent.close()
stale.cash_balance = 900
stale.asset_quantity = 1
owner.save(
stale,
current_price=100,
action="BUY",
trade_price=100,
quantity=1,
fee_amount=0,
tax_amount=0,
total_value_after=1_000,
strategy_set_id=strategy_a,
note="stale snapshot regression",
)
current = owner.get(portfolio_id)
assert current.active_strategy_id == strategy_b.id
assert current.avabile_strategies == [strategy_b.id]
assert owner.get_strategy_set(strategy_a) is None
with owner._connect().cursor() as cur:
cur.execute("DELETE FROM portfolio_ledger WHERE portfolio_id = %s", (portfolio_id,))
cur.execute("DELETE FROM portfolios WHERE portfolio_id = %s", (portfolio_id,))
cur.execute("DELETE FROM strategy_sets WHERE id = %s", (strategy_b.id,))
cur.execute("DELETE FROM assets WHERE asset_id = %s", (asset_id,))
cur.execute("DELETE FROM users WHERE id = %s", (owner_user_id,))
owner.close()
def test_alembic_upgrade_is_repeatable_on_fresh_postgres_database() -> None:
database_name = f"portfolio_hub_0029_{uuid.uuid4().hex[:12]}"
maintenance = _db_values("postgres")
connection = psycopg.connect(**maintenance, autocommit=True)
try:
with connection.cursor() as cur:
cur.execute(sql.SQL("CREATE DATABASE {}").format(sql.Identifier(database_name)))
env = os.environ.copy()
env.update(
{
"TIMESCALEDB_HOST": str(maintenance["host"]),
"TIMESCALEDB_PORT": str(maintenance["port"]),
"TIMESCALEDB_NAME": database_name,
"TIMESCALEDB_USER": str(maintenance["user"]),
"TIMESCALEDB_PASSWORD": str(maintenance["password"]),
"TIMESCALEDB_SSLMODE": str(maintenance["sslmode"]),
}
)
for _ in range(2):
subprocess.run(
[str(_ROOT / ".venv/bin/python"), "-m", "alembic", "upgrade", "head"],
cwd=_ROOT,
env=env,
check=True,
capture_output=True,
text=True,
)
test_db = psycopg.connect(**_db_values(database_name))
try:
with test_db.cursor() as cur:
cur.execute(
"""
SELECT id, status, revision, nick
FROM strategy_sets
WHERE name = 'target_value' AND config = '{}'
"""
)
rows = cur.fetchall()
assert rows == [
(
"00000000-0000-0000-0000-000000000029",
"active",
1,
"target_value:bf21a9e8fbc5",
)
]
cur.execute("SELECT version_num FROM alembic_version")
assert cur.fetchone()[0] == "20260814_0029"
finally:
test_db.close()
finally:
with connection.cursor() as cur:
cur.execute(
"SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = %s",
(database_name,),
)
cur.execute(sql.SQL("DROP DATABASE IF EXISTS {}").format(sql.Identifier(database_name)))
connection.close()
+55 -7
View File
@@ -79,6 +79,9 @@ class _FakeRepo:
def get_or_create_strategy_set(self, name: str, config: str) -> StrategySet:
return self._strategy_set
def get_strategy_set(self, id: str) -> StrategySet | None:
return self._strategy_set if id == self._strategy_set.id else None
def create_portfolio(
self,
portfolio_id: str,
@@ -190,7 +193,7 @@ def test_post_admin_creates_for_target_user() -> None:
with _with_repo(repo):
resp = client.post(
"/v1/portfolios",
json={"asset_id": _ASSET_UUID, "broker": "kiwoom", "user_id": _OTHER_USER_ID},
json={"asset_id": _ASSET_UUID, "broker": "kiwoom", "strategy_id": "strat-001", "user_id": _OTHER_USER_ID},
headers=_auth("admin-tok"),
)
assert resp.status_code == 201
@@ -209,7 +212,7 @@ def test_post_user_creates_for_self() -> None:
with _with_repo(repo):
resp = client.post(
"/v1/portfolios",
json={"asset_id": _ASSET_UUID, "broker": "kiwoom"},
json={"asset_id": _ASSET_UUID, "broker": "kiwoom", "strategy_id": "strat-001"},
headers=_auth("user-tok"),
)
assert resp.status_code == 201
@@ -230,7 +233,7 @@ def test_post_service_token_returns_403() -> None:
with _with_repo(repo, db_auth=True):
resp = client.post(
"/v1/portfolios",
json={"asset_id": _ASSET_UUID, "broker": "kiwoom"},
json={"asset_id": _ASSET_UUID, "broker": "kiwoom", "strategy_id": "strat-001"},
headers=_auth("svc-raw-token"),
)
assert resp.status_code == 403
@@ -244,7 +247,7 @@ def test_post_inactive_asset_returns_422() -> None:
with _with_repo(repo):
resp = client.post(
"/v1/portfolios",
json={"asset_id": _ASSET_UUID, "broker": "kiwoom"},
json={"asset_id": _ASSET_UUID, "broker": "kiwoom", "strategy_id": "strat-001"},
headers=_auth("admin-tok"),
)
assert resp.status_code == 422
@@ -259,7 +262,7 @@ def test_post_missing_asset_returns_422() -> None:
with _with_repo(repo):
resp = client.post(
"/v1/portfolios",
json={"asset_id": "no-such-uuid", "broker": "kiwoom"},
json={"asset_id": "no-such-uuid", "broker": "kiwoom", "strategy_id": "strat-001"},
headers=_auth("admin-tok"),
)
assert resp.status_code == 422
@@ -268,7 +271,7 @@ def test_post_missing_asset_returns_422() -> None:
def test_post_no_bearer_returns_401() -> None:
"""REQ-025 AC-6: no token → HTTP 401."""
client = _make_client()
resp = client.post("/v1/portfolios", json={"asset_id": _ASSET_UUID, "broker": "kiwoom"})
resp = client.post("/v1/portfolios", json={"asset_id": _ASSET_UUID, "broker": "kiwoom", "strategy_id": "strat-001"})
assert resp.status_code == 401
@@ -280,7 +283,7 @@ def test_post_non_admin_user_id_ignored() -> None:
with _with_repo(repo):
resp = client.post(
"/v1/portfolios",
json={"asset_id": _ASSET_UUID, "broker": "kiwoom", "user_id": _OTHER_USER_ID},
json={"asset_id": _ASSET_UUID, "broker": "kiwoom", "strategy_id": "strat-001", "user_id": _OTHER_USER_ID},
headers=_auth("user-tok"),
)
assert resp.status_code == 201
@@ -499,3 +502,48 @@ def test_delete_already_disabled_is_idempotent() -> None:
resp = client.delete(f"/v1/portfolios/{_PORTFOLIO_UUID}", headers=_auth("admin-tok"))
assert resp.status_code == 200
assert resp.json()["status"] == "disabled"
# Issue #26: strategy catalog references on portfolio mutations.
def test_post_strategy_reference_errors_and_required_contract() -> None:
repo = _FakeRepo(asset=_make_asset(_ASSET_UUID, "active"))
client = _make_client()
with _with_repo(repo):
missing = client.post(
"/v1/portfolios",
json={"asset_id": _ASSET_UUID, "broker": "kiwoom", "strategy_id": "missing"},
headers=_auth("admin-tok"),
)
invalid = client.post(
"/v1/portfolios",
json={"asset_id": _ASSET_UUID, "broker": "kiwoom"},
headers=_auth("admin-tok"),
)
repo._strategy_set.status = "inactive"
inactive = client.post(
"/v1/portfolios",
json={"asset_id": _ASSET_UUID, "broker": "kiwoom", "strategy_id": "strat-001"},
headers=_auth("admin-tok"),
)
assert missing.status_code == 404
assert invalid.status_code == 422
assert inactive.status_code == 409
def test_patch_strategy_reference_errors_are_404_and_409() -> None:
repo = _repo_with_portfolio(owner=_OTHER_USER_ID)
client = _make_client()
with _with_repo(repo):
missing = client.patch(
f"/v1/portfolios/{_PORTFOLIO_UUID}",
json={"strategy_id": "missing"},
headers=_auth("admin-tok"),
)
repo._strategy_set.status = "inactive"
inactive = client.patch(
f"/v1/portfolios/{_PORTFOLIO_UUID}",
json={"strategy_id": "strat-001"},
headers=_auth("admin-tok"),
)
assert missing.status_code == 404
assert inactive.status_code == 409
+4
View File
@@ -49,6 +49,7 @@ def _make_portfolio(
active_strategy_id: str | None = None,
strategy_name: str | None = None,
strategy_params: dict | None = None,
strategy_revision: int | None = None,
markets: list[str] | None = None,
) -> Portfolio:
return Portfolio(
@@ -69,6 +70,7 @@ def _make_portfolio(
active_strategy_id=active_strategy_id,
strategy_name=strategy_name,
strategy_params=strategy_params if strategy_params is not None else {},
strategy_revision=strategy_revision,
markets=markets if markets is not None else [],
)
@@ -375,6 +377,7 @@ def test_req055_ac1_bound_portfolio_returns_strategy_fields() -> None:
active_strategy_id=_STRATEGY_ID,
strategy_name="tvr_v1",
strategy_params={"target_weight": 0.5, "rebalance_band_pct": 0.05},
strategy_revision=7,
markets=["KRX"],
)
repo = _FakeListRepo(portfolios=[p])
@@ -387,6 +390,7 @@ def test_req055_ac1_bound_portfolio_returns_strategy_fields() -> None:
assert item["active_strategy_id"] == _STRATEGY_ID
assert item["strategy_name"] == "tvr_v1"
assert item["strategy_params"] == {"target_weight": 0.5, "rebalance_band_pct": 0.05}
assert item["strategy_revision"] == 7
def test_req055_ac2_unbound_portfolio_returns_null_strategy() -> None:
+205
View File
@@ -0,0 +1,205 @@
"""Contract tests for the versioned strategy catalog API."""
from __future__ import annotations
import hashlib
from contextlib import ExitStack
from datetime import datetime, timezone
from unittest.mock import patch
from fastapi.testclient import TestClient
from app.core.config import Settings, get_settings
from app.domain.models import StrategySet
from app.main import create_app
class FakeStrategyRepo:
def __init__(self) -> None:
now = datetime.now(timezone.utc)
self.strategies = {
"active-1": StrategySet(
id="active-1", name="target_value", config="{}", nick="target_value:base",
status="active", revision=1, created_at=now, updated_at=now,
),
"inactive-1": StrategySet(
id="inactive-1", name="archived", config="{}", nick="archived:base",
status="inactive", revision=3, created_at=now, updated_at=now,
),
}
self.referenced: set[str] = set()
self.closed = False
def close(self) -> None:
self.closed = True
def list_strategy_sets(self, *, include_inactive: bool = True) -> list[StrategySet]:
values = list(self.strategies.values())
return values if include_inactive else [item for item in values if item.status == "active"]
def get_strategy_set(self, id: str) -> StrategySet | None:
return self.strategies.get(id)
def get_or_create_strategy_set(self, name: str, config: str) -> StrategySet:
return self.create_strategy_set(name, config)
def create_strategy_set(self, name: str, config: str, *, status: str = "active") -> StrategySet:
existing = next(
(item for item in self.strategies.values() if item.name == name and item.config == config),
None,
)
if existing is not None:
return existing
now = datetime.now(timezone.utc)
item = StrategySet(
id=f"created-{len(self.strategies)}",
name=name,
config=config,
nick=f"{name}:{hashlib.sha1(config.encode()).hexdigest()[:12]}",
status=status,
created_at=now,
updated_at=now,
)
self.strategies[item.id] = item
return item
def update_strategy_set(
self,
id: str,
*,
name: str | None = None,
config: str | None = None,
status: str | None = None,
) -> StrategySet:
item = self.strategies.get(id)
if item is None:
raise ValueError("전략을 찾을 수 없습니다")
final_name = name if name is not None else item.name
final_config = config if config is not None else item.config
if any(
other.id != id and other.name == final_name and other.config == final_config
for other in self.strategies.values()
):
raise ValueError("동일한 name/config 전략이 이미 존재합니다")
final_status = status if status is not None else item.status
if (final_name, final_config, final_status) == (item.name, item.config, item.status):
return item
item.name = final_name
item.config = final_config
item.status = final_status
item.nick = f"{final_name}:{hashlib.sha1(final_config.encode()).hexdigest()[:12]}"
item.revision += 1
item.updated_at = datetime.now(timezone.utc)
return item
def delete_strategy_set(self, id: str) -> None:
if id in self.referenced:
raise ValueError("Strategy set is in use by 1 portfolio(s) and cannot be deleted")
if id not in self.strategies:
raise ValueError("전략을 찾을 수 없습니다")
del self.strategies[id]
def _client() -> TestClient:
settings = Settings(
allowed_origins_raw="http://localhost:3000",
admin_tokens_raw="admin-token",
read_tokens_raw="read-token",
)
app = create_app(settings_factory=lambda: settings)
app.dependency_overrides[get_settings] = lambda: settings
return TestClient(app)
def _repos(repo: FakeStrategyRepo) -> ExitStack:
stack = ExitStack()
stack.enter_context(patch("app.api.v1_strategies.get_portfolio_repository_from_settings", return_value=repo))
stack.enter_context(patch("app.core.auth.get_portfolio_repository_from_settings", return_value=repo))
return stack
def _auth(token: str) -> dict[str, str]:
return {"Authorization": f"Bearer {token}"}
def test_public_catalog_excludes_inactive_and_inactive_lookup_conflicts() -> None:
repo = FakeStrategyRepo()
with _repos(repo):
response = _client().get("/v1/strategies", headers=_auth("read-token"))
inactive = _client().get("/v1/strategies/inactive-1", headers=_auth("read-token"))
assert response.status_code == 200
assert [item["strategy_id"] for item in response.json()] == ["active-1"]
assert inactive.status_code == 409
def test_admin_catalog_requires_admin_and_reports_missing_strategy() -> None:
repo = FakeStrategyRepo()
with _repos(repo):
forbidden = _client().get("/v1/admin/strategies", headers=_auth("read-token"))
missing = _client().get("/v1/admin/strategies/missing", headers=_auth("admin-token"))
assert forbidden.status_code == 403
assert missing.status_code == 404
def test_admin_create_and_patch_preserve_id_and_revision_semantics() -> None:
repo = FakeStrategyRepo()
with _repos(repo):
created = _client().post(
"/v1/admin/strategies",
json={"name": "momentum", "config": {"window": 20}},
headers=_auth("admin-token"),
)
strategy_id = created.json()["strategy_id"]
changed = _client().patch(
f"/v1/admin/strategies/{strategy_id}",
json={"name": "momentum-v2"},
headers=_auth("admin-token"),
)
no_op = _client().patch(
f"/v1/admin/strategies/{strategy_id}",
json={"name": "momentum-v2"},
headers=_auth("admin-token"),
)
assert created.status_code == 201
assert created.json()["revision"] == 1
assert changed.status_code == 200
assert changed.json()["strategy_id"] == strategy_id
assert changed.json()["revision"] == 2
assert no_op.json()["revision"] == 2
assert repo.strategies[strategy_id].nick.startswith("momentum-v2:")
def test_admin_create_inactive_starts_at_revision_one() -> None:
repo = FakeStrategyRepo()
with _repos(repo):
created = _client().post(
"/v1/admin/strategies",
json={"name": "retired", "config": {}, "status": "inactive"},
headers=_auth("admin-token"),
)
assert created.status_code == 201
assert created.json()["status"] == "inactive"
assert created.json()["revision"] == 1
def test_admin_patch_validation_conflict_and_reference_safe_delete() -> None:
repo = FakeStrategyRepo()
repo.referenced.add("active-1")
with _repos(repo):
empty = _client().patch("/v1/admin/strategies/active-1", json={}, headers=_auth("admin-token"))
conflict = _client().patch(
"/v1/admin/strategies/inactive-1",
json={"name": "target_value"},
headers=_auth("admin-token"),
)
referenced = _client().delete("/v1/admin/strategies/active-1", headers=_auth("admin-token"))
deleted = _client().delete("/v1/admin/strategies/inactive-1", headers=_auth("admin-token"))
assert empty.status_code == 422
assert conflict.status_code == 409
assert referenced.status_code == 409
assert deleted.status_code == 204
def test_legacy_strategy_set_routes_are_removed() -> None:
response = _client().get("/strategy-sets", headers=_auth("admin-token"))
assert response.status_code == 404