Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 7 additions & 5 deletions dashboard/backend/api/routers/agents.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
)
Expand Down Expand Up @@ -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).
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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(
Expand Down
20 changes: 11 additions & 9 deletions dashboard/backend/domain/agents/service.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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)
Expand All @@ -485,17 +486,18 @@ 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,
owner_user_id=int(owner_user_id),
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
Expand Down Expand Up @@ -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(
Expand Down
21 changes: 21 additions & 0 deletions dashboard/backend/domain/backtesting/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion dashboard/backend/tests/test_admin_analytics_frontend.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"',
Expand Down
6 changes: 3 additions & 3 deletions dashboard/backend/tests/test_agent_duplicate.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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):
Expand Down
2 changes: 1 addition & 1 deletion dashboard/backend/tests/test_analytics_frontend.py
Original file line number Diff line number Diff line change
Expand Up @@ -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('<script src="app.js?v=142" defer></script>')
app_at = APP_HTML.index('<script src="app.js?v=143" defer></script>')
analytics_at = APP_HTML.index(
'<script src="js/analytics.js?v=1" defer></script>'
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 = '<script src="js/backtest-comparison.js?v=1" defer></script>'
app = '<script src="app.js?v=142" defer></script>'
app = '<script src="app.js?v=143" defer></script>'
assert 'href="styles.css?v=147"' in APP_HTML
assert APP_HTML.index(helper) < APP_HTML.index(app)
for element_id in (
Expand Down
4 changes: 2 additions & 2 deletions dashboard/backend/tests/test_frontend_fast_boot.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
11 changes: 11 additions & 0 deletions dashboard/backend/tests/test_my_agents_card_ui.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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'<button[^>]*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."""
Expand Down
120 changes: 115 additions & 5 deletions dashboard/backend/tests/test_portfolio_allocate.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,18 +15,21 @@
# 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
Comment thread
github-advanced-security[bot] marked this conversation as resolved.
Fixed
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
import dashboard.backend.domain.portfolios.service as portfolio_service_module
import dashboard.backend.users as users_module
from dashboard.backend.app import app
from dashboard.backend.domain.agents.defaults import STARTER_AGENTS
from dashboard.backend.domain.backtesting.constants import (
DEFAULT_AGENT_CASH_ALLOCATION,
DEFAULT_PORTFOLIO_EQUITY,
MAX_AGENT_CASH_ALLOCATION,
)

# Plain values read off the module (it is imported whole for the
# PAPER_TRADING_ENABLED patch below; a second `from` import of it is CodeQL
# py/import-and-import-from). None of these three is ever patched.
DEFAULT_AGENT_CASH_ALLOCATION = backtesting_constants.DEFAULT_AGENT_CASH_ALLOCATION
DEFAULT_PORTFOLIO_EQUITY = backtesting_constants.DEFAULT_PORTFOLIO_EQUITY
MAX_AGENT_CASH_ALLOCATION = backtesting_constants.MAX_AGENT_CASH_ALLOCATION

# Signup mints one starter per STARTER_AGENTS entry, each funded at
# DEFAULT_AGENT_CASH_ALLOCATION. Cash checks after _signup() subtract this,
Expand All @@ -38,6 +41,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)."""
Expand Down Expand Up @@ -568,3 +580,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
Loading
Loading