feat(strategy): expose configurable nick contract
This commit is contained in:
@@ -3,6 +3,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
import json
|
||||||
|
from typing import NoReturn
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Response, status
|
from fastapi import APIRouter, Depends, HTTPException, Response, status
|
||||||
|
|
||||||
@@ -11,7 +12,11 @@ from app.core.config import Settings, get_settings
|
|||||||
from app.domain.models import StrategySet
|
from app.domain.models import StrategySet
|
||||||
from app.repositories.factory import get_portfolio_repository_from_settings
|
from app.repositories.factory import get_portfolio_repository_from_settings
|
||||||
from app.schemas.portfolio import CreateStrategySetRequest, UpdateStrategySetRequest
|
from app.schemas.portfolio import CreateStrategySetRequest, UpdateStrategySetRequest
|
||||||
from app.schemas.v1 import StrategyCreateRequest, StrategyResponse, StrategyUpdateRequest
|
from app.schemas.v1 import (
|
||||||
|
StrategyCreateRequest,
|
||||||
|
StrategyResponse,
|
||||||
|
StrategyUpdateRequest,
|
||||||
|
)
|
||||||
from app.services.strategy_set_service import (
|
from app.services.strategy_set_service import (
|
||||||
StrategyConflictError,
|
StrategyConflictError,
|
||||||
StrategyInactiveError,
|
StrategyInactiveError,
|
||||||
@@ -44,6 +49,7 @@ def _response(strategy: StrategySet) -> StrategyResponse:
|
|||||||
parsed = {}
|
parsed = {}
|
||||||
return StrategyResponse(
|
return StrategyResponse(
|
||||||
strategy_id=strategy.id,
|
strategy_id=strategy.id,
|
||||||
|
nick=strategy.nick,
|
||||||
name=strategy.name,
|
name=strategy.name,
|
||||||
config=parsed if isinstance(parsed, dict) else {},
|
config=parsed if isinstance(parsed, dict) else {},
|
||||||
status=strategy.status,
|
status=strategy.status,
|
||||||
@@ -53,7 +59,7 @@ def _response(strategy: StrategySet) -> StrategyResponse:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _raise_strategy_error(exc: ValueError) -> None:
|
def _raise_strategy_error(exc: ValueError) -> NoReturn:
|
||||||
if isinstance(exc, StrategyNotFoundError):
|
if isinstance(exc, StrategyNotFoundError):
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
|
||||||
if isinstance(exc, (StrategyInactiveError, StrategyInUseError, StrategyConflictError)):
|
if isinstance(exc, (StrategyInactiveError, StrategyInUseError, StrategyConflictError)):
|
||||||
@@ -129,14 +135,18 @@ def create_admin_strategy(
|
|||||||
) -> StrategyResponse:
|
) -> StrategyResponse:
|
||||||
service, repository = _get_service(settings)
|
service, repository = _get_service(settings)
|
||||||
try:
|
try:
|
||||||
strategy = service.create(
|
try:
|
||||||
CreateStrategySetRequest(
|
strategy = service.create(
|
||||||
name=body.name,
|
CreateStrategySetRequest(
|
||||||
config=_canonical_config(body.config),
|
name=body.name,
|
||||||
status=body.status,
|
nick=body.nick,
|
||||||
|
config=_canonical_config(body.config),
|
||||||
|
status=body.status,
|
||||||
|
)
|
||||||
)
|
)
|
||||||
)
|
return _response(strategy)
|
||||||
return _response(strategy)
|
except ValueError as exc:
|
||||||
|
_raise_strategy_error(exc)
|
||||||
finally:
|
finally:
|
||||||
_close_repository(repository)
|
_close_repository(repository)
|
||||||
|
|
||||||
@@ -155,6 +165,7 @@ def update_admin_strategy(
|
|||||||
strategy_id,
|
strategy_id,
|
||||||
UpdateStrategySetRequest(
|
UpdateStrategySetRequest(
|
||||||
name=body.name,
|
name=body.name,
|
||||||
|
nick=body.nick,
|
||||||
config=_canonical_config(body.config) if body.config is not None else None,
|
config=_canonical_config(body.config) if body.config is not None else None,
|
||||||
status=body.status,
|
status=body.status,
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -243,7 +243,14 @@ class PortfolioRepository(Protocol):
|
|||||||
|
|
||||||
def get_or_create_strategy_set(self, name: str, config: str) -> StrategySet: ...
|
def get_or_create_strategy_set(self, name: str, config: str) -> StrategySet: ...
|
||||||
|
|
||||||
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: ...
|
||||||
|
|
||||||
def get_strategy_set(self, id: str) -> StrategySet | None: ...
|
def get_strategy_set(self, id: str) -> StrategySet | None: ...
|
||||||
|
|
||||||
@@ -254,6 +261,7 @@ class PortfolioRepository(Protocol):
|
|||||||
id: str,
|
id: str,
|
||||||
*,
|
*,
|
||||||
name: str | None = None,
|
name: str | None = None,
|
||||||
|
nick: str | None = None,
|
||||||
config: str | None = None,
|
config: str | None = None,
|
||||||
status: str | None = None,
|
status: str | None = None,
|
||||||
) -> StrategySet: ...
|
) -> StrategySet: ...
|
||||||
|
|||||||
@@ -1360,28 +1360,38 @@ class PostgresPortfolioRepository:
|
|||||||
def get_or_create_strategy_set(self, name: str, config: str) -> StrategySet:
|
def get_or_create_strategy_set(self, name: str, config: str) -> StrategySet:
|
||||||
return self.create_strategy_set(name, config)
|
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:
|
||||||
self.ensure_schema()
|
self.ensure_schema()
|
||||||
nick = f"{name}:{hashlib.sha1(config.encode('utf-8')).hexdigest()[:12]}"
|
final_nick = nick or f"{name}:{hashlib.sha1(config.encode('utf-8')).hexdigest()[:12]}"
|
||||||
with self._connect().cursor() as cur:
|
try:
|
||||||
new_id = str(uuid.uuid4())
|
with self._connect().cursor() as cur:
|
||||||
cur.execute(
|
new_id = str(uuid.uuid4())
|
||||||
"""
|
cur.execute(
|
||||||
INSERT INTO strategy_sets (id, name, config, nick, status)
|
"""
|
||||||
VALUES (%s, %s, %s, %s, %s)
|
INSERT INTO strategy_sets (id, name, config, nick, status)
|
||||||
ON CONFLICT (name, config) DO NOTHING
|
VALUES (%s, %s, %s, %s, %s)
|
||||||
""",
|
ON CONFLICT (name, config) DO NOTHING
|
||||||
(new_id, name, config, nick, status),
|
""",
|
||||||
)
|
(new_id, name, config, final_nick, status),
|
||||||
cur.execute(
|
)
|
||||||
"""
|
cur.execute(
|
||||||
SELECT id, name, config, nick, status, revision, created_at, updated_at
|
"""
|
||||||
FROM strategy_sets
|
SELECT id, name, config, nick, status, revision, created_at, updated_at
|
||||||
WHERE name = %s AND config = %s
|
FROM strategy_sets
|
||||||
""",
|
WHERE name = %s AND config = %s
|
||||||
(name, config),
|
""",
|
||||||
)
|
(name, config),
|
||||||
row = cur.fetchone()
|
)
|
||||||
|
row = cur.fetchone()
|
||||||
|
except psycopg.errors.UniqueViolation as exc:
|
||||||
|
raise ValueError("동일한 nick 전략이 이미 존재합니다") from exc
|
||||||
return self._row_to_strategy_set(row)
|
return self._row_to_strategy_set(row)
|
||||||
|
|
||||||
def get_strategy_set(self, id: str) -> StrategySet | None:
|
def get_strategy_set(self, id: str) -> StrategySet | None:
|
||||||
@@ -1414,6 +1424,7 @@ class PostgresPortfolioRepository:
|
|||||||
id: str,
|
id: str,
|
||||||
*,
|
*,
|
||||||
name: str | None = None,
|
name: str | None = None,
|
||||||
|
nick: str | None = None,
|
||||||
config: str | None = None,
|
config: str | None = None,
|
||||||
status: str | None = None,
|
status: str | None = None,
|
||||||
) -> StrategySet:
|
) -> StrategySet:
|
||||||
@@ -1435,15 +1446,16 @@ class PostgresPortfolioRepository:
|
|||||||
raise ValueError("전략을 찾을 수 없습니다")
|
raise ValueError("전략을 찾을 수 없습니다")
|
||||||
current = self._row_to_strategy_set(row)
|
current = self._row_to_strategy_set(row)
|
||||||
final_name = name if name is not None else current.name
|
final_name = name if name is not None else current.name
|
||||||
|
final_nick = nick if nick is not None else current.nick
|
||||||
final_config = config if config is not None else current.config
|
final_config = config if config is not None else current.config
|
||||||
final_status = status if status is not None else current.status
|
final_status = status if status is not None else current.status
|
||||||
if (
|
if (
|
||||||
final_name == current.name
|
final_name == current.name
|
||||||
|
and final_nick == current.nick
|
||||||
and final_config == current.config
|
and final_config == current.config
|
||||||
and final_status == current.status
|
and final_status == current.status
|
||||||
):
|
):
|
||||||
return current
|
return current
|
||||||
nick = f"{final_name}:{hashlib.sha1(final_config.encode('utf-8')).hexdigest()[:12]}"
|
|
||||||
cur.execute(
|
cur.execute(
|
||||||
"""
|
"""
|
||||||
UPDATE strategy_sets
|
UPDATE strategy_sets
|
||||||
@@ -1456,11 +1468,11 @@ class PostgresPortfolioRepository:
|
|||||||
WHERE id = %s
|
WHERE id = %s
|
||||||
RETURNING id, name, config, nick, status, revision, created_at, updated_at
|
RETURNING id, name, config, nick, status, revision, created_at, updated_at
|
||||||
""",
|
""",
|
||||||
(final_name, final_config, nick, final_status, id),
|
(final_name, final_config, final_nick, final_status, id),
|
||||||
)
|
)
|
||||||
updated = cur.fetchone()
|
updated = cur.fetchone()
|
||||||
except psycopg.errors.UniqueViolation as exc:
|
except psycopg.errors.UniqueViolation as exc:
|
||||||
raise ValueError("동일한 name/config 전략이 이미 존재합니다") from exc
|
raise ValueError("동일한 name/config 또는 nick 전략이 이미 존재합니다") from exc
|
||||||
if updated is None:
|
if updated is None:
|
||||||
raise ValueError("전략을 찾을 수 없습니다")
|
raise ValueError("전략을 찾을 수 없습니다")
|
||||||
return self._row_to_strategy_set(updated)
|
return self._row_to_strategy_set(updated)
|
||||||
|
|||||||
@@ -186,6 +186,7 @@ class CreateStrategySetRequest(BaseModel):
|
|||||||
model_config = ConfigDict(extra="forbid")
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
name: str
|
name: str
|
||||||
|
nick: str | None = Field(default=None, min_length=1)
|
||||||
config: str
|
config: str
|
||||||
status: Literal["active", "inactive"] = "active"
|
status: Literal["active", "inactive"] = "active"
|
||||||
|
|
||||||
@@ -194,6 +195,7 @@ class UpdateStrategySetRequest(BaseModel):
|
|||||||
model_config = ConfigDict(extra="forbid")
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
name: str | None = None
|
name: str | None = None
|
||||||
|
nick: str | None = Field(default=None, min_length=1)
|
||||||
config: str | None = None
|
config: str | None = None
|
||||||
status: Literal["active", "inactive"] | None = None
|
status: Literal["active", "inactive"] | None = None
|
||||||
|
|
||||||
|
|||||||
@@ -305,6 +305,7 @@ StrategyStatus = Literal["active", "inactive"]
|
|||||||
|
|
||||||
class StrategyResponse(BaseModel):
|
class StrategyResponse(BaseModel):
|
||||||
strategy_id: str
|
strategy_id: str
|
||||||
|
nick: str
|
||||||
name: str
|
name: str
|
||||||
config: dict[str, Any] = Field(default_factory=dict)
|
config: dict[str, Any] = Field(default_factory=dict)
|
||||||
status: StrategyStatus
|
status: StrategyStatus
|
||||||
@@ -317,6 +318,7 @@ class StrategyCreateRequest(BaseModel):
|
|||||||
model_config = ConfigDict(extra="forbid")
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
name: str = Field(..., min_length=1)
|
name: str = Field(..., min_length=1)
|
||||||
|
nick: str | None = Field(default=None, min_length=1)
|
||||||
config: dict[str, Any] = Field(default_factory=dict)
|
config: dict[str, Any] = Field(default_factory=dict)
|
||||||
status: StrategyStatus = "active"
|
status: StrategyStatus = "active"
|
||||||
|
|
||||||
@@ -325,6 +327,7 @@ class StrategyUpdateRequest(BaseModel):
|
|||||||
model_config = ConfigDict(extra="forbid")
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
name: str | None = Field(default=None, min_length=1)
|
name: str | None = Field(default=None, min_length=1)
|
||||||
|
nick: str | None = Field(default=None, min_length=1)
|
||||||
config: dict[str, Any] | None = None
|
config: dict[str, Any] | None = None
|
||||||
status: StrategyStatus | None = None
|
status: StrategyStatus | None = None
|
||||||
|
|
||||||
|
|||||||
@@ -46,26 +46,49 @@ class StrategySetService:
|
|||||||
return self.get(strategy_set_id, active_only=True)
|
return self.get(strategy_set_id, active_only=True)
|
||||||
|
|
||||||
def create(self, payload: CreateStrategySetRequest) -> StrategySet:
|
def create(self, payload: CreateStrategySetRequest) -> StrategySet:
|
||||||
create_strategy = cast(
|
try:
|
||||||
Callable[..., StrategySet] | None,
|
create_strategy = cast(
|
||||||
getattr(self._repository, "create_strategy_set", None),
|
Callable[..., StrategySet] | None,
|
||||||
)
|
getattr(self._repository, "create_strategy_set", None),
|
||||||
if create_strategy is not None:
|
|
||||||
strategy = create_strategy(payload.name, payload.config, status=payload.status)
|
|
||||||
else:
|
|
||||||
strategy = self._repository.get_or_create_strategy_set(payload.name, payload.config)
|
|
||||||
if payload.status != strategy.status:
|
|
||||||
strategy = self.update(
|
|
||||||
strategy.id,
|
|
||||||
UpdateStrategySetRequest(status=payload.status),
|
|
||||||
)
|
)
|
||||||
return strategy
|
if create_strategy is not None:
|
||||||
|
strategy = create_strategy(
|
||||||
|
payload.name,
|
||||||
|
payload.config,
|
||||||
|
nick=payload.nick,
|
||||||
|
status=payload.status,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
strategy = self._repository.get_or_create_strategy_set(payload.name, payload.config)
|
||||||
|
if payload.nick is not None and payload.nick != strategy.nick:
|
||||||
|
strategy = self.update(
|
||||||
|
strategy.id,
|
||||||
|
UpdateStrategySetRequest(nick=payload.nick),
|
||||||
|
)
|
||||||
|
if payload.status != strategy.status:
|
||||||
|
strategy = self.update(
|
||||||
|
strategy.id,
|
||||||
|
UpdateStrategySetRequest(status=payload.status),
|
||||||
|
)
|
||||||
|
return strategy
|
||||||
|
except StrategyConflictError:
|
||||||
|
raise
|
||||||
|
except ValueError as exc:
|
||||||
|
raise StrategyConflictError(str(exc)) from exc
|
||||||
|
|
||||||
def update(self, strategy_set_id: str, payload: UpdateStrategySetRequest) -> StrategySet:
|
def update(self, strategy_set_id: str, payload: UpdateStrategySetRequest) -> StrategySet:
|
||||||
try:
|
try:
|
||||||
|
if payload.nick is None:
|
||||||
|
return self._repository.update_strategy_set(
|
||||||
|
strategy_set_id,
|
||||||
|
name=payload.name,
|
||||||
|
config=payload.config,
|
||||||
|
status=payload.status,
|
||||||
|
)
|
||||||
return self._repository.update_strategy_set(
|
return self._repository.update_strategy_set(
|
||||||
strategy_set_id,
|
strategy_set_id,
|
||||||
name=payload.name,
|
name=payload.name,
|
||||||
|
nick=payload.nick,
|
||||||
config=payload.config,
|
config=payload.config,
|
||||||
status=payload.status,
|
status=payload.status,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ PostgreSQL endpoint. Normal unit-test environments skip this module.
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import hashlib
|
|
||||||
import os
|
import os
|
||||||
import subprocess
|
import subprocess
|
||||||
import threading
|
import threading
|
||||||
@@ -73,9 +72,12 @@ def _prepare_catalog_fixture(repo: PostgresPortfolioRepository) -> tuple[str, st
|
|||||||
return strategy.id, asset_id, owner_user_id
|
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()
|
owner = _repo()
|
||||||
strategy_id, asset_id, owner_user_id = _prepare_catalog_fixture(owner)
|
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}"
|
renamed = f"concurrent-renamed-{uuid.uuid4().hex}"
|
||||||
barrier = threading.Barrier(3)
|
barrier = threading.Barrier(3)
|
||||||
errors: list[BaseException] = []
|
errors: list[BaseException] = []
|
||||||
@@ -114,8 +116,7 @@ def test_concurrent_strategy_patch_keeps_derived_nick_consistent() -> None:
|
|||||||
assert final.name == renamed
|
assert final.name == renamed
|
||||||
assert final.config == '{"window":21}'
|
assert final.config == '{"window":21}'
|
||||||
assert final.revision == 3
|
assert final.revision == 3
|
||||||
expected_nick = f"{final.name}:{hashlib.sha1(final.config.encode()).hexdigest()[:12]}"
|
assert final.nick == initial_nick
|
||||||
assert final.nick == expected_nick
|
|
||||||
portfolio_id = str(uuid.uuid4())
|
portfolio_id = str(uuid.uuid4())
|
||||||
owner.create_portfolio(
|
owner.create_portfolio(
|
||||||
portfolio_id,
|
portfolio_id,
|
||||||
|
|||||||
@@ -43,19 +43,29 @@ class FakeStrategyRepo:
|
|||||||
def get_or_create_strategy_set(self, name: str, config: str) -> StrategySet:
|
def get_or_create_strategy_set(self, name: str, config: str) -> StrategySet:
|
||||||
return self.create_strategy_set(name, config)
|
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(
|
existing = next(
|
||||||
(item for item in self.strategies.values() if item.name == name and item.config == config),
|
(item for item in self.strategies.values() if item.name == name and item.config == config),
|
||||||
None,
|
None,
|
||||||
)
|
)
|
||||||
if existing is not None:
|
if existing is not None:
|
||||||
return existing
|
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)
|
now = datetime.now(timezone.utc)
|
||||||
item = StrategySet(
|
item = StrategySet(
|
||||||
id=f"created-{len(self.strategies)}",
|
id=f"created-{len(self.strategies)}",
|
||||||
name=name,
|
name=name,
|
||||||
config=config,
|
config=config,
|
||||||
nick=f"{name}:{hashlib.sha1(config.encode()).hexdigest()[:12]}",
|
nick=final_nick,
|
||||||
status=status,
|
status=status,
|
||||||
created_at=now,
|
created_at=now,
|
||||||
updated_at=now,
|
updated_at=now,
|
||||||
@@ -68,6 +78,7 @@ class FakeStrategyRepo:
|
|||||||
id: str,
|
id: str,
|
||||||
*,
|
*,
|
||||||
name: str | None = None,
|
name: str | None = None,
|
||||||
|
nick: str | None = None,
|
||||||
config: str | None = None,
|
config: str | None = None,
|
||||||
status: str | None = None,
|
status: str | None = None,
|
||||||
) -> StrategySet:
|
) -> StrategySet:
|
||||||
@@ -75,19 +86,27 @@ class FakeStrategyRepo:
|
|||||||
if item is None:
|
if item is None:
|
||||||
raise ValueError("전략을 찾을 수 없습니다")
|
raise ValueError("전략을 찾을 수 없습니다")
|
||||||
final_name = name if name is not None else item.name
|
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
|
final_config = config if config is not None else item.config
|
||||||
if any(
|
if any(
|
||||||
other.id != id and other.name == final_name and other.config == final_config
|
other.id != id and other.name == final_name and other.config == final_config
|
||||||
for other in self.strategies.values()
|
for other in self.strategies.values()
|
||||||
):
|
):
|
||||||
raise ValueError("동일한 name/config 전략이 이미 존재합니다")
|
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
|
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
|
return item
|
||||||
item.name = final_name
|
item.name = final_name
|
||||||
|
item.nick = final_nick
|
||||||
item.config = final_config
|
item.config = final_config
|
||||||
item.status = final_status
|
item.status = final_status
|
||||||
item.nick = f"{final_name}:{hashlib.sha1(final_config.encode()).hexdigest()[:12]}"
|
|
||||||
item.revision += 1
|
item.revision += 1
|
||||||
item.updated_at = datetime.now(timezone.utc)
|
item.updated_at = datetime.now(timezone.utc)
|
||||||
return item
|
return item
|
||||||
@@ -126,9 +145,13 @@ def test_public_catalog_excludes_inactive_and_inactive_lookup_conflicts() -> Non
|
|||||||
repo = FakeStrategyRepo()
|
repo = FakeStrategyRepo()
|
||||||
with _repos(repo):
|
with _repos(repo):
|
||||||
response = _client().get("/v1/strategies", headers=_auth("read-token"))
|
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"))
|
inactive = _client().get("/v1/strategies/inactive-1", headers=_auth("read-token"))
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
assert [item["strategy_id"] for item in response.json()] == ["active-1"]
|
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
|
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
|
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()
|
repo = FakeStrategyRepo()
|
||||||
with _repos(repo):
|
with _repos(repo):
|
||||||
created = _client().post(
|
created = _client().post(
|
||||||
"/v1/admin/strategies",
|
"/v1/admin/strategies",
|
||||||
json={"name": "momentum", "config": {"window": 20}},
|
json={"name": "momentum", "nick": "mom-20", "config": {"window": 20}},
|
||||||
headers=_auth("admin-token"),
|
headers=_auth("admin-token"),
|
||||||
)
|
)
|
||||||
strategy_id = created.json()["strategy_id"]
|
strategy_id = created.json()["strategy_id"]
|
||||||
changed = _client().patch(
|
changed = _client().patch(
|
||||||
f"/v1/admin/strategies/{strategy_id}",
|
f"/v1/admin/strategies/{strategy_id}",
|
||||||
json={"name": "momentum-v2"},
|
json={"nick": "mom-primary"},
|
||||||
headers=_auth("admin-token"),
|
headers=_auth("admin-token"),
|
||||||
)
|
)
|
||||||
no_op = _client().patch(
|
no_op = _client().patch(
|
||||||
f"/v1/admin/strategies/{strategy_id}",
|
f"/v1/admin/strategies/{strategy_id}",
|
||||||
json={"name": "momentum-v2"},
|
json={"nick": "mom-primary"},
|
||||||
headers=_auth("admin-token"),
|
headers=_auth("admin-token"),
|
||||||
)
|
)
|
||||||
assert created.status_code == 201
|
assert created.status_code == 201
|
||||||
|
assert created.json()["nick"] == "mom-20"
|
||||||
assert created.json()["revision"] == 1
|
assert created.json()["revision"] == 1
|
||||||
assert changed.status_code == 200
|
assert changed.status_code == 200
|
||||||
assert changed.json()["strategy_id"] == strategy_id
|
assert changed.json()["strategy_id"] == strategy_id
|
||||||
|
assert changed.json()["nick"] == "mom-primary"
|
||||||
assert changed.json()["revision"] == 2
|
assert changed.json()["revision"] == 2
|
||||||
assert no_op.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:
|
def test_admin_create_inactive_starts_at_revision_one() -> None:
|
||||||
|
|||||||
Reference in New Issue
Block a user