From ebd4559939cf3aa452fca9f7b11ba8861e3089b3 Mon Sep 17 00:00:00 2001 From: Justin Ko Date: Fri, 3 Apr 2026 08:03:27 +0000 Subject: [PATCH] feat: update strategy set schema fields --- app/cli/core/utils.py | 5 +- app/domain/models.py | 5 +- app/domain/services.py | 2 +- app/repositories/portfolio_repository.py | 2 +- app/repositories/postgres.py | 31 ++++---- app/schemas/portfolio.py | 5 +- app/services/portfolio_service.py | 5 +- docs/api-examples.md | 5 +- docs/db-schema-spec.md | 15 ++-- docs/portfoliohub-spec.md | 8 ++ .../20260403_0002_strategy_set_columns.py | 73 +++++++++++++++++++ tests/test_cli.py | 6 +- tests/test_domain_services.py | 6 +- 13 files changed, 131 insertions(+), 37 deletions(-) create mode 100644 migrations/versions/20260403_0002_strategy_set_columns.py diff --git a/app/cli/core/utils.py b/app/cli/core/utils.py index 7415112..9628290 100644 --- a/app/cli/core/utils.py +++ b/app/cli/core/utils.py @@ -95,8 +95,9 @@ def print_portfolio_detail(console: Console, detail: PortfolioDetailResponse) -> "\n".join( [ f"id: {detail.strategy_set.id}", - f"name: {detail.strategy_set.strategy_name}", - f"config: {detail.strategy_set.strategy_config}", + f"name: {detail.strategy_set.name}", + f"config: {detail.strategy_set.config}", + f"nick: {detail.strategy_set.nick}", ] ), title="Strategy Set", diff --git a/app/domain/models.py b/app/domain/models.py index 439fa19..f7bc436 100644 --- a/app/domain/models.py +++ b/app/domain/models.py @@ -10,8 +10,9 @@ from typing import Any, Protocol, runtime_checkable @dataclass class StrategySet: id: str - strategy_name: str - strategy_config: str + name: str + config: str + nick: str created_at: datetime | None = None diff --git a/app/domain/services.py b/app/domain/services.py index 012539e..2eecfca 100644 --- a/app/domain/services.py +++ b/app/domain/services.py @@ -45,7 +45,7 @@ class PortfolioDomainRepository(Protocol): def update_portfolio(self, portfolio_id: str, **fields: object) -> Portfolio: ... def list_portfolios(self, *, broker: str | None = None, status: str | None = None) -> list[Portfolio]: ... def get_strategy_set(self, id: str) -> StrategySet | None: ... - def get_or_create_strategy_set(self, strategy_name: str, strategy_config: str) -> StrategySet: ... + def get_or_create_strategy_set(self, name: str, config: str) -> StrategySet: ... def save( self, portfolio: Portfolio, diff --git a/app/repositories/portfolio_repository.py b/app/repositories/portfolio_repository.py index b61bcdc..c596a66 100644 --- a/app/repositories/portfolio_repository.py +++ b/app/repositories/portfolio_repository.py @@ -43,7 +43,7 @@ class PortfolioRepository(Protocol): def list_portfolios(self, *, broker: str | None = None, status: str | None = None) -> list[Portfolio]: ... - def get_or_create_strategy_set(self, strategy_name: str, strategy_config: str) -> StrategySet: ... + def get_or_create_strategy_set(self, name: str, config: str) -> StrategySet: ... def get_strategy_set(self, id: str) -> StrategySet | None: ... diff --git a/app/repositories/postgres.py b/app/repositories/postgres.py index b5151f7..3274143 100644 --- a/app/repositories/postgres.py +++ b/app/repositories/postgres.py @@ -2,6 +2,7 @@ from __future__ import annotations +import hashlib import json import uuid from dataclasses import dataclass @@ -82,10 +83,11 @@ class PostgresPortfolioRepository: """ CREATE TABLE IF NOT EXISTS strategy_sets ( id TEXT PRIMARY KEY, - strategy_name TEXT NOT NULL, - strategy_config TEXT NOT NULL DEFAULT '', + name TEXT NOT NULL, + config TEXT NOT NULL DEFAULT '', + nick TEXT NOT NULL UNIQUE, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - UNIQUE (strategy_name, strategy_config) + UNIQUE (name, config) ) """ ) @@ -352,40 +354,41 @@ class PostgresPortfolioRepository: rows = cur.fetchall() return [self._row_to_portfolio(row) for row in rows] - def get_or_create_strategy_set(self, strategy_name: str, strategy_config: str) -> StrategySet: + def get_or_create_strategy_set(self, name: str, config: str) -> StrategySet: self.ensure_schema() + nick = f"{name}:{hashlib.sha1(config.encode('utf-8')).hexdigest()[:12]}" with self._connect().cursor() as cur: new_id = str(uuid.uuid4()) cur.execute( """ - INSERT INTO strategy_sets (id, strategy_name, strategy_config) - VALUES (%s, %s, %s) - ON CONFLICT (strategy_name, strategy_config) DO NOTHING + INSERT INTO strategy_sets (id, name, config, nick) + VALUES (%s, %s, %s, %s) + ON CONFLICT (name, config) DO NOTHING """, - (new_id, strategy_name, strategy_config), + (new_id, name, config, nick), ) cur.execute( """ - SELECT id, strategy_name, strategy_config, created_at + SELECT id, name, config, nick, created_at FROM strategy_sets - WHERE strategy_name = %s AND strategy_config = %s + WHERE name = %s AND config = %s """, - (strategy_name, strategy_config), + (name, config), ) row = cur.fetchone() - return StrategySet(id=row[0], strategy_name=row[1], strategy_config=row[2], created_at=row[3]) + return StrategySet(id=row[0], name=row[1], config=row[2], nick=row[3], created_at=row[4]) def get_strategy_set(self, id: str) -> StrategySet | None: self.ensure_schema() with self._connect().cursor() as cur: cur.execute( - "SELECT id, strategy_name, strategy_config, created_at FROM strategy_sets WHERE id = %s", + "SELECT id, name, config, nick, created_at FROM strategy_sets WHERE id = %s", (id,), ) row = cur.fetchone() if row is None: return None - return StrategySet(id=row[0], strategy_name=row[1], strategy_config=row[2], created_at=row[3]) + return StrategySet(id=row[0], name=row[1], config=row[2], nick=row[3], created_at=row[4]) def save( self, diff --git a/app/schemas/portfolio.py b/app/schemas/portfolio.py index 81369f3..785f9ef 100644 --- a/app/schemas/portfolio.py +++ b/app/schemas/portfolio.py @@ -10,8 +10,9 @@ from pydantic import BaseModel, ConfigDict, Field class StrategySetResponse(BaseModel): id: str - strategy_name: str - strategy_config: str + name: str + config: str + nick: str created_at: datetime | None = None diff --git a/app/services/portfolio_service.py b/app/services/portfolio_service.py index fc31713..e6f32e9 100644 --- a/app/services/portfolio_service.py +++ b/app/services/portfolio_service.py @@ -65,8 +65,9 @@ def _to_strategy_set_response(strategy_set: StrategySet | None): return None return StrategySetResponse( id=strategy_set.id, - strategy_name=strategy_set.strategy_name, - strategy_config=strategy_set.strategy_config, + name=strategy_set.name, + config=strategy_set.config, + nick=strategy_set.nick, created_at=strategy_set.created_at, ) diff --git a/docs/api-examples.md b/docs/api-examples.md index fa907df..4167b23 100644 --- a/docs/api-examples.md +++ b/docs/api-examples.md @@ -114,8 +114,9 @@ Response 200: ], "strategy_set": { "id": "stv-001", - "strategy_name": "target_value", - "strategy_config": "{}", + "name": "target_value", + "config": "{}", + "nick": "target_value:99914b932bd3", "created_at": "2026-04-03T00:00:00Z" }, "live_quantity": null diff --git a/docs/db-schema-spec.md b/docs/db-schema-spec.md index 4a85638..c74c347 100644 --- a/docs/db-schema-spec.md +++ b/docs/db-schema-spec.md @@ -5,8 +5,10 @@ This document defines the database schema in an AI-agent-friendly format. - Source of truth: Alembic revisions in `migrations/versions` -- Current baseline revision: `20260403_0001` -- Baseline file: `migrations/versions/20260403_0001_initial_portfolio_schema.py` +- Current baseline revision: `20260403_0002` +- Baseline files: + - `migrations/versions/20260403_0001_initial_portfolio_schema.py` + - `migrations/versions/20260403_0002_strategy_set_columns.py` ## 2. Scope @@ -38,14 +40,16 @@ Columns: | Column | Type | Null | Default | Constraints / Notes | |---|---|---|---|---| | id | Text | NO | - | Primary Key | -| strategy_name | Text | NO | - | Part of unique pair | -| strategy_config | Text | NO | "" | Part of unique pair | +| name | Text | NO | - | Strategy name, part of unique pair | +| config | Text | NO | "" | Strategy config payload, part of unique pair | +| nick | Text | NO | - | Unique strategy set nickname | | created_at | DateTime(timezone=True) | NO | NOW() | Creation timestamp | Constraints: - Primary key: `id` -- Unique: `uq_strategy_sets_name_config` on (`strategy_name`, `strategy_config`) +- Unique: `uq_strategy_sets_name_config` on (`name`, `config`) +- Unique: `uq_strategy_sets_nick` on (`nick`) ### 4.2 portfolios @@ -155,3 +159,4 @@ When schema changes: | Revision ID | File | Summary | |---|---|---| | 20260403_0001 | migrations/versions/20260403_0001_initial_portfolio_schema.py | Initial schema for strategy sets, portfolios, ledger, indexes, optional hypertable | +| 20260403_0002 | migrations/versions/20260403_0002_strategy_set_columns.py | Rename `strategy_name`->`name`, `strategy_config`->`config`, add unique `nick` | diff --git a/docs/portfoliohub-spec.md b/docs/portfoliohub-spec.md index ae46b4b..2000ff2 100644 --- a/docs/portfoliohub-spec.md +++ b/docs/portfoliohub-spec.md @@ -275,6 +275,14 @@ Base routes: - `strategy_set: StrategySetResponse | null` - `live_quantity: float | null` +`StrategySetResponse` + +- `id: str` +- `name: str` +- `config: str` +- `nick: str` +- `created_at: datetime | null` + `LedgerEntryResponse` - Snapshot-oriented ledger response including balances after each action diff --git a/migrations/versions/20260403_0002_strategy_set_columns.py b/migrations/versions/20260403_0002_strategy_set_columns.py new file mode 100644 index 0000000..bb109bf --- /dev/null +++ b/migrations/versions/20260403_0002_strategy_set_columns.py @@ -0,0 +1,73 @@ +"""rename strategy_set columns and add nick + +Revision ID: 20260403_0002 +Revises: 20260403_0001 +Create Date: 2026-04-03 00:30:00 +""" + +from __future__ import annotations + +from alembic import op +import sqlalchemy as sa + + +revision = "20260403_0002" +down_revision = "20260403_0001" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.add_column("strategy_sets", sa.Column("name", sa.Text(), nullable=True)) + op.add_column("strategy_sets", sa.Column("config", sa.Text(), nullable=True, server_default="")) + op.add_column("strategy_sets", sa.Column("nick", sa.Text(), nullable=True)) + + op.execute( + sa.text( + """ + UPDATE strategy_sets + SET + name = strategy_name, + config = strategy_config, + nick = strategy_name || ':' || substr(md5(strategy_config), 1, 12) + """ + ) + ) + + op.alter_column("strategy_sets", "name", nullable=False) + op.alter_column("strategy_sets", "config", nullable=False) + op.alter_column("strategy_sets", "nick", nullable=False) + + op.drop_constraint("uq_strategy_sets_name_config", "strategy_sets", type_="unique") + op.create_unique_constraint("uq_strategy_sets_name_config", "strategy_sets", ["name", "config"]) + op.create_unique_constraint("uq_strategy_sets_nick", "strategy_sets", ["nick"]) + + op.drop_column("strategy_sets", "strategy_name") + op.drop_column("strategy_sets", "strategy_config") + + +def downgrade() -> None: + op.add_column("strategy_sets", sa.Column("strategy_name", sa.Text(), nullable=True)) + op.add_column("strategy_sets", sa.Column("strategy_config", sa.Text(), nullable=True, server_default="")) + + op.execute( + sa.text( + """ + UPDATE strategy_sets + SET + strategy_name = name, + strategy_config = config + """ + ) + ) + + op.alter_column("strategy_sets", "strategy_name", nullable=False) + op.alter_column("strategy_sets", "strategy_config", nullable=False) + + op.drop_constraint("uq_strategy_sets_nick", "strategy_sets", type_="unique") + op.drop_constraint("uq_strategy_sets_name_config", "strategy_sets", type_="unique") + op.create_unique_constraint("uq_strategy_sets_name_config", "strategy_sets", ["strategy_name", "strategy_config"]) + + op.drop_column("strategy_sets", "nick") + op.drop_column("strategy_sets", "name") + op.drop_column("strategy_sets", "config") diff --git a/tests/test_cli.py b/tests/test_cli.py index 0c02766..d9e73f0 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -15,7 +15,7 @@ class FakeRepo: def __init__(self, portfolio: Portfolio | None = None) -> None: self._portfolio = portfolio self.entries: list[LedgerEntry] = [] - self.strategy_set = StrategySet(id="set-1", strategy_name="target_value", strategy_config="{}") + self.strategy_set = StrategySet(id="set-1", name="target_value", config="{}", nick="target_value:99914b932bd3") self.closed = False def close(self) -> None: @@ -99,8 +99,8 @@ class FakeRepo: return [] return [self._portfolio] - def get_or_create_strategy_set(self, strategy_name: str, strategy_config: str) -> StrategySet: - self.strategy_set = StrategySet(id="set-1", strategy_name=strategy_name, strategy_config=strategy_config) + def get_or_create_strategy_set(self, name: str, config: str) -> StrategySet: + self.strategy_set = StrategySet(id="set-1", name=name, config=config, nick=f"{name}:99914b932bd3") return self.strategy_set def get_strategy_set(self, id: str) -> StrategySet | None: diff --git a/tests/test_domain_services.py b/tests/test_domain_services.py index 3468fda..598b911 100644 --- a/tests/test_domain_services.py +++ b/tests/test_domain_services.py @@ -12,7 +12,7 @@ class FakeRepo: def __init__(self, portfolio: Portfolio | None = None) -> None: self._portfolio = portfolio self.entries: list[LedgerEntry] = [] - self.strategy_set = StrategySet(id="set-1", strategy_name="target_value", strategy_config="{}") + self.strategy_set = StrategySet(id="set-1", name="target_value", config="{}", nick="target_value:99914b932bd3") self.last_delete: tuple[str, bool] | None = None def get(self, portfolio_id: str) -> Portfolio: @@ -60,8 +60,8 @@ class FakeRepo: def get_strategy_set(self, id: str) -> StrategySet | None: return self.strategy_set if id == self.strategy_set.id else None - def get_or_create_strategy_set(self, strategy_name: str, strategy_config: str) -> StrategySet: - self.strategy_set = StrategySet(id="set-1", strategy_name=strategy_name, strategy_config=strategy_config) + def get_or_create_strategy_set(self, name: str, config: str) -> StrategySet: + self.strategy_set = StrategySet(id="set-1", name=name, config=config, nick=f"{name}:99914b932bd3") return self.strategy_set def save(