feat(strategy): expose configurable nick contract

This commit is contained in:
2026-08-15 23:38:18 +09:00
parent acfaab7fd4
commit e6a90800ec
8 changed files with 189 additions and 60 deletions
@@ -6,7 +6,6 @@ PostgreSQL endpoint. Normal unit-test environments skip this module.
from __future__ import annotations
import hashlib
import os
import subprocess
import threading
@@ -73,9 +72,12 @@ def _prepare_catalog_fixture(repo: PostgresPortfolioRepository) -> tuple[str, st
return strategy.id, asset_id, owner_user_id
def test_concurrent_strategy_patch_keeps_derived_nick_consistent() -> None:
def test_concurrent_strategy_patch_preserves_existing_nick() -> None:
owner = _repo()
strategy_id, asset_id, owner_user_id = _prepare_catalog_fixture(owner)
initial = owner.get_strategy_set(strategy_id)
assert initial is not None
initial_nick = initial.nick
renamed = f"concurrent-renamed-{uuid.uuid4().hex}"
barrier = threading.Barrier(3)
errors: list[BaseException] = []
@@ -114,8 +116,7 @@ def test_concurrent_strategy_patch_keeps_derived_nick_consistent() -> 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
assert final.nick == initial_nick
portfolio_id = str(uuid.uuid4())
owner.create_portfolio(
portfolio_id,
+78 -9
View File
@@ -43,19 +43,29 @@ class FakeStrategyRepo:
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:
def create_strategy_set(
self,
name: str,
config: str,
*,
nick: str | None = None,
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
final_nick = nick or f"{name}:{hashlib.sha1(config.encode()).hexdigest()[:12]}"
if any(item.nick == final_nick for item in self.strategies.values()):
raise ValueError("동일한 nick 전략이 이미 존재합니다")
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]}",
nick=final_nick,
status=status,
created_at=now,
updated_at=now,
@@ -68,6 +78,7 @@ class FakeStrategyRepo:
id: str,
*,
name: str | None = None,
nick: str | None = None,
config: str | None = None,
status: str | None = None,
) -> StrategySet:
@@ -75,19 +86,27 @@ class FakeStrategyRepo:
if item is None:
raise ValueError("전략을 찾을 수 없습니다")
final_name = name if name is not None else item.name
final_nick = nick if nick is not None else item.nick
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 전략이 이미 존재합니다")
if any(other.id != id and other.nick == final_nick for other in self.strategies.values()):
raise ValueError("동일한 nick 전략이 이미 존재합니다")
final_status = status if status is not None else item.status
if (final_name, final_config, final_status) == (item.name, item.config, item.status):
if (final_name, final_nick, final_config, final_status) == (
item.name,
item.nick,
item.config,
item.status,
):
return item
item.name = final_name
item.nick = final_nick
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
@@ -126,9 +145,13 @@ def test_public_catalog_excludes_inactive_and_inactive_lookup_conflicts() -> Non
repo = FakeStrategyRepo()
with _repos(repo):
response = _client().get("/v1/strategies", headers=_auth("read-token"))
active = _client().get("/v1/strategies/active-1", 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 response.json()[0]["nick"] == "target_value:base"
assert active.status_code == 200
assert active.json()["nick"] == "target_value:base"
assert inactive.status_code == 409
@@ -141,32 +164,78 @@ def test_admin_catalog_requires_admin_and_reports_missing_strategy() -> None:
assert missing.status_code == 404
def test_admin_create_and_patch_preserve_id_and_revision_semantics() -> None:
def test_admin_create_custom_nick_and_patch_nick_only_preserve_id_and_increment_revision() -> None:
repo = FakeStrategyRepo()
with _repos(repo):
created = _client().post(
"/v1/admin/strategies",
json={"name": "momentum", "config": {"window": 20}},
json={"name": "momentum", "nick": "mom-20", "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"},
json={"nick": "mom-primary"},
headers=_auth("admin-token"),
)
no_op = _client().patch(
f"/v1/admin/strategies/{strategy_id}",
json={"name": "momentum-v2"},
json={"nick": "mom-primary"},
headers=_auth("admin-token"),
)
assert created.status_code == 201
assert created.json()["nick"] == "mom-20"
assert created.json()["revision"] == 1
assert changed.status_code == 200
assert changed.json()["strategy_id"] == strategy_id
assert changed.json()["nick"] == "mom-primary"
assert changed.json()["revision"] == 2
assert no_op.json()["revision"] == 2
assert repo.strategies[strategy_id].nick.startswith("momentum-v2:")
assert repo.strategies[strategy_id].nick == "mom-primary"
def test_admin_patch_omitting_nick_preserves_custom_nick_when_name_and_config_change() -> None:
repo = FakeStrategyRepo()
with _repos(repo):
created = _client().post(
"/v1/admin/strategies",
json={"name": "momentum", "nick": "mom-custom", "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", "config": {"window": 30}},
headers=_auth("admin-token"),
)
assert changed.status_code == 200
assert changed.json()["nick"] == "mom-custom"
assert changed.json()["revision"] == 2
def test_admin_create_omitting_nick_retains_legacy_generated_value() -> None:
repo = FakeStrategyRepo()
with _repos(repo):
created = _client().post(
"/v1/admin/strategies",
json={"name": "legacy", "config": {"window": 20}},
headers=_auth("admin-token"),
)
canonical_config = '{"window":20}'
expected = f"legacy:{hashlib.sha1(canonical_config.encode()).hexdigest()[:12]}"
assert created.status_code == 201
assert created.json()["nick"] == expected
def test_admin_duplicate_nick_returns_conflict() -> None:
repo = FakeStrategyRepo()
with _repos(repo):
duplicate = _client().post(
"/v1/admin/strategies",
json={"name": "duplicate", "nick": "target_value:base", "config": {}},
headers=_auth("admin-token"),
)
assert duplicate.status_code == 409
def test_admin_create_inactive_starts_at_revision_one() -> None: