Skip to content

feat: simplify trade profile schema and add MCP tools#193

Merged
mgh3326 merged 6 commits intomainfrom
feat/trade-profile-simplify
Mar 3, 2026
Merged

feat: simplify trade profile schema and add MCP tools#193
mgh3326 merged 6 commits intomainfrom
feat/trade-profile-simplify

Conversation

@robin-watcha
Copy link
Collaborator

@robin-watcha robin-watcha commented Mar 3, 2026

Summary

  • Schema simplification: Remove broker_account_id from asset_profiles and market_filters tables, making trade profiles account-agnostic. Alembic migration replaces partial unique indexes with simple ones.
  • New MCP tools: get_asset_profile (read with filters) and set_asset_profile (upsert with auto-correction rules for exit/hold_only profiles). External market_type param (kr/us/crypto) mapped internally to instrument_type enum.
  • Shared KR_SYMBOLS module: app/core/kr_symbols.py with symbol dict and normalization helper.
  • Cleanup: Removed scripts/seed_trade_profiles.py and its integration tests — profiles are now managed exclusively via set_asset_profile.

Changes

  • 6 files created, 5 files modified, 2 files deleted (net +43 lines)
  • 13 files touched across migration, models, MCP tooling, and tests

Test Plan

  • uv run ruff check app/ tests/ — passed
  • uv run ty check on all changed app files — passed
  • uv run pytest --no-cov -q — 1635 passed, 4 skipped

Summary by CodeRabbit

  • New Features

    • Asset profile management tools to get/set trading profiles with validation and rule handling
    • Korean equity symbol mapping and normalization support
  • Refactor

    • Simplified trade-profile schema to enforce unified uniqueness per user/instrument
    • Reorganized tool registration to include trade-profile tooling
  • Chores

    • Removed legacy trade-profile seeding script
    • Updated repository ignore rules
  • Tests

    • Added unit tests for trade-profile tooling; removed legacy integration persistence tests

@coderabbitai
Copy link

coderabbitai bot commented Mar 3, 2026

📝 Walkthrough

Walkthrough

Removes broker_account_id from trade-profile schema and models, adds Alembic migration to apply the change, introduces MCP trade-profile tooling (get/set) and KR symbol utilities, deletes legacy seeding script and an integration test, and adds unit tests for the new tooling.

Changes

Cohort / File(s) Summary
Gitignore
/.gitignore
Add .worktrees/ to ignored paths.
DB migration
alembic/versions/a1b2c3d4e5f6_simplify_trade_profile_schema.py
New Alembic revision: drops existing partial unique indexes and broker_account_id columns; creates two new composite unique indexes; provides reversible downgrade that restores previous columns, FK constraints and partial indexes.
ORM models
app/models/trade_profile.py
Remove broker_account_id columns and add composite unique indexes on (user_id, symbol, instrument_type) and (user_id, instrument_type, filter_name) to match migration.
MCP package exports
app/mcp_server/__init__.py, app/mcp_server/tooling/__init__.py, app/mcp_server/tooling/registry.py
Rewire imports/exports to expose trade-profile tooling; add get_asset_profile/set_asset_profile to AVAILABLE_TOOL_NAMES; invoke trade-profile registrar from registry.
Trade-profile tooling
app/mcp_server/tooling/trade_profile_registration.py, app/mcp_server/tooling/trade_profile_tools.py
New registration module and comprehensive implementation of get_asset_profile and set_asset_profile (async DB access, normalization/validation, rule application, upsert logic, change logging, serialization).
KR symbol utilities
app/core/kr_symbols.py
New mapping KR_SYMBOLS and normalize_kr_symbol() to resolve/normalize Korean equity symbols and names.
Removed seed & integration test
scripts/seed_trade_profiles.py (deleted), tests/integration/test_trade_profile_persistence.py (deleted)
Delete legacy seeding script and related integration test that seeded trade-profile data and validated persistence.
Unit tests updated/added
tests/models/test_trade_profile.py, tests/test_mcp_tool_registration.py, tests/test_mcp_trade_profile_tools.py
Update model tests to stop using broker_account_id; add unit tests for tooling registration and comprehensive tests for get/set behavior, validation, rule logic, and change logging.

Sequence Diagram

sequenceDiagram
    participant Client as MCP Client
    participant Registry as Tool Registry
    participant Handler as TradeProfile Handler
    participant DB as Database (SQLAlchemy)
    participant Logger as ProfileChangeLog

    Client->>Registry: register_trade_profile_tools(mcp)
    Registry->>Handler: apply `@mcp.tool` to get/set handlers

    Client->>Handler: get_asset_profile(params)
    Handler->>DB: open session & query AssetProfile
    DB-->>Handler: rows
    Handler->>Handler: serialize results
    Handler-->>Client: {data, count, tier_rule_params?}

    Client->>Handler: set_asset_profile(payload)
    Handler->>Handler: normalize inputs, determine instrument_type
    Handler->>Handler: _apply_profile_rules(profile)
    Handler->>DB: upsert AssetProfile / TierRuleParam
    DB-->>Handler: commit success
    Handler->>Logger: insert ProfileChangeLog entry
    Logger-->>Handler: log stored
    Handler-->>Client: {action, profile_data}
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~50 minutes

Possibly related PRs

Poem

🐰 Hopping through the schema glade,

broker_id gently fades away.
Tools awake to fetch and set,
KR codes tidy, rules are met.
Seeds retire — new tests at play.

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 19.61% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'feat: simplify trade profile schema and add MCP tools' directly and accurately reflects the two main changes in the PR: schema simplification (removing broker_account_id) and addition of MCP tools (get/set_asset_profile).

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
  • 📝 Generate docstrings (stacked PR)
  • 📝 Generate docstrings (commit on current branch)
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch feat/trade-profile-simplify

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@claude
Copy link

claude bot commented Mar 3, 2026

PR Review: feat/trade-profile-simplify

Overall this is a clean, well-structured PR with good test coverage. The schema simplification is a clear improvement and the MCP tooling is nicely layered. A few issues worth addressing before merging:


Bugs / Correctness

1. _apply_profile_rules allows sell_mode=None to bypass the exit constraint

In trade_profile_tools.py:

if profile_value == "exit":
    if requested_buy_allowed is True:
        raise ValueError("profile=exit requires buy_allowed=False")
    if requested_sell_mode is not None and requested_sell_mode != "any":
        raise ValueError("profile=exit requires sell_mode='any'")
    return False, "any"

If an existing profile has sell_mode="exit_only" and someone updates it with profile="exit" and omits sell_mode, the constraint check is skipped (because requested_sell_mode is None), but the function still returns "any" — so the effective value is correct. This is actually fine, but the logic is subtle. A comment explaining why None is allowed would help future readers avoid misreading this as a gap.

2. scalar_one_or_none() can raise MultipleResultsFound with ambiguous symbols

In set_asset_profile, when market_type is not provided, the code uses or_(*predicates) across multiple (instrument_type, symbol) pairs and calls scalar_one_or_none():

existing = existing_result.scalar_one_or_none()

If a symbol somehow matches more than one candidate pair (edge case, but possible in theory if data is inconsistent), this raises MultipleResultsFound. This exception is caught by the outer except Exception and surfaced as "set_asset_profile failed: ..." — not a very helpful message. Consider catching MultipleResultsFound explicitly with a clearer error.

3. Korean company name auto-detection fails silently on create

_auto_detect_for_create("삼성전자") returns (InstrumentType.equity_us, "삼성전자") since the name is not numeric and doesn't start with KRW-. This is by design (MCP user should pass market_type="kr" for names), but the failure mode is silent — the record gets created as a US equity with a Korean name as the symbol. A guard in _auto_detect_for_create or set_asset_profile that raises if the auto-detected result looks implausible (e.g., non-ASCII symbol with equity_us) would improve user experience.


Code Quality

4. Non-standard Alembic revision ID

revision: str = "a1b2c3d4e5f6"

This is a sequential/patterned ID rather than a randomly generated one. Alembic generates random 12-char hex IDs (e.g. f3a9b1c2d0e4). Using a hand-crafted sequential ID isn't harmful, but it stands out and could cause confusion if someone thinks it's a placeholder. Consider regenerating with uv run alembic revision --autogenerate -m "simplify_trade_profile_schema".

5. Double cast for session factory

def _session_factory() -> async_sessionmaker[AsyncSession]:
    return cast(async_sessionmaker[AsyncSession], cast(object, AsyncSessionLocal))

The cast(object, ...) inner cast serves no runtime purpose — it's fighting the type checker rather than fixing it. If AsyncSessionLocal has a type mismatch, annotate it at the source in app/core/db.py, or use # type: ignore[return-value] with a brief comment. The double-cast is confusing and will mislead future readers.

6. KR_SYMBOLS is a hardcoded allowlist with no fallback

def normalize_kr_symbol(symbol_input: str) -> str:
    ...
    mapped = KR_SYMBOLS.get(candidate)
    if mapped is None:
        raise ValueError(f"KR symbol mapping missing for input: {symbol_input}")
    return mapped

This means adding any new Korean stock to the system requires a code change to kr_symbols.py. If the kr_symbol_universe DB table is the single source of truth (per CLAUDE.md), consider looking up unmapped names there instead of raising. At minimum, the docstring should note this limitation.

7. Serialization duplication between _serialize_profile and _snapshot_for_change_log

Both functions serialize overlapping fields of AssetProfile. _snapshot_for_change_log could simply call _serialize_profile and drop the fields it doesn't need (id, created_at, updated_at). This would eliminate the parallel maintenance burden.


Test Coverage Gaps

8. No test for include_rules=True in get_asset_profile

The include_rules branch in get_asset_profile (which fetches TierRuleParam and joins them to the output) has no unit test. Given the tuple_.in_() query logic and the dict assembly, this code path is worth covering.

9. No test for the ambiguous-symbol or_() branch in set_asset_profile

The path where market_type=None and no existing record is found (falling through to _auto_detect_for_create) is not tested. The two happy-path auto-detect tests only exercise _auto_detect_for_create directly, not the full set_asset_profile flow with that code path.

10. test_get_asset_profile_filters_by_market_type asserts on query internals

stmt = mock_session.execute.await_args.args[0]
compiled = stmt.compile()
assert InstrumentType.equity_kr in compiled.params.values()

Asserting on compiled SQL params is brittle — it couples the test to SQLAlchemy's internal representation. Prefer testing observable behavior (e.g., check that result["data"] is empty and that the right DB call was made via mock_session.execute.call_count).


Minor Nits

  • The _ = mcp.tool(...)(fn) pattern in trade_profile_registration.py is a bit unusual. mcp.tool() is used as a decorator factory; assigning the result to _ works but a comment explaining it's used for its side effects would be clearer.
  • set_asset_profile description string in registration says "New profiles require market_type, tier, profile" but market_type is actually optional for new profiles (auto-detection kicks in). This could mislead MCP clients.

Summary

Category Count
Bugs / Correctness 3
Code Quality 4
Test Coverage 3
Nits 2

The core changes (schema simplification + MCP tooling) are solid. The main items to address before merging are #2 (possible unhandled exception), #3 (silent misclassification of KR names), and #9 (missing test for auto-detect flow in set_asset_profile).

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 5

🧹 Nitpick comments (1)
tests/test_mcp_trade_profile_tools.py (1)

42-55: Add unsupported market_type error-path tests for contract completeness.

You covered positive behavior well; please also pin the unsupported-market path (e.g., "jp") for both get_asset_profile and set_asset_profile so market-mapping errors remain stable.

Based on learnings: When adding new MCP behavior, update both positive test cases and unsupported-market/error-path tests to maintain comprehensive coverage.

Also applies to: 62-88

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/test_mcp_trade_profile_tools.py` around lines 42 - 55, Add
negative-path unit tests that assert unsupported market_type values return the
expected error response: create async tests mirroring
test_get_asset_profile_filters_by_market_type but call
get_asset_profile(market_type="jp") and assert the response indicates an
unsupported-market error (e.g., success False or specific error payload your API
returns) and likewise add a parallel test for
set_asset_profile(market_type="jp") to cover the error path; reuse the same
AsyncMock/MagicMock session setup pattern and patching of _session_factory so
the tests follow the positive-case structure but validate the unsupported-market
error behavior for both get_asset_profile and set_asset_profile.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@alembic/versions/a1b2c3d4e5f6_simplify_trade_profile_schema.py`:
- Around line 13-44: In upgrade(), before calling op.create_index for
"uq_asset_profiles_user_symbol_instrument" and
"uq_market_filters_user_instrument_filter", add a preflight duplicate check
using op.get_bind() to query asset_profiles grouped by (user_id, symbol,
instrument_type) and market_filters grouped by (user_id, instrument_type,
filter_name) to detect rows having count>1; if duplicates exist either
deterministically merge/choose a single row (e.g., keep the latest/first by id
or updated_at) and delete others, or raise a clear MigrationError so the
migration stops, ensuring the cleanup runs prior to creating the unique indexes
to prevent index creation failure.

In `@app/core/kr_symbols.py`:
- Around line 22-24: The lookup uses exact key matching on KR_SYMBOLS with
variable candidate, causing lowercase aliases like "naver" to miss; normalize
candidate before lookup (e.g., use candidate = candidate.casefold() or .upper()
consistently) and ensure KR_SYMBOLS keys are compared using the same
normalization so KR_SYMBOLS.get(candidate) succeeds; keep the existing
ValueError on missing mapping if lookup still returns None. Reference symbols:
KR_SYMBOLS, candidate, symbol_input.

In `@app/mcp_server/tooling/trade_profile_tools.py`:
- Around line 158-160: The symbol is only stripped in get_asset_profile before
building the filter, so queries like "1234" or "NAVER" won’t match normalized
stored symbols for market_type="kr"; update get_asset_profile to fully normalize
the symbol (use the same normalization/mapping used when storing symbols—e.g.,
call the existing symbol-normalization helper or mapping used elsewhere) and
assign it to normalized_symbol before constructing the filter, and apply the
same change in the second occurrence around lines 171-172 so both filter builds
use the normalized form; keep the existing calls to _to_instrument_type and
_normalize_profile as they are.
- Around line 20-24: Currently _to_instrument_type silently returns None when
normalize_market(market_type) yields None, allowing callers like
get_asset_profile and set_asset_profile to treat invalid filters as unset;
change _to_instrument_type to raise a clear exception instead of returning None:
if normalize_market(market_type) is None, raise ValueError (or a
project-standard validation error) containing the original market_type value and
a short message that it is invalid, otherwise return InstrumentType(normalized);
update callers if needed to let the error propagate or handle it explicitly.

In `@tests/test_mcp_trade_profile_tools.py`:
- Around line 24-152: Add a module-level pytest marker to classify these tests
as unit tests: import pytest at the top of the file and set pytestmark =
pytest.mark.unit so all tests in this module (e.g.
test_get_asset_profile_returns_empty_when_no_profiles,
test_set_asset_profile_create_requires_tier_and_profile,
test_set_asset_profile_auto_detect_kr_symbol, etc.) are marked; place the import
and pytestmark near the other imports so the test suite with --strict-markers
recognizes this module as unit tests.

---

Nitpick comments:
In `@tests/test_mcp_trade_profile_tools.py`:
- Around line 42-55: Add negative-path unit tests that assert unsupported
market_type values return the expected error response: create async tests
mirroring test_get_asset_profile_filters_by_market_type but call
get_asset_profile(market_type="jp") and assert the response indicates an
unsupported-market error (e.g., success False or specific error payload your API
returns) and likewise add a parallel test for
set_asset_profile(market_type="jp") to cover the error path; reuse the same
AsyncMock/MagicMock session setup pattern and patching of _session_factory so
the tests follow the positive-case structure but validate the unsupported-market
error behavior for both get_asset_profile and set_asset_profile.

ℹ️ Review info

Configuration used: defaults

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between a9e9f16 and 8d78658.

📒 Files selected for processing (14)
  • .gitignore
  • alembic/versions/a1b2c3d4e5f6_simplify_trade_profile_schema.py
  • app/core/kr_symbols.py
  • app/mcp_server/__init__.py
  • app/mcp_server/tooling/__init__.py
  • app/mcp_server/tooling/registry.py
  • app/mcp_server/tooling/trade_profile_registration.py
  • app/mcp_server/tooling/trade_profile_tools.py
  • app/models/trade_profile.py
  • scripts/seed_trade_profiles.py
  • tests/integration/test_trade_profile_persistence.py
  • tests/models/test_trade_profile.py
  • tests/test_mcp_tool_registration.py
  • tests/test_mcp_trade_profile_tools.py
💤 Files with no reviewable changes (2)
  • tests/integration/test_trade_profile_persistence.py
  • scripts/seed_trade_profiles.py

Comment on lines +13 to +44
def upgrade() -> None:
op.execute("DROP INDEX IF EXISTS uq_asset_profiles_with_broker")
op.execute("DROP INDEX IF EXISTS uq_asset_profiles_without_broker")
op.execute("DROP INDEX IF EXISTS uq_market_filters_with_broker")
op.execute("DROP INDEX IF EXISTS uq_market_filters_without_broker")

op.drop_constraint(
op.f("fk_asset_profiles_broker_account_id_broker_accounts"),
"asset_profiles",
type_="foreignkey",
)
op.drop_constraint(
op.f("fk_market_filters_broker_account_id_broker_accounts"),
"market_filters",
type_="foreignkey",
)

op.drop_column("asset_profiles", "broker_account_id")
op.drop_column("market_filters", "broker_account_id")

op.create_index(
"uq_asset_profiles_user_symbol_instrument",
"asset_profiles",
["user_id", "symbol", "instrument_type"],
unique=True,
)
op.create_index(
"uq_market_filters_user_instrument_filter",
"market_filters",
["user_id", "instrument_type", "filter_name"],
unique=True,
)
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Guard against pre-existing duplicates before creating new unique indexes.

This migration can fail on live data if multiple broker-scoped rows collapse to the same new key. Add an explicit preflight duplicate check (or deterministic merge) before creating the new unique indexes.

🛡️ Suggested preflight guard
 def upgrade() -> None:
+    bind = op.get_bind()
+
+    duplicate_asset = bind.execute(
+        sa.text(
+            """
+            SELECT 1
+            FROM asset_profiles
+            GROUP BY user_id, symbol, instrument_type
+            HAVING COUNT(*) > 1
+            LIMIT 1
+            """
+        )
+    ).first()
+    if duplicate_asset is not None:
+        raise RuntimeError(
+            "Cannot apply migration: duplicate asset_profiles rows exist for "
+            "(user_id, symbol, instrument_type). Deduplicate first."
+        )
+
+    duplicate_filter = bind.execute(
+        sa.text(
+            """
+            SELECT 1
+            FROM market_filters
+            GROUP BY user_id, instrument_type, filter_name
+            HAVING COUNT(*) > 1
+            LIMIT 1
+            """
+        )
+    ).first()
+    if duplicate_filter is not None:
+        raise RuntimeError(
+            "Cannot apply migration: duplicate market_filters rows exist for "
+            "(user_id, instrument_type, filter_name). Deduplicate first."
+        )
+
     op.execute("DROP INDEX IF EXISTS uq_asset_profiles_with_broker")
     op.execute("DROP INDEX IF EXISTS uq_asset_profiles_without_broker")
     op.execute("DROP INDEX IF EXISTS uq_market_filters_with_broker")
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@alembic/versions/a1b2c3d4e5f6_simplify_trade_profile_schema.py` around lines
13 - 44, In upgrade(), before calling op.create_index for
"uq_asset_profiles_user_symbol_instrument" and
"uq_market_filters_user_instrument_filter", add a preflight duplicate check
using op.get_bind() to query asset_profiles grouped by (user_id, symbol,
instrument_type) and market_filters grouped by (user_id, instrument_type,
filter_name) to detect rows having count>1; if duplicates exist either
deterministically merge/choose a single row (e.g., keep the latest/first by id
or updated_at) and delete others, or raise a clear MigrationError so the
migration stops, ensuring the cleanup runs prior to creating the unique indexes
to prevent index creation failure.

Comment on lines +22 to +24
mapped = KR_SYMBOLS.get(candidate)
if mapped is None:
raise ValueError(f"KR symbol mapping missing for input: {symbol_input}")
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Handle English alias lookup case-insensitively.

Line 22 performs an exact key lookup, so inputs like naver fail even though NAVER is mapped.

🔧 Proposed fix
-    mapped = KR_SYMBOLS.get(candidate)
+    mapped = KR_SYMBOLS.get(candidate)
+    if mapped is None:
+        mapped = KR_SYMBOLS.get(candidate.upper())
     if mapped is None:
         raise ValueError(f"KR symbol mapping missing for input: {symbol_input}")
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
mapped = KR_SYMBOLS.get(candidate)
if mapped is None:
raise ValueError(f"KR symbol mapping missing for input: {symbol_input}")
mapped = KR_SYMBOLS.get(candidate)
if mapped is None:
mapped = KR_SYMBOLS.get(candidate.upper())
if mapped is None:
raise ValueError(f"KR symbol mapping missing for input: {symbol_input}")
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/core/kr_symbols.py` around lines 22 - 24, The lookup uses exact key
matching on KR_SYMBOLS with variable candidate, causing lowercase aliases like
"naver" to miss; normalize candidate before lookup (e.g., use candidate =
candidate.casefold() or .upper() consistently) and ensure KR_SYMBOLS keys are
compared using the same normalization so KR_SYMBOLS.get(candidate) succeeds;
keep the existing ValueError on missing mapping if lookup still returns None.
Reference symbols: KR_SYMBOLS, candidate, symbol_input.

