From 3ea40a661a76de89e50b9d2c99633cad1a09de9f Mon Sep 17 00:00:00 2001 From: FlyM1ss Date: Fri, 25 Sep 2026 15:41:24 -0400 Subject: [PATCH 1/2] fix: reserve $0 server-side while paper trading is off Signup starters, marketplace clone, duplicate and a create that omits cash_allocation all still reserved $1,000 from the now-hidden ledger. Route them through new_agent_cash_allocation(), gated on a server-side PAPER_TRADING_ENABLED. Also hide Home's "View my portfolio", whose scroll target this PR hides. Co-Authored-By: Claude Opus 5.5 (1M context) --- dashboard/backend/api/routers/agents.py | 12 +- dashboard/backend/domain/agents/service.py | 20 ++-- .../backend/domain/backtesting/constants.py | 21 ++++ .../tests/test_admin_analytics_frontend.py | 2 +- .../backend/tests/test_agent_duplicate.py | 6 +- .../backend/tests/test_analytics_frontend.py | 2 +- .../test_backtest_comparison_frontend.py | 2 +- .../backend/tests/test_frontend_fast_boot.py | 4 +- .../backend/tests/test_my_agents_card_ui.py | 11 ++ .../backend/tests/test_portfolio_allocate.py | 108 ++++++++++++++++++ .../tests/test_portfolio_scope_sync.py | 10 ++ dashboard/frontend/app.html | 8 +- dashboard/frontend/app.js | 4 +- dashboard/frontend/home-page.js | 3 + 14 files changed, 187 insertions(+), 26 deletions(-) diff --git a/dashboard/backend/api/routers/agents.py b/dashboard/backend/api/routers/agents.py index cb4af0b8..ed3c780d 100644 --- a/dashboard/backend/api/routers/agents.py +++ b/dashboard/backend/api/routers/agents.py @@ -20,10 +20,10 @@ ) from dashboard.backend.domain.backtesting.constants import ( - DEFAULT_AGENT_CASH_ALLOCATION, MAX_AGENT_CASH_ALLOCATION, MAX_BACKTEST_INITIAL_CAPITAL, MIN_BACKTEST_INITIAL_CAPITAL, + new_agent_cash_allocation, ) from dashboard.backend.domain.agents.repository import _UNSET from dashboard.backend.domain.agents.taxonomy import AgentCategory, coerce_category @@ -75,8 +75,10 @@ class CreateAgentBody(BaseModel): description: Optional[str] = Field(default=None, max_length=280) runtime_type: Literal["pipeline", "ai_hedge_fund"] = "pipeline" runtime_config: Dict[str, Any] = Field(default_factory=dict) + # None = "not chosen": the route resolves it via new_agent_cash_allocation() + # at call time ($0 while paper trading is switched off). cash_allocation: Optional[float] = Field( - default=DEFAULT_AGENT_CASH_ALLOCATION, + default=None, ge=0, le=MAX_AGENT_CASH_ALLOCATION, ) @@ -167,7 +169,7 @@ def create_agent( cash = float( body.cash_allocation if body.cash_allocation is not None - else DEFAULT_AGENT_CASH_ALLOCATION + else new_agent_cash_allocation() ) # Signed-in users fund the sleeve from their account portfolio (#175). @@ -280,7 +282,7 @@ def clone_marketplace_agent( ): """Copy a marketplace template into the caller's My Agents list.""" ctx = _require_owner_context(request, authorization) - cash = float(DEFAULT_AGENT_CASH_ALLOCATION) + cash = new_agent_cash_allocation() if ctx["user_id"] and cash > 0: try: portfolio_service.ensure_cash_for_new_agent( @@ -682,7 +684,7 @@ def duplicate_agent( """ ctx = _require_owner_context(request, authorization) _require_agent_access(agent_id, ctx, reclaim_on_session_match=True) - cash = float(DEFAULT_AGENT_CASH_ALLOCATION) + cash = new_agent_cash_allocation() if ctx["user_id"] and cash > 0: try: portfolio_service.ensure_cash_for_new_agent( diff --git a/dashboard/backend/domain/agents/service.py b/dashboard/backend/domain/agents/service.py index 25b3299e..0d4ccc85 100644 --- a/dashboard/backend/domain/agents/service.py +++ b/dashboard/backend/domain/agents/service.py @@ -405,14 +405,14 @@ def create_agent( protocol API and never use the editor pipeline, so they are not seeded. """ from dashboard.backend.domain.backtesting.constants import ( - DEFAULT_AGENT_CASH_ALLOCATION, + new_agent_cash_allocation, ) runtime_type = normalize_runtime_type(runtime_type) runtime_config = normalize_runtime_config(runtime_type, runtime_config or {}) category = coerce_category(category) # see update_agent for why if cash_allocation is None: - cash_allocation = float(DEFAULT_AGENT_CASH_ALLOCATION) + cash_allocation = new_agent_cash_allocation() agent = self.agents.create_agent( name=name, model_name=model_name, @@ -460,13 +460,14 @@ def provision_starter_agents( Fail-open per card: signup must still succeed if one write fails. """ from dashboard.backend.domain.backtesting.constants import ( - DEFAULT_AGENT_CASH_ALLOCATION, + new_agent_cash_allocation, ) from dashboard.backend.domain.portfolios.service import portfolio_service owned = list(self.agents.list_agents(owner_user_id=int(owner_user_id))) by_model = {str(agent.get("model_name") or ""): agent for agent in owned} created: List[Dict[str, Any]] = [] + starter_cash = new_agent_cash_allocation() for spec in STARTER_AGENTS: model_name = spec["model_name"] existing_agent = by_model.get(model_name) @@ -485,9 +486,10 @@ def provision_starter_agents( ) continue try: - portfolio_service.ensure_cash_for_new_agent( - int(owner_user_id), float(DEFAULT_AGENT_CASH_ALLOCATION) - ) + if starter_cash > 0: + portfolio_service.ensure_cash_for_new_agent( + int(owner_user_id), starter_cash + ) agent = self.create_agent( name=spec["name"], model_name=model_name, @@ -495,7 +497,7 @@ def provision_starter_agents( owner_browser_session=owner_browser_session, agent_type="builtin", description=spec["description"], - cash_allocation=float(DEFAULT_AGENT_CASH_ALLOCATION), + cash_allocation=starter_cash, ) created.append(agent) by_model[model_name] = agent @@ -554,8 +556,8 @@ def _create_builtin_copy( le=MAX_BACKTEST_INITIAL_CAPITAL``), so it is safe to copy from a source. ``cash_allocation`` is a real ledger debit and must NOT be copied here -- ``create_agent`` below is deliberately not passed one, - so it falls back to ``DEFAULT_AGENT_CASH_ALLOCATION`` like any other - fresh agent. + so it falls back to ``new_agent_cash_allocation()`` like any other + fresh agent ($0 while paper trading is switched off). """ has_own_pipeline = isinstance(pipeline, list) and bool(pipeline) agent = self.create_agent( diff --git a/dashboard/backend/domain/backtesting/constants.py b/dashboard/backend/domain/backtesting/constants.py index 588974cd..4e8bfc76 100644 --- a/dashboard/backend/domain/backtesting/constants.py +++ b/dashboard/backend/domain/backtesting/constants.py @@ -66,6 +66,27 @@ DEFAULT_AGENT_CASH_ALLOCATION = 1000 MAX_AGENT_CASH_ALLOCATION = 3_000 +# Paper trading is switched off product-wide until it ships for real +# (``execution/paper_backend.py`` is still a stub); the dashboard mirrors this as +# ``PAPER_TRADING_ENABLED`` in ``app.js`` and hides the ledger the sleeves draw +# from. While off, every server-side path that picks a sleeve on the caller's +# behalf -- signup starters, marketplace clone, duplicate, a create that omits +# ``cash_allocation`` -- reserves ``new_agent_cash_allocation()`` ($0) instead +# of $1,000: a reservation nobody can see or release would otherwise refuse a +# later create with "Insufficient unallocated cash". An explicit +# ``cash_allocation`` is still honoured, and existing sleeves are left +# untouched. Flip both flags together. +PAPER_TRADING_ENABLED = False + + +def new_agent_cash_allocation() -> float: + """Sleeve a new agent gets when nobody chose one. + + A function, read at call time, so the ledger tests can pin the paper-on + behaviour by patching ``PAPER_TRADING_ENABLED`` alone. + """ + return float(DEFAULT_AGENT_CASH_ALLOCATION) if PAPER_TRADING_ENABLED else 0.0 + def resolve_initial_capital(requested: Optional[Any] = None) -> float: """Resolve simulation capital for a backtest / protocol run. diff --git a/dashboard/backend/tests/test_admin_analytics_frontend.py b/dashboard/backend/tests/test_admin_analytics_frontend.py index 17431429..c260f654 100644 --- a/dashboard/backend/tests/test_admin_analytics_frontend.py +++ b/dashboard/backend/tests/test_admin_analytics_frontend.py @@ -162,7 +162,7 @@ def test_app_lifecycle_and_cache_versions_are_wired(): # Lockstep owner for the console's bumped tags and the /admin page's pins: # every bump edits this test in the same change (Global Constraints). assert 'styles.css?v=147' in APP_HTML - assert 'app.js?v=142' in APP_HTML + assert 'app.js?v=143' in APP_HTML assert 'js/admin-tabs.js?v=12' in APP_HTML for tag in ( 'href="admin.css?v=4"', diff --git a/dashboard/backend/tests/test_agent_duplicate.py b/dashboard/backend/tests/test_agent_duplicate.py index 134d3555..5b913031 100644 --- a/dashboard/backend/tests/test_agent_duplicate.py +++ b/dashboard/backend/tests/test_agent_duplicate.py @@ -118,8 +118,8 @@ def test_duplicate_copies_backtest_allocation_but_not_cash_allocation(client): against the source's -- different starting capital makes those curves incomparable. ``backtest_allocation`` is simulated capital with no ledger coupling, so it is safe to copy. ``cash_allocation`` IS a real ledger debit - (the route reserves only DEFAULT_AGENT_CASH_ALLOCATION for it) and must - stay un-copied.""" + (the route reserves only ``new_agent_cash_allocation()`` for it -- $0 while + paper trading is switched off) and must stay un-copied.""" headers = {"X-Session-Id": str(uuid.uuid4())} created = client.post( "/api/v1/agents", @@ -144,7 +144,7 @@ def test_duplicate_copies_backtest_allocation_but_not_cash_allocation(client): assert response.status_code == 200, response.text copy = response.json()["agent"] assert copy["backtest_allocation"] == 2000 - assert copy["cash_allocation"] == 1000 + assert copy["cash_allocation"] == 0 def test_duplicate_defaults_the_name(client): diff --git a/dashboard/backend/tests/test_analytics_frontend.py b/dashboard/backend/tests/test_analytics_frontend.py index 8f7eecd1..0cad3c3a 100644 --- a/dashboard/backend/tests/test_analytics_frontend.py +++ b/dashboard/backend/tests/test_analytics_frontend.py @@ -41,7 +41,7 @@ def _function_body(source: str, signature: str) -> str: def test_analytics_script_loads_between_app_and_page_scripts(): - app_at = APP_HTML.index('') + app_at = APP_HTML.index('') analytics_at = APP_HTML.index( '' ) diff --git a/dashboard/backend/tests/test_backtest_comparison_frontend.py b/dashboard/backend/tests/test_backtest_comparison_frontend.py index 664d8617..c0a6d826 100644 --- a/dashboard/backend/tests/test_backtest_comparison_frontend.py +++ b/dashboard/backend/tests/test_backtest_comparison_frontend.py @@ -191,7 +191,7 @@ def test_exact_raw_ties_mark_every_tied_series_best(): def test_comparison_script_and_semantic_table_ship_before_app(): helper = '' - app = '' + app = '' assert 'href="styles.css?v=147"' in APP_HTML assert APP_HTML.index(helper) < APP_HTML.index(app) for element_id in ( diff --git a/dashboard/backend/tests/test_frontend_fast_boot.py b/dashboard/backend/tests/test_frontend_fast_boot.py index 09de5b54..e7e0bf9e 100644 --- a/dashboard/backend/tests/test_frontend_fast_boot.py +++ b/dashboard/backend/tests/test_frontend_fast_boot.py @@ -191,11 +191,11 @@ def test_cache_busters_bumped(): # the next bump, so the exact one looks like the broken guard and gets # "fixed" by loosening it. That collision has already cost this repo one # round of follow-ups (#347/#348). - assert "app.js?v=142" in APP_HTML + assert "app.js?v=143" in APP_HTML assert "js/agent-editor.js?v=32" in APP_HTML assert "styles.css?v=147" in APP_HTML assert "js/leaderboard.js?v=33" in APP_HTML - assert "home-page.js?v=50" in APP_HTML + assert "home-page.js?v=51" in APP_HTML assert "js/credit-format.js?v=1" in APP_HTML assert "js/credits.js?v=8" in APP_HTML assert "js/admin-credits.js?v=8" in APP_HTML diff --git a/dashboard/backend/tests/test_my_agents_card_ui.py b/dashboard/backend/tests/test_my_agents_card_ui.py index 25f1983a..d5c8837b 100644 --- a/dashboard/backend/tests/test_my_agents_card_ui.py +++ b/dashboard/backend/tests/test_my_agents_card_ui.py @@ -9,6 +9,7 @@ shows the backtest figure alone and offers no paper-trading button at all. """ +import re import shutil import subprocess from pathlib import Path @@ -160,6 +161,16 @@ def test_cards_offer_no_paper_trading_button(): assert ">Run Paper Trading<" not in actions + +def test_home_view_my_portfolio_button_ships_hidden(): + """Its target -- the My Portfolio header on My Agents -- is hidden while + paper trading is off, so the button would navigate and then scroll nowhere.""" + html = (_FRONTEND / "app.html").read_text(encoding="utf-8") + tag = re.search(r']*id="homeModuleViewPortfolioBtn"[^>]*>', html) + assert tag, "Home 'View my portfolio' button not found" + assert "data-paper-trading-only" in tag.group(0) + assert re.search(r"\shidden[\s>]", tag.group(0)) + def test_status_badge_never_says_paper_trading_while_it_is_off(): """A live/paper deployment flag (or the guest demo's is_live mock) must not resurrect the PAPER TRADING card while the feature is disabled.""" diff --git a/dashboard/backend/tests/test_portfolio_allocate.py b/dashboard/backend/tests/test_portfolio_allocate.py index e36d3d89..a0be26b1 100644 --- a/dashboard/backend/tests/test_portfolio_allocate.py +++ b/dashboard/backend/tests/test_portfolio_allocate.py @@ -15,6 +15,7 @@ # attributes at call time to see the patched value. Importing the classes # directly as well would bind two names to one module (CodeQL # py/import-and-import-from) and make it easy to grab a stale, unpatched store. +import dashboard.backend.domain.backtesting.constants as backtesting_constants import dashboard.backend.domain.agents.repository as agent_repo import dashboard.backend.domain.agents.service as agent_service_module import dashboard.backend.domain.portfolios.repository as portfolio_repo @@ -38,6 +39,15 @@ def _cash_after_signup() -> float: return float(DEFAULT_PORTFOLIO_EQUITY) - STARTER_ALLOCATED +@pytest.fixture(autouse=True) +def paper_trading_on(monkeypatch): + """These tests pin the ledger that paper trading draws on, so they run with + it switched on: while it is off, nothing picks a non-zero sleeve on the + caller's behalf (``new_agent_cash_allocation``) and every default below + would be $0.""" + monkeypatch.setattr(backtesting_constants, "PAPER_TRADING_ENABLED", True) + + @pytest.fixture def env(monkeypatch): """Auth + portfolio + agents share one content DB (ledger ↔ sleeve).""" @@ -568,3 +578,101 @@ def allocate(agent): assert len(rejected) == 1, f"expected one rejection, got {rejected}" expected = STARTER_ALLOCATED + 2 * float(MAX_AGENT_CASH_ALLOCATION) assert total == expected, f"over-allocated: {total} against a 10000 account" + + +# --------------------------------------------------------------------------- +# Paper trading switched off (the shipped state). The dashboard hides My +# Portfolio then, so a sleeve the server picks by default is a reservation +# nobody can see or release -- and enough of them refuse a later create with +# "Insufficient unallocated cash". Every path that chooses a sleeve for the +# caller (signup starters, a create that omits one, marketplace clone, +# duplicate) reserves $0; an explicit value is still honoured. +# --------------------------------------------------------------------------- + + +def _switch_paper_trading_off(monkeypatch): + # Runs in the test body, after the autouse paper_trading_on fixture. + monkeypatch.setattr(backtesting_constants, "PAPER_TRADING_ENABLED", False) + + +def test_paper_trading_ships_switched_off(): + # Read the source, not the module attribute: the autouse fixture above has + # already switched the flag on for this test. + source = Path(backtesting_constants.__file__).read_text(encoding="utf-8") + assert "\nPAPER_TRADING_ENABLED = False\n" in source + + +def test_signup_starters_reserve_nothing(client, monkeypatch): + _switch_paper_trading_off(monkeypatch) + token, _ = _signup(client, "paper-off-signup@example.com") + headers = _auth(token) + + agents = client.get("/api/v1/agents", headers=headers).json()["agents"] + assert agents, "signup should still provision the starter agents" + assert all(float(agent["cash_allocation"] or 0) == 0 for agent in agents) + + portfolio = client.get("/api/v1/portfolio", headers=headers).json()["portfolio"] + assert portfolio["allocated"] == 0 + assert portfolio["cash_available"] == float(DEFAULT_PORTFOLIO_EQUITY) + + +def test_create_without_a_sleeve_reserves_nothing(client, monkeypatch): + _switch_paper_trading_off(monkeypatch) + token, _ = _signup(client, "paper-off-create@example.com") + headers = _auth(token) + + created = client.post( + "/api/v1/agents", + headers=headers, + json={"name": "No sleeve", "model_name": "local-model", "agent_type": "builtin"}, + ) + assert created.status_code == 200, created.text + assert created.json()["agent"]["cash_allocation"] == 0 + + +def test_an_explicit_sleeve_is_still_honoured(client, monkeypatch): + _switch_paper_trading_off(monkeypatch) + token, _ = _signup(client, "paper-off-explicit@example.com") + headers = _auth(token) + + created = client.post( + "/api/v1/agents", + headers=headers, + json={ + "name": "Explicit", + "model_name": "local-model", + "agent_type": "builtin", + "cash_allocation": 1500, + }, + ) + assert created.status_code == 200, created.text + assert created.json()["agent"]["cash_allocation"] == 1500 + portfolio = client.get("/api/v1/portfolio", headers=headers).json()["portfolio"] + assert portfolio["allocated"] == 1500 + + +def test_clone_and_duplicate_reserve_nothing_and_never_run_out(client, monkeypatch): + """Twelve copies would have needed $12,000 of a $10,000 ledger at $1,000 + each -- the "Insufficient unallocated cash" refusal this change removes.""" + _switch_paper_trading_off(monkeypatch) + token, _ = _signup(client, "paper-off-copies@example.com") + headers = _auth(token) + + cloned = client.post( + "/api/v1/agents/marketplace/claude-haiku-4-5/clone", headers=headers, json={} + ) + assert cloned.status_code == 200, cloned.text + source = cloned.json()["agent"] + assert source["cash_allocation"] == 0 + + for _ in range(12): + copy = client.post( + f"/api/v1/agents/{source['agent_id']}/duplicate", + headers=headers, + json={"model_name": "deepseek/deepseek-v4-pro"}, + ) + assert copy.status_code == 200, copy.text + assert copy.json()["agent"]["cash_allocation"] == 0 + + portfolio = client.get("/api/v1/portfolio", headers=headers).json()["portfolio"] + assert portfolio["allocated"] == 0 diff --git a/dashboard/backend/tests/test_portfolio_scope_sync.py b/dashboard/backend/tests/test_portfolio_scope_sync.py index 72d5bbbd..d7c6446a 100644 --- a/dashboard/backend/tests/test_portfolio_scope_sync.py +++ b/dashboard/backend/tests/test_portfolio_scope_sync.py @@ -28,6 +28,7 @@ from dashboard.backend.tests.auth_cookies_helpers import _cookie_session_token +import dashboard.backend.domain.backtesting.constants as backtesting_constants import dashboard.backend.domain.agents.repository as agent_repo import dashboard.backend.domain.agents.service as agent_service_module import dashboard.backend.domain.portfolios.repository as portfolio_repo @@ -39,6 +40,15 @@ BROWSER = "browser-scope-sync" +@pytest.fixture(autouse=True) +def paper_trading_on(monkeypatch): + """These tests pin the ledger that paper trading draws on, so they run with + it switched on: while it is off, nothing picks a non-zero sleeve on the + caller's behalf (``new_agent_cash_allocation``) and every default below + would be $0.""" + monkeypatch.setattr(backtesting_constants, "PAPER_TRADING_ENABLED", True) + + @pytest.fixture def env(monkeypatch): with tempfile.TemporaryDirectory() as tmpdir: diff --git a/dashboard/frontend/app.html b/dashboard/frontend/app.html index 4c8cad1b..cbd3c946 100644 --- a/dashboard/frontend/app.html +++ b/dashboard/frontend/app.html @@ -651,7 +651,9 @@

My Portfolio

- + +
@@ -2409,7 +2411,7 @@

Refund Credits purchase

- + @@ -2417,7 +2419,7 @@

Refund Credits purchase

- +