Comment on lines +158 to +160
instrument_type = _to_instrument_type(market_type)
normalized_profile = _normalize_profile(profile)
normalized_symbol = symbol.strip() if symbol is not None else None
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Normalize symbol in get_asset_profile before building the filter.

Line 160 uses raw strip() only, so KR queries like 1234 or NAVER won’t match normalized stored symbols (001234, mapped code) even when market_type="kr" is provided.

🔧 Proposed fix
-        normalized_symbol = symbol.strip() if symbol is not None else None
+        normalized_symbol: str | None = None
+        if symbol is not None:
+            candidate = symbol.strip()
+            if candidate:
+                normalized_symbol = (
+                    _normalize_symbol_for_instrument(candidate, instrument_type)
+                    if instrument_type is not None
+                    else candidate
+                )

Also applies to: 171-172

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/mcp_server/tooling/trade_profile_tools.py` around lines 158 - 160, The
symbol is only stripped in get_asset_profile before building the filter, so
queries like "1234" or "NAVER" won’t match normalized stored symbols for
market_type="kr"; update get_asset_profile to fully normalize the symbol (use
the same normalization/mapping used when storing symbols—e.g., call the existing
symbol-normalization helper or mapping used elsewhere) and assign it to
normalized_symbol before constructing the filter, and apply the same change in
the second occurrence around lines 171-172 so both filter builds use the
normalized form; keep the existing calls to _to_instrument_type and
_normalize_profile as they are.

Comment on lines +24 to +152
@pytest.mark.asyncio
async def test_get_asset_profile_returns_empty_when_no_profiles() -> None:
mock_session = AsyncMock()
mock_result = MagicMock()
mock_result.scalars.return_value.all.return_value = []
mock_session.execute.return_value = mock_result

session_factory = MagicMock(return_value=_build_session_cm(mock_session))
with patch(
"app.mcp_server.tooling.trade_profile_tools._session_factory",
return_value=session_factory,
):
result = await get_asset_profile()

assert result == {"success": True, "data": [], "count": 0}


@pytest.mark.asyncio
async def test_get_asset_profile_filters_by_market_type() -> None:
mock_session = AsyncMock()
mock_result = MagicMock()
mock_result.scalars.return_value.all.return_value = []
mock_session.execute.return_value = mock_result

session_factory = MagicMock(return_value=_build_session_cm(mock_session))
with patch(
"app.mcp_server.tooling.trade_profile_tools._session_factory",
return_value=session_factory,
):
result = await get_asset_profile(market_type="kr")

assert result == {"success": True, "data": [], "count": 0}
stmt = mock_session.execute.await_args.args[0]
compiled = stmt.compile()
assert InstrumentType.equity_kr in compiled.params.values()


@pytest.mark.asyncio
async def test_set_asset_profile_create_requires_tier_and_profile() -> None:
mock_session = MagicMock()
mock_session.execute = AsyncMock(
return_value=SimpleNamespace(scalar_one_or_none=lambda: None)
)
tx_cm = AsyncMock()
tx_cm.__aenter__.return_value = None
tx_cm.__aexit__.return_value = None
mock_session.begin = MagicMock(return_value=tx_cm)

session_factory = MagicMock(return_value=_build_session_cm(mock_session))
with patch(
"app.mcp_server.tooling.trade_profile_tools._session_factory",
return_value=session_factory,
):
missing_tier = await set_asset_profile(symbol="AAPL")
missing_profile = await set_asset_profile(symbol="AAPL", tier=2)

assert missing_tier == {
"success": False,
"error": "tier is required for new profile",
}
assert missing_profile == {
"success": False,
"error": "profile is required for new profile",
}


def test_set_asset_profile_exit_forces_buy_allowed_false() -> None:
buy_allowed, sell_mode = _apply_profile_rules(
profile_value="exit",
buy_allowed_value=True,
sell_mode_value="any",
requested_buy_allowed=None,
requested_sell_mode=None,
)

assert buy_allowed is False
assert sell_mode == "any"


def test_set_asset_profile_exit_rejects_buy_allowed_true() -> None:
with pytest.raises(ValueError, match="profile=exit requires buy_allowed=False"):
_apply_profile_rules(
profile_value="exit",
buy_allowed_value=True,
sell_mode_value="any",
requested_buy_allowed=True,
requested_sell_mode=None,
)


def test_set_asset_profile_hold_only_forces_sell_mode_rebalance() -> None:
buy_allowed, sell_mode = _apply_profile_rules(
profile_value="hold_only",
buy_allowed_value=True,
sell_mode_value="any",
requested_buy_allowed=None,
requested_sell_mode=None,
)

assert buy_allowed is True
assert sell_mode == "rebalance_only"


def test_set_asset_profile_hold_only_rejects_invalid_sell_mode() -> None:
with pytest.raises(
ValueError,
match="profile=hold_only requires sell_mode='rebalance_only'",
):
_apply_profile_rules(
profile_value="hold_only",
buy_allowed_value=True,
sell_mode_value="any",
requested_buy_allowed=None,
requested_sell_mode="any",
)


def test_set_asset_profile_auto_detect_kr_symbol() -> None:
instrument_type, normalized_symbol = _auto_detect_for_create("5930")

assert instrument_type == InstrumentType.equity_kr
assert normalized_symbol == "005930"


def test_set_asset_profile_auto_detect_crypto_symbol() -> None:
instrument_type, normalized_symbol = _auto_detect_for_create("KRW-BTC")

assert instrument_type == InstrumentType.crypto
assert normalized_symbol == "KRW-BTC"
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

# Check for existing markers in the test file
rg -n '(^pytestmark\s*=)|(`@pytest`\.mark\.(unit|integration|slow))' tests/test_mcp_trade_profile_tools.py

# Check if markers are registered in pyproject.toml
echo "=== Checking pytest marker registration ==="
rg -A 20 '\[tool\.pytest\.ini_options\]' pyproject.toml | head -40

Repository: mgh3326/auto_trader

Length of output: 708


🏁 Script executed:

# Check the actual file to see current state
head -30 tests/test_mcp_trade_profile_tools.py

# Also check if any markers are used anywhere in the file
echo "=== All pytest markers in file ==="
rg '@pytest\.' tests/test_mcp_trade_profile_tools.py

Repository: mgh3326/auto_trader

Length of output: 980


🏁 Script executed:

# Count total tests in the file to understand scope
rg -c 'def test_' tests/test_mcp_trade_profile_tools.py

# Verify the exact line count of the file
wc -l tests/test_mcp_trade_profile_tools.py

# Double-check that strict-markers is enabled
rg 'strict-markers' pyproject.toml

Repository: mgh3326/auto_trader

Length of output: 130


🏁 Script executed:

# Verify if there are similar test files that DO have unit markers for comparison
echo "=== Checking marker patterns in other test files ==="
rg -l 'pytestmark = pytest.mark.unit' tests/ | head -5

# Also check tests with `@pytest.mark.unit` at function level
echo "=== Function-level unit markers ==="
rg -B 2 '@pytest.mark.unit' tests/test_*.py | head -20

Repository: mgh3326/auto_trader

Length of output: 800


🏁 Script executed:

# Check if there are any integration tests mixed into other test files using function-level markers
echo "=== Checking for mixed markers in test files ==="
rg -l '@pytest.mark.integration' tests/test_*.py | head -3

# Sample one file to see if it uses both unit and integration markers
echo "=== Sample: test_trading_integration.py ==="
rg '@pytest.mark\.(unit|integration|slow)' tests/test_trading_integration.py | sort | uniq -c

Repository: mgh3326/auto_trader

Length of output: 207


🏁 Script executed:

# Final check: Run pytest with strict-markers to see if it would actually fail
echo "=== Checking if strict-markers would catch this ==="
cd /tmp && python3 << 'EOF'
# Simulate what pytest strict-markers checks
# A test without a unit/integration/slow marker would fail with --strict-markers

markers_registered = {'unit', 'integration', 'slow', 'asyncio'}
test_markers_in_file = {'asyncio'}  # Only `@pytest.mark.asyncio` is present

category_markers = {'unit', 'integration', 'slow'}
has_category_marker = any(m in test_markers_in_file for m in category_markers)

if not has_category_marker:
    print("❌ VIOLATION: No category marker (`@pytest.mark.unit/integration/slow`) found")
    print(f"   Tests have only: {test_markers_in_file}")
    print(f"   Required one of: {category_markers}")
else:
    print("✓ Has category marker")
EOF

Repository: mgh3326/auto_trader

Length of output: 273


Add required pytest category markers for this new test module.

All tests in this file are missing the registered category markers (unit, integration, or slow) required by the test suite. With --strict-markers enabled in pyproject.toml, this will cause test runs to fail. Add a module-level marker to categorize all tests here as unit tests:

 from __future__ import annotations
 
 from types import SimpleNamespace
 from unittest.mock import AsyncMock, MagicMock, patch
 
 import pytest
+
+pytestmark = pytest.mark.unit
 
 from app.mcp_server.tooling.trade_profile_tools import (
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/test_mcp_trade_profile_tools.py` around lines 24 - 152, Add a
module-level pytest marker to classify these tests as unit tests: import pytest
at the top of the file and set pytestmark = pytest.mark.unit so all tests in
this module (e.g. test_get_asset_profile_returns_empty_when_no_profiles,
test_set_asset_profile_create_requires_tier_and_profile,
test_set_asset_profile_auto_detect_kr_symbol, etc.) are marked; place the import
and pytestmark near the other imports so the test suite with --strict-markers
recognizes this module as unit tests.

@codecov
Copy link

codecov bot commented Mar 3, 2026

Codecov Report

❌ Patch coverage is 77.88018% with 48 lines in your changes missing coverage. Please review.
✅ Project coverage is 63.94%. Comparing base (a9e9f16) to head (c235e3f).
⚠️ Report is 1 commits behind head on main.

Files with missing lines Patch % Lines
app/mcp_server/tooling/trade_profile_tools.py 79.05% 40 Missing ⚠️
app/core/kr_symbols.py 36.36% 7 Missing ⚠️
...p/mcp_server/tooling/trade_profile_registration.py 90.00% 1 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main     #193      +/-   ##
==========================================
+ Coverage   63.81%   63.94%   +0.12%     
==========================================
  Files         181      184       +3     
  Lines       22574    22788     +214     
==========================================
+ Hits        14405    14571     +166     
- Misses       8169     8217      +48     

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@claude
Copy link

claude bot commented Mar 3, 2026

PR Review: feat: simplify trade profile schema and add MCP tools

Overall this is a clean and well-structured PR. The schema simplification is a meaningful reduction in complexity, the new MCP tools follow existing patterns, and the test coverage is solid. A few issues worth addressing before merging:


Bugs / Correctness Issues

1. Symbol filter in get_asset_profile doesn't normalize KR symbols

# trade_profile_tools.py:456
normalized_symbol = symbol.strip() if symbol is not None else None

If a caller passes a Korean company name (e.g., "한화에어로") as the symbol filter, it won't match the stored 6-digit code "012450". The parameter name suggests it can be anything the caller knows the asset by, but the filter does an exact DB match. Either document that symbol must be the normalized code, or run it through _normalize_symbol_for_instrument (which requires also knowing instrument_type).


2. _normalize_sell_mode is identical to _normalize_profile

# Lines 331-335 and 338-342 are byte-for-byte identical
def _normalize_profile(value: str | None) -> str | None: ...
def _normalize_sell_mode(value: str | None) -> str | None: ...

These can be collapsed into one _normalize_str helper. Both only strip and lowercase.


Design Concerns

3. KR_SYMBOLS hardcoded mapping vs. DB universe

app/core/kr_symbols.py introduces a hardcoded dict of 8 symbols. Per CLAUDE.md, kr_symbol_universe is the DB single source of truth for KR symbol resolution. Adding a separate parallel mapping creates a divergence risk (symbols added to the DB won't be resolvable via MCP until the dict is updated). Consider querying kr_symbol_universe or at least adding a comment explaining why the DB table is not used here.


4. Alembic revision ID looks manually crafted

revision: str = "a1b2c3d4e5f6"

This looks like a hand-typed placeholder rather than an Alembic-generated ID. It works, but it's a pattern mismatch from the rest of alembic/versions/ (which use fully random IDs). If this was intentional, fine — but it's worth a comment.


5. Broad exception swallowing in get_asset_profile

except Exception as exc:
    return {"success": False, "error": str(exc)}

This silently catches connection errors, ORM failures, etc., returning them as normal-looking tool errors. Unexpected exceptions should at minimum be logged (or re-raised) so they surface in monitoring rather than appearing as benign tool misuse.


6. _session_factory double-cast is fragile

def _session_factory() -> async_sessionmaker[AsyncSession]:
    return cast(async_sessionmaker[AsyncSession], cast(object, AsyncSessionLocal))

This pattern exists to silence a type checker complaint, but it's obscuring a real issue. The same factory pattern used in other tools in the codebase may have a cleaner approach. At minimum, a comment explaining why the double-cast is needed would help future readers.


Test Coverage Gaps

7. include_rules=True path is not tested

The get_asset_profile function has a non-trivial branch for fetching TierRuleParam rows when include_rules=True (including a tuple_(...).in_() query). This is untested.

8. Symbol auto-detection path (no market_type, existing record found) is not tested

The set_asset_profile logic that queries multiple (instrument_type, symbol) pairs when market_type is omitted is not covered. A unit test with a mock returning an existing record for that path would catch regressions.


Minor / Style

9. Missing blank line before _validate_tier

_VALID_PROFILES = frozenset({...})


def _validate_tier(tier: int | None) -> None:   # ← only one blank line before this

PEP 8 requires two blank lines between top-level definitions. Ruff may not catch this in all configurations.


Summary

Category Count
Bugs / correctness 2
Design concerns 4
Test gaps 2
Style 1

The schema migration and deletion of the seed script are clean. The MCP tool structure follows the existing registration pattern well. Resolving the symbol normalization bug (#1) and the exception swallowing (#5) before merge would be my top priorities.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

♻️ Duplicate comments (2)
tests/test_mcp_trade_profile_tools.py (1)

7-8: ⚠️ Potential issue | 🟠 Major

Add the required pytest category marker (unit) at module level.

This module is still missing unit/integration/slow categorization under strict marker policy.

🔧 Suggested fix
 import pytest
 
+pytestmark = pytest.mark.unit
+
 from app.mcp_server.tooling.trade_profile_tools import (
As per coding guidelines `tests/test_*.py`: Use test markers (`@pytest.mark.unit`, `@pytest.mark.integration`, `@pytest.mark.slow`) to categorize tests appropriately.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/test_mcp_trade_profile_tools.py` around lines 7 - 8, Add the
module-level pytest marker for the test file by declaring the pytestmark
variable and assigning it pytest.mark.unit so the whole module is categorized as
unit tests; since pytest is already imported in this file, add a single
module-level statement like pytestmark = pytest.mark.unit near the top (e.g.,
directly after the import) to satisfy the strict marker policy.
app/mcp_server/tooling/trade_profile_tools.py (1)

177-189: ⚠️ Potential issue | 🟠 Major

Normalize symbol before filtering profiles.

Line [177] only applies strip(). This misses normalized stored symbols (e.g., KR code/name normalization), so valid lookups can return empty results even when data exists.

🔧 Suggested fix
-        normalized_symbol = symbol.strip() if symbol is not None else None
+        normalized_symbol: str | None = None
+        if symbol is not None:
+            candidate = symbol.strip()
+            if candidate:
+                normalized_symbol = (
+                    _normalize_symbol_for_instrument(candidate, instrument_type)
+                    if instrument_type is not None
+                    else candidate
+                )
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/mcp_server/tooling/trade_profile_tools.py` around lines 177 - 189, The
code currently only strips whitespace when building normalized_symbol
(symbol.strip()), which fails to match stored normalized symbols (e.g., KR
code/name normalization); update the normalized_symbol assignment to apply the
same normalization routine used when persisting symbols (e.g., call the existing
normalize_symbol/normalize_name helper or implement the same normalization
logic) before adding the filter on AssetProfile.symbol so lookups use the
consistent normalized form; change the line that sets normalized_symbol and keep
the subsequent filter using AssetProfile.symbol unchanged.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@app/mcp_server/tooling/trade_profile_tools.py`:
- Line 35: Ruff formatting is failing for this module due to style issues around
the constant _VALID_PROFILES; run ruff and/or black auto-fixes (e.g. ruff --fix
. && black .) on the file and commit the changes so the file conforms to project
formatting, ensuring the _VALID_PROFILES definition remains a
frozenset({"aggressive", "balanced", "conservative", "exit", "hold_only"}) and
that imports/whitespace/newline conventions are corrected.

In `@tests/test_mcp_tool_registration.py`:
- Around line 1-4: Add a pytest marker to classify this test module (e.g.,
`@pytest.mark.unit` or `@pytest.mark.integration`) by importing pytest if not
already and applying the marker at module level or to the test functions in
tests/test_mcp_tool_registration.py; locate the top of the file where pytest is
imported and add the appropriate marker annotation to the test definitions or a
module-level marker variable so the suite adheres to the strict marker policy
(choose unit/integration/slow based on the test's scope).

In `@tests/test_mcp_trade_profile_tools.py`:
- Around line 275-277: The test module tests/test_mcp_trade_profile_tools.py
fails Ruff formatting; normalize the file formatting (fix tuple/line breaks and
any lint issues) so it passes CI — for example reformat the attributes tuple
used in the for loop that iterates over ("id", "symbol", "instrument_type",
"tier", "profile", "sector", "tags", "max_position_pct", "buy_allowed",
"sell_mode", "note", "updated_by", "created_at", "updated_at") and any
surrounding lines, then run ruff (and black if configured) to auto-fix style
violations before committing.

---

Duplicate comments:
In `@app/mcp_server/tooling/trade_profile_tools.py`:
- Around line 177-189: The code currently only strips whitespace when building
normalized_symbol (symbol.strip()), which fails to match stored normalized
symbols (e.g., KR code/name normalization); update the normalized_symbol
assignment to apply the same normalization routine used when persisting symbols
(e.g., call the existing normalize_symbol/normalize_name helper or implement the
same normalization logic) before adding the filter on AssetProfile.symbol so
lookups use the consistent normalized form; change the line that sets
normalized_symbol and keep the subsequent filter using AssetProfile.symbol
unchanged.

In `@tests/test_mcp_trade_profile_tools.py`:
- Around line 7-8: Add the module-level pytest marker for the test file by
declaring the pytestmark variable and assigning it pytest.mark.unit so the whole
module is categorized as unit tests; since pytest is already imported in this
file, add a single module-level statement like pytestmark = pytest.mark.unit
near the top (e.g., directly after the import) to satisfy the strict marker
policy.

ℹ️ Review info

Configuration used: defaults

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 8d78658 and 6e15fcd.

📒 Files selected for processing (5)
  • app/mcp_server/tooling/__init__.py
  • app/mcp_server/tooling/trade_profile_registration.py
  • app/mcp_server/tooling/trade_profile_tools.py
  • tests/test_mcp_tool_registration.py
  • tests/test_mcp_trade_profile_tools.py

Comment on lines +59 to +64
def _normalize_sell_mode(value: str | None) -> str | None:
if value is None:
return None
normalized = value.strip().lower()
return normalized or None

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Validate sell_mode at input time instead of deferring to DB constraints.

Line [59] only normalizes text. Invalid values can reach persistence and fail as generic DB errors, which weakens tool contract/error clarity.

🔧 Suggested fix
 _VALID_PROFILES = frozenset({"aggressive", "balanced", "conservative", "exit", "hold_only"})
+_VALID_SELL_MODES = frozenset({"any", "rebalance_only", "none"})
@@
 def _normalize_sell_mode(value: str | None) -> str | None:
@@
     return normalized or None
+
+
+def _validate_sell_mode(value: str | None) -> None:
+    if value is None:
+        return
+    normalized = value.strip().lower()
+    if normalized and normalized not in _VALID_SELL_MODES:
+        raise ValueError("sell_mode must be one of: any, rebalance_only, none")
@@
         _validate_tier(tier)
         _validate_profile(profile)
+        _validate_sell_mode(sell_mode)
         requested_profile = _normalize_profile(profile)
         requested_sell_mode = _normalize_sell_mode(sell_mode)

Also applies to: 255-256

Comment on lines +1 to 4
from typing import Any, cast

import pytest

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Add a pytest category marker for this test module.

This file lacks a unit/integration/slow marker classification, which conflicts with strict marker policy.

🔧 Suggested fix
 from typing import Any, cast
 
 import pytest
+
+pytestmark = pytest.mark.unit
As per coding guidelines `tests/test_*.py`: Use test markers (`@pytest.mark.unit`, `@pytest.mark.integration`, `@pytest.mark.slow`) to categorize tests appropriately.
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
from typing import Any, cast
import pytest
from typing import Any, cast
import pytest
pytestmark = pytest.mark.unit
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/test_mcp_tool_registration.py` around lines 1 - 4, Add a pytest marker
to classify this test module (e.g., `@pytest.mark.unit` or
`@pytest.mark.integration`) by importing pytest if not already and applying the
marker at module level or to the test functions in
tests/test_mcp_tool_registration.py; locate the top of the file where pytest is
imported and add the appropriate marker annotation to the test definitions or a
module-level marker variable so the suite adheres to the strict marker policy
(choose unit/integration/slow based on the test's scope).

@sonarqubecloud
Copy link

sonarqubecloud bot commented Mar 3, 2026

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

♻️ Duplicate comments (2)
app/mcp_server/tooling/trade_profile_tools.py (2)

63-67: ⚠️ Potential issue | 🟠 Major

Validate sell_mode at input time, not at DB constraint time.

_normalize_sell_mode currently normalizes but never validates. Invalid values can reach persistence and fail as generic DB errors instead of returning a clear tool-level validation error.

🔧 Proposed fix
 _VALID_PROFILES = frozenset(
     {"aggressive", "balanced", "conservative", "exit", "hold_only"}
 )
+_VALID_SELL_MODES = frozenset({"any", "rebalance_only", "none"})
@@
 def _normalize_sell_mode(value: str | None) -> str | None:
     if value is None:
         return None
     normalized = value.strip().lower()
     return normalized or None
+
+
+def _validate_sell_mode(value: str | None) -> None:
+    if value is None:
+        return
+    normalized = value.strip().lower()
+    if normalized and normalized not in _VALID_SELL_MODES:
+        raise ValueError("sell_mode must be one of: any, rebalance_only, none")
@@
         _validate_tier(tier)
         _validate_profile(profile)
+        _validate_sell_mode(sell_mode)
         requested_profile = _normalize_profile(profile)
         requested_sell_mode = _normalize_sell_mode(sell_mode)

Also applies to: 256-260

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/mcp_server/tooling/trade_profile_tools.py` around lines 63 - 67, The
_normalize_sell_mode function only normalizes but does not validate allowed
values, so invalid sell_mode strings reach the DB; modify _normalize_sell_mode
to check the normalized value against an explicit allowed set (e.g.,
ALLOWED_SELL_MODES constant) and, if not None and not in that set, raise a clear
tool-level exception (ValueError or your project ValidationError) with a
descriptive message; apply the same change to the duplicate implementation
referenced at the other location (lines 256-260) so all input paths validate
before persistence.

181-193: ⚠️ Potential issue | 🟠 Major

Normalize symbol in get_asset_profile before building the filter.

Line 181 only trims whitespace. That can miss rows stored in normalized form (notably KR symbols and case-normalized symbols), so valid lookups may return no matches.

🔧 Proposed fix
-        normalized_symbol = symbol.strip() if symbol is not None else None
+        normalized_symbol: str | None = None
+        if symbol is not None:
+            candidate = symbol.strip()
+            if candidate:
+                normalized_symbol = (
+                    _normalize_symbol_for_instrument(candidate, instrument_type)
+                    if instrument_type is not None
+                    else candidate
+                )
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/mcp_server/tooling/trade_profile_tools.py` around lines 181 - 193, The
symbol is only whitespace-trimmed (normalized_symbol = symbol.strip()) in
get_asset_profile which can miss rows stored in normalized/case-normalized form;
before building filters, apply the same normalization used for stored symbols
(the same logic/helper used for normalized_profile) to symbol (e.g., call the
existing normalize function or replicate its normalization rules) and assign
that to normalized_symbol so the AssetProfile.symbol filter compares against the
normalized form; update references to normalized_symbol in the condition that
appends AssetProfile.symbol == normalized_symbol.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@app/mcp_server/tooling/trade_profile_tools.py`:
- Around line 269-313: The current set_asset_profile flow does a race-prone
SELECT then conditional INSERT/UPDATE on AssetProfile (keys: user_id, symbol,
instrument_type), so replace the read-then-write with an atomic upsert or a
protected transaction: use a single database upsert (INSERT ... ON CONFLICT
(user_id, symbol, instrument_type) DO UPDATE ...) executed via SQLAlchemy core
(insert().on_conflict_do_update or equivalent) to create-or-update the row using
instrument_type and normalized_symbol, or wrap the logic in a transaction and
acquire a row-level lock (SELECT ... FOR UPDATE) when you must read first;
update code paths that reference set_asset_profile, AssetProfile, MCP_USER_ID,
instrument_type, and normalized_symbol to use the atomic upsert or locked
transaction to eliminate the race.

---

Duplicate comments:
In `@app/mcp_server/tooling/trade_profile_tools.py`:
- Around line 63-67: The _normalize_sell_mode function only normalizes but does
not validate allowed values, so invalid sell_mode strings reach the DB; modify
_normalize_sell_mode to check the normalized value against an explicit allowed
set (e.g., ALLOWED_SELL_MODES constant) and, if not None and not in that set,
raise a clear tool-level exception (ValueError or your project ValidationError)
with a descriptive message; apply the same change to the duplicate
implementation referenced at the other location (lines 256-260) so all input
paths validate before persistence.
- Around line 181-193: The symbol is only whitespace-trimmed (normalized_symbol
= symbol.strip()) in get_asset_profile which can miss rows stored in
normalized/case-normalized form; before building filters, apply the same
normalization used for stored symbols (the same logic/helper used for
normalized_profile) to symbol (e.g., call the existing normalize function or
replicate its normalization rules) and assign that to normalized_symbol so the
AssetProfile.symbol filter compares against the normalized form; update
references to normalized_symbol in the condition that appends
AssetProfile.symbol == normalized_symbol.

ℹ️ Review info

Configuration used: defaults

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 6e15fcd and c235e3f.

📒 Files selected for processing (2)
  • app/mcp_server/tooling/trade_profile_tools.py
  • tests/test_mcp_trade_profile_tools.py
🚧 Files skipped from review as they are similar to previous changes (1)
  • tests/test_mcp_trade_profile_tools.py

Comment on lines +269 to +313
if explicit_instrument_type is not None:
instrument_type = explicit_instrument_type
normalized_symbol = _normalize_symbol_for_instrument(
symbol_input, instrument_type
)
existing_stmt = select(AssetProfile).where(
AssetProfile.user_id == MCP_USER_ID,
AssetProfile.symbol == normalized_symbol,
AssetProfile.instrument_type == instrument_type,
)
existing_result = await db.execute(existing_stmt)
existing = existing_result.scalar_one_or_none()
else:
candidate_pairs: list[tuple[InstrumentType, str]] = []
if symbol_input.isdigit() and len(symbol_input) <= 6:
candidate_pairs.append(
(InstrumentType.equity_kr, symbol_input.zfill(6))
)
upper_symbol = symbol_input.upper()
if upper_symbol.startswith("KRW-"):
candidate_pairs.append((InstrumentType.crypto, upper_symbol))
if not candidate_pairs:
candidate_pairs.append((InstrumentType.equity_us, upper_symbol))

predicates = [
and_(
AssetProfile.instrument_type == candidate_type,
AssetProfile.symbol == candidate_symbol,
)
for candidate_type, candidate_symbol in candidate_pairs
]
existing_stmt = select(AssetProfile).where(
AssetProfile.user_id == MCP_USER_ID,
or_(*predicates),
)
existing_result = await db.execute(existing_stmt)
existing = existing_result.scalar_one_or_none()

if existing is not None:
instrument_type = existing.instrument_type
normalized_symbol = existing.symbol
else:
raise ValueError("market_type is required for new profile")

if existing is None:
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

set_asset_profile read-then-write flow is race-prone for concurrent calls.

This path does SELECT → conditional INSERT/UPDATE. Two concurrent requests for the same (user_id, symbol, instrument_type) can both see no row and one will fail on the unique index, which breaks reliable upsert behavior.

Also applies to: 331-346

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/mcp_server/tooling/trade_profile_tools.py` around lines 269 - 313, The
current set_asset_profile flow does a race-prone SELECT then conditional
INSERT/UPDATE on AssetProfile (keys: user_id, symbol, instrument_type), so
replace the read-then-write with an atomic upsert or a protected transaction:
use a single database upsert (INSERT ... ON CONFLICT (user_id, symbol,
instrument_type) DO UPDATE ...) executed via SQLAlchemy core
(insert().on_conflict_do_update or equivalent) to create-or-update the row using
instrument_type and normalized_symbol, or wrap the logic in a transaction and
acquire a row-level lock (SELECT ... FOR UPDATE) when you must read first;
update code paths that reference set_asset_profile, AssetProfile, MCP_USER_ID,
instrument_type, and normalized_symbol to use the atomic upsert or locked
transaction to eliminate the race.

@claude
Copy link

claude bot commented Mar 3, 2026

PR Review: feat: simplify trade profile schema and add MCP tools

Overall this is a clean and well-structured PR. The schema simplification is a good architectural decision, and the new MCP tools follow the existing tooling patterns well. Below are issues ranging from bugs to nits.


Bug / Correctness Issues

1. Cannot clear optional fields once set

In the update path of set_asset_profile, sector, note, and tags are only written when the incoming value is not None. Once a field is set, there is no way to clear it back to None via the MCP tool. Consider a sentinel value (e.g. "__clear__") or document clearly that these fields are write-only/append-only.


2. Hard-coded KR symbol dictionary conflicts with the project single source of truth

app/core/kr_symbols.py introduces a static dict of 8 symbols, but CLAUDE.md explicitly states the kr_symbol_universe DB table is the single source of truth for KR symbol lookups. This creates a maintenance burden: every new KR stock requires a code change.

More critically, normalize_kr_symbol raises ValueError for any KR company name not in this list, making set_asset_profile unusable for the vast majority of KR equities when referenced by name.

Recommendation: Fall back to querying kr_symbol_universe for name-to-code resolution, or document this limitation prominently and only accept 6-digit numeric codes directly.


3. Symbol auto-detection silently misroutes Korean company names

When market_type is omitted, the auto-detection logic falls through: numeric -> equity_kr, KRW- prefix -> crypto, else -> equity_us (silent fallback). A Korean company name like 한화에어로 is non-digit and non-KRW, so it silently falls to an equity_us lookup. If no existing profile is found, the error "market_type is required for new profile" is confusing. The error should hint that market_type is required for non-numeric, non-KRW symbols.


Design / Behavioral Concerns

4. Silent auto-correction with no feedback to the caller

_apply_profile_rules silently forces buy_allowed=False for exit and sell_mode="rebalance_only" for hold_only when the caller omits those values. The success response does not indicate that a value was auto-corrected. Consider returning a "warnings" list in the success response, e.g. "buy_allowed forced to False for exit profile".


5. Non-standard migration revision ID

a1b2c3d4e5f6 is a sequential, human-readable revision ID. All other migrations in the project use randomly-generated hex IDs (e.g. 4d9f0b2c7a11). This looks like a placeholder and is inconsistent. Use alembic revision --autogenerate to generate the ID.


6. Downgrade migration is schema-only

downgrade() re-adds broker_account_id as nullable=True but does not restore original values. All rows will have NULL after rollback. Acceptable for dev-only downgrade, but worth a comment noting it is schema-only and not data-reversible.


Code Quality

7. Double-cast in _session_factory is a type-system smell

The double cast through object suppresses type-checker errors about an incompatible intermediate type. Either align the annotation with what AsyncSessionLocal actually is, or add a comment explaining the mismatch. Using AsyncSessionLocal directly at call sites would remove the indirection entirely.


8. get_asset_profile swallows all exceptions uniformly

Unlike set_asset_profile, which separates ValueError from generic Exception, get_asset_profile catches everything the same way. A database connectivity failure looks identical to a validation error from the perspective of the caller. At minimum, log unexpected exceptions at ERROR level before returning the generic error response.


9. Missing test for the include_rules=True path

get_asset_profile has a significant branch that fetches TierRuleParam rows and attaches them when include_rules=True, but no unit test covers it. The tuple_(...).in_(list(combos)) query is also worth testing since tuple_ support varies by backend.


Minor Nits

10. The _ = mcp.tool(...)(fn) pattern in trade_profile_registration.py is unusual. Other registration modules do not assign the return value. Simply calling mcp.tool(...)(fn) without assignment is clearer.

11. _validate_profile skips validation for empty-string input via the guard if normalized and normalized not in _VALID_PROFILES. An empty string passes silently and _normalize_profile converts it to None. Harmless in practice but surprising; consider raising for empty-string input explicitly.

12. app/mcp_server/tooling/__init__.py docstring still lists only the original registration modules, minor doc drift after adding trade-profile tooling.


What is Working Well

  • Upsert semantics and change-log design in set_asset_profile are solid.
  • _apply_profile_rules is clear and well-tested for both enforcement and rejection paths.
  • Existing model tests were cleanly updated to remove broker_account_id with no uncovered deletions.
  • test_mcp_trade_profile_tools.py covers the happy path, validation errors, and profile-rule enforcement comprehensively.
  • Testing that register_all_tools re-exported from app.mcp_server.tooling is the same object is a good defensive regression test.

@mgh3326 mgh3326 merged commit 3c54e58 into main Mar 3, 2026
15 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants