diff --git a/dashboard/backend/domain/agents/marketplace.py b/dashboard/backend/domain/agents/marketplace.py index 2a5a4ee2..f4097d30 100644 --- a/dashboard/backend/domain/agents/marketplace.py +++ b/dashboard/backend/domain/agents/marketplace.py @@ -1,8 +1,9 @@ """Open agent templates for the Agent Supermarket. -Templates are defined in ``dashboard/config/marketplace.json`` so baseline open -agents can be added without schema migrations. The listing is public; cloning -creates a user-owned built-in agent with the template's pipeline copied in. +Templates are defined in ``dashboard/config/marketplace.json`` so competition +models and hosted agents can be added without schema migrations. The listing +is public; cloning creates a user-owned built-in agent with the template's +pipeline copied in. """ from __future__ import annotations @@ -11,14 +12,29 @@ from functools import lru_cache from typing import Any, Dict, List, Optional -from dashboard.backend.domain.agents.taxonomy import ( - category_sort_rank, - normalize_category, -) +from dashboard.backend.domain.agents.taxonomy import normalize_category from dashboard.backend.paths import CONFIG_DIR _MARKETPLACE_PATH = CONFIG_DIR / "marketplace.json" +# Community supermarket rows. Declared order is display order: LLMs first, +# then Agents. Unknown / omitted values fall through ``_normalize_shelf``. +MARKETPLACE_SHELVES = ("llms", "open") + + +def _normalize_shelf(raw: Dict[str, Any]) -> str: + """Return ``llms`` or ``open``. + + Explicit ``shelf`` on the catalog row wins. Otherwise a non-pipeline + runtime (today: AI Hedge Fund) is an open agent, so a future hosted + project does not have to remember the field to land on the right row. + """ + explicit = str(raw.get("shelf") or "").strip().lower() + if explicit in MARKETPLACE_SHELVES: + return explicit + runtime_type = str(raw.get("runtime_type") or "pipeline") + return "open" if runtime_type != "pipeline" else "llms" + def _public_template(raw: Dict[str, Any]) -> Dict[str, Any]: # This "category" and an agent's "category" used to be two different @@ -47,6 +63,8 @@ def _public_template(raw: Dict[str, Any]) -> Dict[str, Any]: "author": raw.get("author") or "Community", "runtime_type": runtime_type, "step_count": step_count, + "shelf": _normalize_shelf(raw), + "card_subtitle": str(raw.get("card_subtitle") or "").strip() or None, "mode": ( "runtime" if runtime_type != "pipeline" @@ -82,18 +100,17 @@ def _load_catalog() -> Dict[str, Dict[str, Any]]: def list_marketplace_templates() -> List[Dict[str, Any]]: - """Return public marketplace cards grouped by shelf, then sorted by name. + """Return public marketplace cards grouped by supermarket shelf. - Ordered by ``category_sort_rank`` rather than by the slug itself: the slugs - are not alphabetical in shelf order, so a plain ``sorted`` on the raw value - leads the Community listing with the A-share shelf. Uncategorized templates - sort last. + ``MARKETPLACE_SHELVES`` declaration order is the page order (LLMs, then + Agents). Within a shelf the catalog's insertion order is preserved + (stable sort) so Community can list models in the leaderboard roster + order without a second sort key. """ items = [_public_template(raw) for raw in _load_catalog().values()] - return sorted( - items, - key=lambda t: (category_sort_rank(t.get("category")), str(t.get("name") or "")), - ) + # _public_template always sets "shelf" via _normalize_shelf, which only + # returns members of MARKETPLACE_SHELVES -- no fallback branch needed. + return sorted(items, key=lambda t: MARKETPLACE_SHELVES.index(t["shelf"])) def get_marketplace_template(template_id: str) -> Optional[Dict[str, Any]]: diff --git a/dashboard/backend/tests/test_admin_analytics_frontend.py b/dashboard/backend/tests/test_admin_analytics_frontend.py index 9599048a..9bf878c0 100644 --- a/dashboard/backend/tests/test_admin_analytics_frontend.py +++ b/dashboard/backend/tests/test_admin_analytics_frontend.py @@ -190,8 +190,8 @@ def test_app_lifecycle_and_cache_versions_are_wired(): assert "window.AdminAnalytics.syncAuth(user)" in APP_JS assert "window.AdminAnalytics.onEnter()" in APP_JS assert "window.AdminAnalytics.refresh()" in APP_JS - assert 'styles.css?v=130' in APP_HTML - assert 'app.js?v=125' in APP_HTML + assert 'styles.css?v=132' in APP_HTML + assert 'app.js?v=127' in APP_HTML assert 'js/admin-analytics.js?v=2' in APP_HTML assert 'js/admin-tabs.js?v=3' in APP_HTML diff --git a/dashboard/backend/tests/test_agent_starter_defaults.py b/dashboard/backend/tests/test_agent_starter_defaults.py index 79e4cdb1..b447d99c 100644 --- a/dashboard/backend/tests/test_agent_starter_defaults.py +++ b/dashboard/backend/tests/test_agent_starter_defaults.py @@ -175,10 +175,30 @@ def test_create_still_returns_the_one_time_api_key(client): assert body["session_id"] -def test_marketplace_clone_keeps_its_own_pipeline(client): - """A template with its own pipeline must not be overwritten by the seed.""" +def test_marketplace_clone_keeps_its_own_pipeline(client, monkeypatch): + """A template with its own pipeline must not be overwritten by the seed. + + The shipped catalog is now one-step competition-model cards plus a hosted + runtime, so the three-step shape is faked — the guard is the copy, not + which template still ships it. + """ + fake_template = { + "template_id": "three-step-template", + "name": "Three-Step Template", + "model_name": "anthropic/claude-haiku-4-5", + "pipeline": [ + {"id": "sub_gather", "presetKey": "info_gather", "prompt": "gather"}, + {"id": "sub_signal", "presetKey": "info_to_signal", "prompt": "signal"}, + {"id": "sub_exec", "presetKey": "signal_to_execution", "prompt": "exec"}, + ], + } + monkeypatch.setattr( + marketplace_module, + "get_marketplace_template", + lambda template_id: fake_template if template_id == "three-step-template" else None, + ) cloned = client.post( - "/api/v1/agents/marketplace/pipeline-analyst/clone", + "/api/v1/agents/marketplace/three-step-template/clone", json={}, headers={"X-Session-Id": str(uuid.uuid4())}, ) diff --git a/dashboard/backend/tests/test_agents_api.py b/dashboard/backend/tests/test_agents_api.py index 402d481c..75157822 100644 --- a/dashboard/backend/tests/test_agents_api.py +++ b/dashboard/backend/tests/test_agents_api.py @@ -735,7 +735,7 @@ def test_marketplace_listing_and_clone(client): assert listing.status_code == 200 templates = listing.json()["templates"] assert templates - assert any(t["template_id"] == "balanced-starter" for t in templates) + assert any(t["template_id"] == "claude-haiku-4-5" for t in templates) hedge_fund_card = next( t for t in templates if t["template_id"] == "ai-hedge-fund" ) @@ -751,13 +751,13 @@ def test_marketplace_listing_and_clone(client): browser_session = str(uuid.uuid4()) headers = {"X-Session-Id": browser_session} cloned = client.post( - "/api/v1/agents/marketplace/balanced-starter/clone", + "/api/v1/agents/marketplace/claude-haiku-4-5/clone", json={}, headers=headers, ) assert cloned.status_code == 200 agent = cloned.json()["agent"] - assert agent["name"] == "Balanced Starter" + assert agent["name"] == "Claude Haiku 4.5" assert agent["agent_type"] == "builtin" assert agent.get("pipeline") assert agent["pipeline"][0]["presetKey"] == "simple_instruction" @@ -825,43 +825,38 @@ def test_marketplace_catalog_shape(): ) # "Pipeline" is banned product-copy vocabulary (glossary: pipeline -> - # "multi-step strategy"); the template_id stays "pipeline-analyst" since - # it's an API identifier baked into clone URLs, but the display name -- - # the card's largest text -- must not carry the word. + # "multi-step strategy"). The hosted card and the competition-model cards + # must not put that word in the display name -- the card's largest text. names = {t["template_id"]: t["name"] for t in templates} - assert names["pipeline-analyst"] == "Three-Step Analyst" - assert "Pipeline Analyst" not in names.values() + assert names["ai-hedge-fund"] == "AI Hedge Fund" + assert "Pipeline" not in " ".join(names.values()) def test_marketplace_listing_is_ordered_by_shelf_not_by_slug(): - """Community cards group by market in *declaration* order, not slug order. - - The recategorization onto slugs quietly changed which card leads the page: - ``sorted`` on the raw value orders cn_ashares < us_stocks, so the A-share - template became card #1 on a U.S.-focused product. Nothing caught it because - no test asserted order. ``category_sort_rank`` keys on the AgentCategory - Literal's declaration order instead, which is also the order MARKET_LABELS - renders the market chips in, so the two surfaces agree. + """Community cards group by supermarket shelf, LLMs then Agents. + + Within a shelf the catalog's insertion order is preserved so the LLM + row can follow the leaderboard roster instead of alphabetical names. """ import dashboard.backend.domain.agents.marketplace as marketplace_mod - from dashboard.backend.domain.agents.taxonomy import ( - AGENT_CATEGORY_ORDER, - category_sort_rank, - ) marketplace_mod.reload_marketplace_catalog() templates = marketplace_mod.list_marketplace_templates() - ranks = [category_sort_rank(t.get("category")) for t in templates] - assert ranks == sorted(ranks), "templates are not grouped in shelf order" - - # The U.S. market leads; uncategorized templates never do. - assert templates[0]["category"] == AGENT_CATEGORY_ORDER[0] == "us_stocks" - assert templates[-1]["category"] == "cn_ashares" - - # Within a shelf, still by name. - us_stocks = [t["name"] for t in templates if t["category"] == "us_stocks"] - assert us_stocks == sorted(us_stocks) + shelves = [t.get("shelf") for t in templates] + assert set(shelves) <= set(marketplace_mod.MARKETPLACE_SHELVES) + llms = [t for t in templates if t["shelf"] == "llms"] + opens = [t for t in templates if t["shelf"] == "open"] + assert llms, "the LLM shelf is empty" + assert opens, "the Agents shelf is empty" + assert templates[0]["shelf"] == "llms" + assert templates[-1]["shelf"] == "open" + assert [t["name"] for t in opens][0] == "AI Hedge Fund" + assert {t["template_id"] for t in opens} >= { + "ai-hedge-fund", + "balanced-starter", + "ashare-momentum-t1", + } def test_uncategorized_templates_sort_last_and_carry_no_fake_shelf(): @@ -1416,7 +1411,7 @@ def test_builtin_listing_echoes_category(client): def test_clone_honours_a_model_name_override(client): """Community's "Choose model" affordance clones a template onto another model.""" cloned = client.post( - "/api/v1/agents/marketplace/balanced-starter/clone", + "/api/v1/agents/marketplace/claude-haiku-4-5/clone", json={"model_name": "deepseek/deepseek-v4-pro"}, headers={"X-Session-Id": str(uuid.uuid4())}, ) @@ -1429,7 +1424,7 @@ def test_clone_falls_back_to_the_template_model(client, blank): """Omitted or blank means "use the template's model", not "use empty".""" body = {} if blank is None else {"model_name": blank} cloned = client.post( - "/api/v1/agents/marketplace/balanced-starter/clone", + "/api/v1/agents/marketplace/claude-haiku-4-5/clone", json=body, headers={"X-Session-Id": str(uuid.uuid4())}, ) @@ -1441,7 +1436,7 @@ def test_clone_does_not_validate_the_model_name(client): """No whitelist here: POST /agents and PATCH /agents/{id} don't have one either, and a Literal would drag in the openapi enum deploy gate #313 discharged.""" cloned = client.post( - "/api/v1/agents/marketplace/balanced-starter/clone", + "/api/v1/agents/marketplace/claude-haiku-4-5/clone", json={"model_name": "some/unreleased-model"}, headers={"X-Session-Id": str(uuid.uuid4())}, ) diff --git a/dashboard/backend/tests/test_analytics_frontend.py b/dashboard/backend/tests/test_analytics_frontend.py index b6f61650..71e443d9 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_app_copy_register.py b/dashboard/backend/tests/test_app_copy_register.py index 16fcff56..51021165 100644 --- a/dashboard/backend/tests/test_app_copy_register.py +++ b/dashboard/backend/tests/test_app_copy_register.py @@ -403,12 +403,12 @@ def test_marketplace_mode_chip_labels_avoid_banned_words(): `renderMarketplaceGrid` so this doesn't over-match an unrelated string elsewhere in app.js. """ - body = fn_body("function renderMarketplaceGrid") + body = fn_body("function buildMarketplaceCardHtml") assert "'Hosted runtime'" not in body assert "'Multi-step pipeline'" not in body - assert "'Hosted'" in body - assert "'Multi-step strategy'" in body - assert "'Simple instruction'" in body + assert "Open Source" in body + assert "'Simple instruction'" not in body + assert "'Multi-step strategy'" not in body def test_each_agent_gets_its_own_trading_session_is_gone(): diff --git a/dashboard/backend/tests/test_backtest_comparison_frontend.py b/dashboard/backend/tests/test_backtest_comparison_frontend.py index 818dffbb..6d2bb002 100644 --- a/dashboard/backend/tests/test_backtest_comparison_frontend.py +++ b/dashboard/backend/tests/test_backtest_comparison_frontend.py @@ -191,8 +191,8 @@ def test_exact_raw_ties_mark_every_tied_series_best(): def test_comparison_script_and_semantic_table_ship_before_app(): helper = '' - app = '' - assert 'href="styles.css?v=130"' in APP_HTML + app = '' + assert 'href="styles.css?v=132"' in APP_HTML assert APP_HTML.index(helper) < APP_HTML.index(app) for element_id in ( "performanceLegend", diff --git a/dashboard/backend/tests/test_frontend_fast_boot.py b/dashboard/backend/tests/test_frontend_fast_boot.py index 91b77efc..338470c3 100644 --- a/dashboard/backend/tests/test_frontend_fast_boot.py +++ b/dashboard/backend/tests/test_frontend_fast_boot.py @@ -191,9 +191,9 @@ 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=125" in APP_HTML + assert "app.js?v=127" in APP_HTML assert "js/agent-editor.js?v=30" in APP_HTML - assert "styles.css?v=130" in APP_HTML + assert "styles.css?v=132" in APP_HTML assert "js/leaderboard.js?v=32" in APP_HTML assert "home-page.js?v=50" in APP_HTML assert "js/credit-format.js?v=1" in APP_HTML diff --git a/dashboard/backend/tests/test_frontend_model_facets.py b/dashboard/backend/tests/test_frontend_model_facets.py index 51d78316..cd035f5b 100644 --- a/dashboard/backend/tests/test_frontend_model_facets.py +++ b/dashboard/backend/tests/test_frontend_model_facets.py @@ -1,10 +1,10 @@ -"""Guards for the Community model-vendor facet. +"""Guards for the Community model-vendor table. The vendor axis is a pure derivation from `model_name` -- no column, no -migration. MODEL_VENDORS is its single source of truth: chip order, display -label and open/closed licence all come from one table, so a badge cannot drift -from the vendor it describes. A wrong badge is a factual claim about someone -else's product. +migration. MODEL_VENDORS is its single source of truth: the pinned key/label +rows, the LLM tile's company submeta and the (currently unrendered) licence +metadata all come from one table, so an entry cannot drift from the vendor it +describes. A wrong entry is a factual claim about someone else's product. """ import json @@ -89,35 +89,6 @@ def test_supported_model_vendor_fields_agree_with_the_vendor_table(): assert vendor == expected, f"{slug} is tagged {vendor!r}, table says {expected!r}" -@pytest.mark.skipif(shutil.which("node") is None, reason="node is not installed") -def test_provider_label_output_is_unchanged(): - """These six strings ship on cards today. The refactor must not touch them.""" - script = f""" -{js_const("MODEL_VENDORS")} -{fn_body("function modelVendorKey")} -{fn_body("function formatModelProviderLabel")} -const cases = ['anthropic/claude-haiku-4-5', 'nvidia/nemotron-3-nano-30b-a3b', - 'deepseek/deepseek-v4-pro', 'openai/gpt-5.5', - 'google/gemini-3.1-pro-preview', 'qwen/qwen3.7-plus', - 'totally/unknown', '']; -console.log(JSON.stringify(cases.map(formatModelProviderLabel))); -""" - result = subprocess.run( - ["node", "-e", script], capture_output=True, text=True, timeout=30 - ) - assert result.returncode == 0, result.stderr - assert json.loads(result.stdout) == [ - "Powered by Claude", - "Powered by NVIDIA Nemotron", - "Powered by DeepSeek", - "Powered by GPT", - "Powered by Gemini", - "Powered by Qwen", - "AI-powered", - "AI-powered", - ] - - @pytest.mark.skipif(shutil.which("node") is None, reason="node is not installed") def test_unknown_vendor_resolves_to_empty_string(): """Same contract as agentMarketKey: unknown stays visible under All and is @@ -143,38 +114,34 @@ def test_unknown_vendor_resolves_to_empty_string(): def test_vendor_chip_container_exists_in_the_community_view(): + """Model-vendor chips were removed: the LLM shelf already names each model.""" community = APP_HTML[ APP_HTML.index('
marketplaceMarketChips(templates).map((c) => c.key); +const labels = (templates) => marketplaceMarketChips(templates).map((c) => c.label); +console.log(JSON.stringify({{ + usOnly: keys([{{category: 'us_stocks'}}, {{category: 'us_stocks'}}]), + mixed: keys([{{category: 'cn_ashares'}}, {{category: 'us_stocks'}}]), + empty: keys([]), + unknown: keys([{{category: 'crypto'}}]), + usLabels: labels([{{category: 'us_stocks'}}]), +}})); +""" + result = subprocess.run( + ["node", "-e", script], capture_output=True, text=True, timeout=30 + ) + assert result.returncode == 0, result.stderr + data = json.loads(result.stdout) + assert data["usOnly"] == ["all", "us_stocks"] + assert data["mixed"] == ["all", "us_stocks", "cn_ashares"] + assert data["empty"] == ["all"] + assert data["unknown"] == ["all"] + assert data["usLabels"] == ["All", "U.S."] + + @pytest.mark.skipif(shutil.which("node") is None, reason="node is not installed") def test_shipped_filter_ands_market_and_vendor_not_or(): - """Lifts the REAL getFilteredMarketplaceTemplates (not a reimplementation) -- - the six chip/empty-state tests above never execute this function, so a - regression that drops the vendor filter or ORs it with the market filter - passed the whole suite until this test existed.""" + """Vendor chips are gone; the market filter still applies to both shelves.""" script = f""" -{js_const("MODEL_VENDORS")} -{fn_body("function modelVendorKey")} const document = {{ getElementById: () => null }}; const marketplaceTemplates = [ {{ template_id: 't1', category: 'us_stocks', model_name: 'anthropic/claude-haiku-4-5' }}, @@ -245,25 +234,18 @@ def test_shipped_filter_ands_market_and_vendor_not_or(): {{ template_id: 't5', category: 'us_stocks', model_name: 'totally/unknown' }}, ]; let marketplaceCategoryFilter = 'all'; -let marketplaceVendorFilter = 'all'; {fn_body("function getFilteredMarketplaceTemplates")} function ids() {{ return getFilteredMarketplaceTemplates().map((t) => t.template_id); }} const results = {{}}; -marketplaceCategoryFilter = 'us_stocks'; marketplaceVendorFilter = 'all'; -results.marketOnly = ids(); - -marketplaceCategoryFilter = 'all'; marketplaceVendorFilter = 'qwen'; -results.vendorOnly = ids(); - -marketplaceCategoryFilter = 'us_stocks'; marketplaceVendorFilter = 'qwen'; -results.both = ids(); - -marketplaceCategoryFilter = 'all'; marketplaceVendorFilter = 'anthropic'; -results.vendorExplicit = ids(); - +marketplaceCategoryFilter = 'us_stocks'; +results.marketUs = ids(); +marketplaceCategoryFilter = 'cn_ashares'; +results.marketCn = ids(); +marketplaceCategoryFilter = 'all'; +results.marketAll = ids(); console.log(JSON.stringify(results)); """ result = subprocess.run( @@ -271,233 +253,172 @@ def test_shipped_filter_ands_market_and_vendor_not_or(): ) assert result.returncode == 0, result.stderr data = json.loads(result.stdout) - # Market filter alone: both known-vendor templates plus the unknown-vendor - # one -- unknown must stay visible when no vendor chip narrows it. - assert set(data["marketOnly"]) == {"t1", "t2", "t5"} - # Vendor filter alone: both markets, only the matching vendor. - assert set(data["vendorOnly"]) == {"t2", "t4"} - # Both together must be the INTERSECTION (t2 only), not the union - # (which would also include t1, t4, t5). - assert data["both"] == ["t2"], "market+vendor must AND, not OR" - # An explicit vendor chip excludes the unknown-vendor template -- it is - # visible only under vendor 'all'. - assert set(data["vendorExplicit"]) == {"t1", "t3"} - - -@pytest.mark.skipif(shutil.which("node") is None, reason="node is not installed") -def test_shipped_vendor_chip_order_follows_model_vendors_not_catalog_order(): - """Lifts the REAL renderMarketplaceVendorChips against a synthetic DOM and a - catalog whose insertion order is deliberately scrambled relative to - MODEL_VENDORS. test_vendor_chips_are_derived_not_hardcoded only substring - -checks the function's source text, so it never actually executes this and - can't tell catalog order from MODEL_VENDORS order.""" - script = f""" -function escapeHtml(s) {{ return String(s); }} -{js_const("MODEL_VENDORS")} -{fn_body("function modelVendorKey")} - -function makeContainer() {{ - let buttons = []; - return {{ - querySelectorAll() {{ return buttons; }}, - set innerHTML(html) {{ - buttons = []; - const re = /data-marketplace-vendor="([^"]*)"/g; - let m; - while ((m = re.exec(html))) {{ - buttons.push({{ - dataset: {{ marketplaceVendor: m[1] }}, - classList: {{ toggle() {{}} }}, - setAttribute() {{}}, - }}); - }} - }}, - }}; -}} -const container = makeContainer(); -const document = {{ getElementById: (id) => (id === 'marketplaceVendorChips' ? container : null) }}; - -// MODEL_VENDORS order is anthropic, openai, google, deepseek, qwen, nvidia, -// meta, xai. This catalog is inserted qwen, anthropic, deepseek -- scrambled -// on purpose -- plus one unknown-vendor template and no openai template. -const marketplaceTemplates = [ - {{ template_id: 'a', model_name: 'qwen/qwen3.7-plus' }}, - {{ template_id: 'b', model_name: 'anthropic/claude-haiku-4-5' }}, - {{ template_id: 'c', model_name: 'deepseek/deepseek-v4-pro' }}, - {{ template_id: 'd', model_name: 'totally/unknown' }}, -]; -let marketplaceVendorFilter = 'all'; - -{fn_body("function renderMarketplaceVendorChips")} -renderMarketplaceVendorChips(); - -console.log(JSON.stringify(container.querySelectorAll().map((b) => b.dataset.marketplaceVendor))); -""" - result = subprocess.run( - ["node", "-e", script], capture_output=True, text=True, timeout=30 - ) - assert result.returncode == 0, result.stderr - keys = json.loads(result.stdout) - # 'openai' has no template so it must not get a chip; order must follow - # MODEL_VENDORS (anthropic, deepseek, qwen), not the catalog's insertion - # order (qwen, anthropic, deepseek). - assert keys == ["all", "anthropic", "deepseek", "qwen"] + assert set(data["marketUs"]) == {"t1", "t2", "t5"} + assert set(data["marketCn"]) == {"t3", "t4"} + assert set(data["marketAll"]) == {"t1", "t2", "t3", "t4", "t5"} def test_only_open_weight_models_get_a_badge(): - """Closed models get NOTHING. A "Closed" label reads as a warning about - someone else's product; absence is not a negative claim.""" - grid = fn_body("function renderMarketplaceGrid") - assert "modelVendorLicence" in grid - assert "Open-source model" in grid + """LLM tiles no longer claim licence. Hosted repo cards use an explicit + Open Source mark; closed models and strategy templates get nothing.""" + card = fn_body("function buildMarketplaceCardHtml") + assert "Open Source" in card + assert "Open-source model" not in card + assert ">Open" not in card assert "Closed-source" not in APP_JS assert "Proprietary" not in APP_JS -def test_licence_badge_has_a_style_rule(): - from dashboard.backend.tests._frontend_source import css_blocks - - assert css_blocks(".marketplace-licence-badge"), "badge has no styles.css rule" - - -@pytest.mark.skipif(shutil.which("node") is None, reason="node is not installed") -def test_shipped_grid_badges_only_open_weight_cards(): - """Lifts the REAL renderMarketplaceGrid (not a reimplementation) against a - synthetic catalog with one open-weight, one closed-weight and one - unknown-vendor template. The two tests above only substring-check - renderMarketplaceGrid's source text, so a polarity bug (badging closed - models instead of open ones) or a "computed but never rendered" bug - (licenceBadge assigned but not interpolated into the tag row) would both - pass them silently. This test executes the shipped card template and - checks the actual HTML each template produces.""" - script = f""" +_CARD_HELPERS = f""" {fn_body("function escapeHtml")} {fn_body("function agentRobotIcon")} const MARKET_LABELS = {{ us_stocks: 'U.S.', cn_ashares: 'China A-Share' }}; {js_const("MODEL_VENDORS")} {fn_body("function modelVendorKey")} -{fn_body("function modelVendorLicence")} -{fn_body("function formatModelProviderLabel")} - -// Collaborators renderMarketplaceGrid calls that render other UI regions -- -// stubbed as no-ops so this test stays scoped to the card template. -function renderMarketplaceCategoryChips() {{}} -function renderMarketplaceVendorChips() {{}} - -function getFilteredMarketplaceTemplates() {{ - return [ - {{ template_id: 'open1', category: 'us_stocks', name: 'Open Template', - model_name: 'deepseek/deepseek-v4-pro', tags: [], author: 'Community' }}, - {{ template_id: 'closed1', category: 'us_stocks', name: 'Closed Template', - model_name: 'anthropic/claude-haiku-4-5', tags: [], author: 'Community' }}, - {{ template_id: 'unknown1', category: 'us_stocks', name: 'Unknown Template', - model_name: 'totally/unknown', tags: [], author: 'Community' }}, - ]; -}} - -const cardHtml = []; -const grid = {{ - innerHTML: '', - appendChild(card) {{ cardHtml.push(card.innerHTML); }}, - querySelectorAll() {{ return []; }}, -}}; -const document = {{ - getElementById(id) {{ return id === 'marketplaceGrid' ? grid : null; }}, - createElement() {{ return {{ className: '', innerHTML: '' }}; }}, -}}; +{fn_body("function formatModelCompanyLabel")} +let marketplaceLeaderboardEntries = []; +let marketplaceContestMeta = {{ start_date: null, end_date: null, display_capital: null, total_entries: null }}; +{fn_body("function templateMarketplaceShelf")} +{fn_body("function findMarketplaceLeaderboardEntry")} +{js_const("MARKETPLACE_MONTHS")} +{fn_body("function marketplaceBenchmarkEntry")} +{fn_body("function downsampleMarketplaceCurve")} +{fn_body("function marketplaceIndexedPctSeries")} +{fn_body("function formatMarketplaceMd")} +{fn_body("function formatMarketplaceWindowRange")} +{fn_body("function formatMarketplaceCapital")} +{fn_body("function marketplaceNicePctTicks")} +{fn_body("function marketplaceLinePath")} +{fn_body("function buildMarketplaceCompareChartHtml")} +{fn_body("function marketplacePerformanceFor")} +{fn_body("function formatMarketplaceReturnPct")} +{fn_body("function marketplaceRepoLabel")} +{fn_body("function compareMarketplaceTemplatesByRank")} +{fn_body("function buildMarketplaceCardHtml")} +""" -{fn_body("function renderMarketplaceGrid")} -renderMarketplaceGrid(); -console.log(JSON.stringify(cardHtml.map((html) => html.includes('marketplace-licence-badge')))); +@pytest.mark.skipif(shutil.which("node") is None, reason="node is not installed") +def test_shipped_grid_badges_only_open_weight_cards(): + """Open Source is a repo-card mark, not an Agents-shelf or open-weight claim.""" + script = f""" +{_CARD_HELPERS} +const cards = [ + buildMarketplaceCardHtml({{ template_id: 'llm', shelf: 'llms', category: 'us_stocks', + name: 'DeepSeek V4 Pro', model_name: 'deepseek/deepseek-v4-pro' }}), + buildMarketplaceCardHtml({{ template_id: 'llm2', shelf: 'llms', category: 'us_stocks', + name: 'Claude Haiku 4.5', model_name: 'anthropic/claude-haiku-4-5' }}), + buildMarketplaceCardHtml({{ template_id: 'ai-hedge-fund', shelf: 'open', category: 'us_stocks', + name: 'AI Hedge Fund', model_name: 'nvidia/nemotron-3-nano-30b-a3b', + card_subtitle: 'Open-source multi-agent system', + repo_url: 'https://github.com/virattt/ai-hedge-fund' }}), + buildMarketplaceCardHtml({{ template_id: 'balanced-starter', shelf: 'open', category: 'us_stocks', + name: 'Balanced Starter', model_name: 'anthropic/claude-haiku-4-5', + description: 'A simple starter agent that diversifies across strong stocks.' }}), +]; +console.log(JSON.stringify(cards.map((html) => html.includes('Open Source')))); """ result = subprocess.run( ["node", "-e", script], capture_output=True, text=True, timeout=30 ) assert result.returncode == 0, result.stderr - # In catalog order: open-weight DeepSeek, closed-weight Claude, unknown vendor. - assert json.loads(result.stdout) == [True, False, False] + assert json.loads(result.stdout) == [False, False, True, False] @pytest.mark.skipif(shutil.which("node") is None, reason="node is not installed") def test_closed_card_differs_from_open_card_by_exactly_the_badge(): - """"Absence is not a negative claim" is a stronger promise than "no badge - with this exact class and text" -- the two tests above enumerate two - guessed forbidden strings ("Closed-source", "Proprietary"), which cannot - rule out a differently-worded, differently-classed marker on closed cards - (e.g. a `.marketplace-vendor-note` span reading "Vendor-locked model"). - - This renders the REAL renderMarketplaceGrid twice, over two templates that - are identical except for `model_name` (one open-weight, one closed-weight), - and diffs the emitted HTML directly: stripping the open-source badge out of - the open card's HTML must yield the closed card's HTML exactly, modulo the - one legitimately-differing "Powered by X" label. Any extra marker on the - closed card -- of any name or wording -- breaks that equality. - """ + """Two LLM cards that differ only by model_name must differ only by company.""" script = f""" -{fn_body("function escapeHtml")} -{fn_body("function agentRobotIcon")} -const MARKET_LABELS = {{ us_stocks: 'U.S.', cn_ashares: 'China A-Share' }}; -{js_const("MODEL_VENDORS")} -{fn_body("function modelVendorKey")} -{fn_body("function modelVendorLicence")} -{fn_body("function formatModelProviderLabel")} - -function renderMarketplaceCategoryChips() {{}} -function renderMarketplaceVendorChips() {{}} - -let currentTemplates; -function getFilteredMarketplaceTemplates() {{ return currentTemplates; }} -let document; - -{fn_body("function renderMarketplaceGrid")} - +{_CARD_HELPERS} function renderOneCard(modelName) {{ - const cardHtml = []; - const grid = {{ - innerHTML: '', - appendChild(card) {{ cardHtml.push(card.innerHTML); }}, - querySelectorAll() {{ return []; }}, - }}; - document = {{ - getElementById(id) {{ return id === 'marketplaceGrid' ? grid : null; }}, - createElement() {{ return {{ className: '', innerHTML: '' }}; }}, - }}; - // Identical in every field except model_name -- the only legitimate - // difference between the two cards is the licence and the model label. - // A shared tag keeps the tag-row wrapper div present on BOTH cards, so the - // only thing that can differ inside it is the badge span itself -- with no - // tags, the wrapper disappears entirely on the closed card (no badge, no - // tags -> nothing to wrap) and that wrapper's absence would itself count as - // a "difference", masking whether an extra marker was also added. - currentTemplates = [ - {{ template_id: 'x', category: 'us_stocks', name: 'Same Template', - model_name: modelName, tags: ['sample'], author: 'Community' }}, - ]; - renderMarketplaceGrid(); - return cardHtml[0]; + return buildMarketplaceCardHtml({{ + template_id: 'x', shelf: 'llms', category: 'us_stocks', + name: 'Same Template', model_name: modelName, + }}); }} - const openHtml = renderOneCard('deepseek/deepseek-v4-pro'); const closedHtml = renderOneCard('anthropic/claude-haiku-4-5'); +const openNorm = openHtml.split('DeepSeek').join('COMPANY'); +const closedNorm = closedHtml.split('Anthropic').join('COMPANY'); +console.log(JSON.stringify({{ + openHasBadge: openHtml.includes('Open Source'), + closedHasBadge: closedHtml.includes('Open Source'), + equalAfterNormalizing: openNorm === closedNorm, +}})); +""" + result = subprocess.run( + ["node", "-e", script], capture_output=True, text=True, timeout=30 + ) + assert result.returncode == 0, result.stderr + data = json.loads(result.stdout) + assert data["openHasBadge"] is False + assert data["closedHasBadge"] is False + assert data["equalAfterNormalizing"] is True -const BADGE = 'Open-source model'; -const openHasBadge = openHtml.includes(BADGE); -const closedHasBadge = closedHtml.includes('marketplace-licence-badge'); -// Strip only the exact badge span (proves it was actually there) and -// normalise only the one known-legitimate difference (the model label) -- -// a blanket strip would hide any other marker instead of catching it. -// Global replace: the label is in both the submeta title attribute and the -// visible text; String.replace(string) would only hit the first. -const openWithoutBadge = openHtml.split(BADGE).join('') - .split('Powered by DeepSeek').join('POWERED_BY_MODEL'); -const closedNormalized = closedHtml - .split('Powered by Claude').join('POWERED_BY_MODEL'); +@pytest.mark.skipif(shutil.which("node") is None, reason="node is not installed") +def test_card_uses_overall_rank_and_return_without_placeholders(): + """Contest stats: trophy rank, Return, model vs DJIA cumulative-return chart.""" + script = f""" +{_CARD_HELPERS} +marketplaceLeaderboardEntries = [ + {{ is_model: true, model: 'Claude Haiku 4.5', entry_id: 'claude_haiku_4_5', + rank: 2, cumulative_return: 0.084, + equity_curve: [ + {{ timestamp: '2026-04-15T00:00:00+00:00', equity: 10000 }}, + {{ timestamp: '2026-04-30T00:00:00+00:00', equity: 10400 }}, + {{ timestamp: '2026-05-15T00:00:00+00:00', equity: 10840 }} + ] }}, + {{ is_model: false, model: 'DJIA', entry_id: 'djia_index', rank: 8, + cumulative_return: 0.022, + equity_curve: [ + {{ timestamp: '2026-04-15T00:00:00+00:00', equity: 10000 }}, + {{ timestamp: '2026-04-30T00:00:00+00:00', equity: 10100 }}, + {{ timestamp: '2026-05-15T00:00:00+00:00', equity: 10224 }} + ] }}, +]; +marketplaceContestMeta = {{ + start_date: '2026-04-15', end_date: '2026-05-15', + display_capital: 10000, total_entries: 12, +}}; +const ranked = buildMarketplaceCardHtml({{ + template_id: 'claude-haiku-4-5', shelf: 'llms', category: 'us_stocks', + name: 'Claude Haiku 4.5', model_name: 'anthropic/claude-haiku-4-5', +}}); +const unranked = buildMarketplaceCardHtml({{ + template_id: 'ai-hedge-fund', shelf: 'open', category: 'us_stocks', + name: 'AI Hedge Fund', model_name: 'nvidia/nemotron-3-nano-30b-a3b', + card_subtitle: 'Open-source multi-agent system', + description: 'A team of AI investors that analyzes the market, develops trading ideas, and tests them through backtesting.', + repo_url: 'https://github.com/virattt/ai-hedge-fund', +}}); console.log(JSON.stringify({{ - openHasBadge, - closedHasBadge, - equalAfterNormalizing: openWithoutBadge === closedNormalized, + rankedHasMedian: ranked.includes('Median Return'), + rankedHasLeaderboardReturn: ranked.includes('Leaderboard Return'), + rankedHasOverallRank: ranked.includes('Overall Rank'), + rankedHasCompetition: ranked.includes('Competition result'), + rankedHasTotalReturn: ranked.includes('Total Return'), + rankedHasReturnLabel: ranked.includes('>Return<'), + rankedHasHashTwo: ranked.includes('#2 of 12'), + rankedHasPct: ranked.includes('+8.4%'), + rankedHasChart: ranked.includes('mp-compare-chart'), + rankedHasAgentLegend: ranked.includes('Claude Haiku 4.5'), + rankedHasDjiaLegend: ranked.includes('>DJIA<') || ranked.includes('DJIA'), + rankedHasDjiaBenchmark: ranked.includes('DJIA Benchmark'), + rankedHasAgentPortfolio: ranked.includes('Agent Portfolio'), + rankedHasMeta: ranked.includes('DJIA 30'), + rankedHasWindow: ranked.includes('Apr 15–May 15'), + rankedHasCapital: ranked.includes('$10K'), + rankedHasHead: ranked.includes('mp-competition-head'), + rankedMetaBeforeBody: ranked.indexOf('DJIA 30') < ranked.indexOf('mp-competition-body') + && ranked.indexOf('Competition result') < ranked.indexOf('mp-competition-body'), + rankedHasComingSoon: ranked.includes('Performance data coming soon'), + unrankedHasCompetition: unranked.includes('Competition result'), + unrankedHasSpark: unranked.includes('mp-compare-chart'), + unrankedHasRepo: unranked.includes('virattt/ai-hedge-fund'), + unrankedHasSubtitle: unranked.includes('Open-source multi-agent system'), + unrankedHasOverallRank: unranked.includes('Overall Rank'), + unrankedHasDescription: unranked.includes('A team of AI investors that analyzes the market'), }})); """ result = subprocess.run( @@ -505,27 +426,92 @@ def test_closed_card_differs_from_open_card_by_exactly_the_badge(): ) assert result.returncode == 0, result.stderr data = json.loads(result.stdout) - assert data["openHasBadge"] is True - assert data["closedHasBadge"] is False - assert data["equalAfterNormalizing"] is True, ( - "closed card must be byte-identical to the open card minus the badge " - "(modulo the model label) -- any other marker on the closed card is a " - "negative claim about someone else's product" + assert data["rankedHasMedian"] is False + assert data["rankedHasLeaderboardReturn"] is False + assert data["rankedHasOverallRank"] is False + assert data["rankedHasCompetition"] is True + assert data["rankedHasTotalReturn"] is False + assert data["rankedHasReturnLabel"] is True + assert data["rankedHasHashTwo"] is True + assert data["rankedHasPct"] is True + assert data["rankedHasChart"] is True + assert data["rankedHasAgentLegend"] is True + assert data["rankedHasDjiaLegend"] is True + assert data["rankedHasDjiaBenchmark"] is False + assert data["rankedHasAgentPortfolio"] is False + assert data["rankedHasMeta"] is True + assert data["rankedHasWindow"] is True + assert data["rankedHasCapital"] is True + assert data["rankedHasHead"] is True + assert data["rankedMetaBeforeBody"] is True + assert data["rankedHasComingSoon"] is False + assert data["unrankedHasCompetition"] is False + assert data["unrankedHasSpark"] is False + assert data["unrankedHasRepo"] is True + assert data["unrankedHasSubtitle"] is True + assert data["unrankedHasOverallRank"] is False + assert data["unrankedHasDescription"] is True + + +@pytest.mark.skipif(shutil.which("node") is None, reason="node is not installed") +def test_llm_shelf_sorts_by_leaderboard_rank(): + script = f""" +{_CARD_HELPERS} +marketplaceLeaderboardEntries = [ + {{ is_model: true, model: 'Claude Haiku 4.5', rank: 11, cumulative_return: 0.0 }}, + {{ is_model: true, model: 'DeepSeek V4 Pro', rank: 1, cumulative_return: 0.07 }}, + {{ is_model: true, model: 'Qwen3.7 Plus', rank: 5, cumulative_return: 0.02 }}, +]; +const cards = [ + {{ template_id: 'claude-haiku-4-5', name: 'Claude Haiku 4.5' }}, + {{ template_id: 'deepseek-v4-pro', name: 'DeepSeek V4 Pro' }}, + {{ template_id: 'qwen3-7-plus', name: 'Qwen3.7 Plus' }}, + {{ template_id: 'unknown-llm', name: 'Unlisted' }}, +]; +const sorted = cards.slice().sort(compareMarketplaceTemplatesByRank).map((t) => t.template_id); +console.log(JSON.stringify(sorted)); +""" + result = subprocess.run( + ["node", "-e", script], capture_output=True, text=True, timeout=30 ) + assert result.returncode == 0, result.stderr + assert json.loads(result.stdout) == [ + "deepseek-v4-pro", + "qwen3-7-plus", + "claude-haiku-4-5", + "unknown-llm", + ] + + +def test_marketplace_card_has_no_fixed_min_height(): + from dashboard.backend.tests._frontend_source import css_blocks + + blocks = css_blocks(".marketplace-card") + assert blocks, "marketplace-card has no styles.css rule" + assert all("min-height" not in block for block in blocks) + + +def test_llm_grid_sorts_by_leaderboard_rank(): + grid = fn_body("function renderMarketplaceGrid") + assert "compareMarketplaceTemplatesByRank" in grid + assert "shelf.key === 'llms'" in grid def test_primary_clone_cta_is_unchanged(): """The conversion click keeps its label and its one-click behaviour.""" - grid = fn_body("function renderMarketplaceGrid") - assert "const cloneLabel = 'Add to My Agents';" in grid - assert "marketplace-clone-btn" in grid + card = fn_body("function buildMarketplaceCardHtml") + assert "const cloneLabel = 'Add to My Agents';" in card + assert "marketplace-clone-btn" in card -def test_model_choice_is_a_secondary_affordance(): +def test_model_choice_is_not_on_the_card(): + """Tiles are the model. Switching models happens in Configure after Add.""" + card = fn_body("function buildMarketplaceCardHtml") grid = fn_body("function renderMarketplaceGrid") - assert "marketplace-clone-model-btn" in grid - assert "Choose model" in grid - assert "SUPPORTED_MODELS" in grid + for body in (card, grid): + assert "marketplace-clone-model-btn" not in body + assert "Choose model" not in body + assert "marketplace-model-menu" not in body def test_clone_sends_the_chosen_model(): @@ -533,71 +519,22 @@ def test_clone_sends_the_chosen_model(): assert "model_name" in body -def test_clone_menu_changes_only_the_model(): - """A second half-Configure inside a clone menu is how two editing surfaces - start drifting apart. Name, capital and pipeline stay in Configure.""" - grid = fn_body("function renderMarketplaceGrid") - menu_start = grid.index("marketplace-model-menu") - menu = grid[menu_start : menu_start + 800] - for forbidden in ("cash_allocation", "backtest_allocation", "pipeline", "rename"): - assert forbidden not in menu - - @pytest.mark.skipif(shutil.which("node") is None, reason="node is not installed") def test_model_picker_gated_on_runtime_type_not_truthiness(): - """`runtime_type` is always present and always truthy -- server-defaulted - to 'pipeline' for every ordinary template (marketplace.py: - `str(raw.get("runtime_type") or "pipeline")`). A gate written as - `template.runtime_type ? '' : (...)` is therefore false for EVERY - template and would hide the picker everywhere while looking correct on a - substring-only test. The AI Hedge Fund runtime hardcodes its own model - (infrastructure/ai_hedge_fund/adapter.py) and never reads the stored - value, so offering a picker there would let a user "choose" a model that - is silently ignored. - - This executes the REAL renderMarketplaceGrid over one ordinary - (runtime_type: 'pipeline') template and one hosted (runtime_type: - 'ai_hedge_fund') template and checks which cards actually get a picker.""" + """Neither shelf card offers Choose model; both keep Add to My Agents.""" script = f""" -{fn_body("function escapeHtml")} -{fn_body("function agentRobotIcon")} -const MARKET_LABELS = {{ us_stocks: 'U.S.' }}; -{js_const("MODEL_VENDORS")} -{js_const("SUPPORTED_MODELS")} -{fn_body("function modelVendorKey")} -{fn_body("function modelVendorLicence")} -{fn_body("function formatModelProviderLabel")} -{fn_body("function normalizeBacktestModelId")} - -function renderMarketplaceCategoryChips() {{}} -function renderMarketplaceVendorChips() {{}} - -const marketplaceTemplates = [ - {{ template_id: 'ordinary', category: 'us_stocks', name: 'Ordinary Template', - model_name: 'anthropic/claude-haiku-4-5', tags: [], author: 'Community', - runtime_type: 'pipeline' }}, - {{ template_id: 'hosted', category: 'us_stocks', name: 'Hosted Template', - model_name: 'nvidia/nemotron-3-nano-30b-a3b', tags: [], author: 'Community', - runtime_type: 'ai_hedge_fund' }}, -]; -function getFilteredMarketplaceTemplates() {{ return marketplaceTemplates; }} -let marketplaceCloneInFlight = false; - -const cardHtml = []; -const grid = {{ - innerHTML: '', - appendChild(card) {{ cardHtml.push(card.innerHTML); }}, - querySelectorAll() {{ return []; }}, -}}; -const document = {{ - getElementById(id) {{ return id === 'marketplaceGrid' ? grid : null; }}, - createElement() {{ return {{ className: '', innerHTML: '' }}; }}, -}}; - -{fn_body("function renderMarketplaceGrid")} -renderMarketplaceGrid(); - -console.log(JSON.stringify(cardHtml.map((html) => ({{ +{_CARD_HELPERS} +const ordinary = buildMarketplaceCardHtml({{ + template_id: 'ordinary', shelf: 'llms', category: 'us_stocks', + name: 'Ordinary Template', model_name: 'anthropic/claude-haiku-4-5', + runtime_type: 'pipeline', +}}); +const hosted = buildMarketplaceCardHtml({{ + template_id: 'hosted', shelf: 'open', category: 'us_stocks', + name: 'Hosted Template', model_name: 'nvidia/nemotron-3-nano-30b-a3b', + runtime_type: 'ai_hedge_fund', +}}); +console.log(JSON.stringify([ordinary, hosted].map((html) => ({{ hasModelBtn: html.includes('marketplace-clone-model-btn'), hasModelMenu: html.includes('marketplace-model-menu'), hasPrimaryBtn: html.includes('marketplace-clone-btn'), @@ -608,13 +545,9 @@ def test_model_picker_gated_on_runtime_type_not_truthiness(): ) assert result.returncode == 0, result.stderr ordinary, hosted = json.loads(result.stdout) - # Ordinary template: both the picker button AND its menu render. - assert ordinary["hasModelBtn"] is True - assert ordinary["hasModelMenu"] is True + assert ordinary["hasModelBtn"] is False + assert ordinary["hasModelMenu"] is False assert ordinary["hasPrimaryBtn"] is True - # Hosted template: the primary "Add to My Agents" CTA still renders, but - # neither the picker button nor its menu markup does -- a hidden button - # with live menu markup is dead weight, not a fix. assert hosted["hasModelBtn"] is False assert hosted["hasModelMenu"] is False assert hosted["hasPrimaryBtn"] is True @@ -780,17 +713,12 @@ def test_duplicate_does_not_start_a_backtest(): assert forbidden not in body -def test_entering_community_resets_the_vendor_filter(): - """A vendor left selected on one visit must not leak into the next. - - `marketplaceCategoryFilter` already resets here, under a comment explaining - exactly this hazard. The vendor filter was added later and initially did not, - so returning to Community via the nav tab stayed filtered -- and the My Agents - empty-shelf deep link (which rides in with a category) then ANDed against the - stale vendor and landed the user on an empty grid. +def test_entering_community_resets_the_category_filter(): + """A chip left selected on one visit must not leak into the next. - Scoped to the `page === 'community'` branch on purpose: a reset anywhere else - in the function would not fix the leak, so it must not satisfy this guard. + Scoped to the `page === 'community'` branch on purpose: a reset anywhere + else in the function would not fix the leak, so it must not satisfy this + guard. (The vendor filter that used to reset here left with the chip row.) """ body = fn_body("function navigateToPage") start = body.index("page === 'community'") @@ -798,11 +726,6 @@ def test_entering_community_resets_the_vendor_filter(): assert re.search(r"marketplaceCategoryFilter\s*=", branch), ( "the category reset vanished from the community branch" ) - assert re.search(r"marketplaceVendorFilter\s*=\s*'all'", branch), ( - "entering Community must reset marketplaceVendorFilter to 'all'; " - "without it the vendor chip leaks across visits and strands the " - "empty-shelf deep links on an empty grid" - ) @pytest.mark.skipif(shutil.which("node") is None, reason="node is not installed") @@ -829,13 +752,3 @@ def test_duplicate_name_never_exceeds_the_backend_cap(): assert length <= 100, f"generated a {length}-char name; backend caps at 100" assert ends_with_vendor, "the vendor suffix was trimmed instead of the base name" - -def test_model_picker_accessible_name_contains_its_visible_label(): - """WCAG 2.5.3 Label in Name (Level A): a voice-control user saying - "click Choose model" must be able to activate the button.""" - grid = fn_body("function renderMarketplaceGrid") - match = re.search(r'aria-label="([^"]*)"[^>]*>Choose model', grid) - assert match, "the model-picker button lost its aria-label or its visible text" - assert match.group(1).lower().startswith("choose model"), ( - f"accessible name {match.group(1)!r} does not contain the visible label 'Choose model'" - ) diff --git a/dashboard/backend/tests/test_frontend_shelves.py b/dashboard/backend/tests/test_frontend_shelves.py index cf0ca2f5..a8619e16 100644 --- a/dashboard/backend/tests/test_frontend_shelves.py +++ b/dashboard/backend/tests/test_frontend_shelves.py @@ -248,18 +248,34 @@ def test_my_agents_card_submeta_drops_duplicate_model_and_hosted_ai(): def test_render_marketplace_category_chips_is_built_from_the_shared_label_map(): - """The chip row is built from MARKET_LABELS rather than a second hardcoded - list, plus an 'all' chip that isn't a category at all. It is no longer built - from AGENT_SHELVES: Community filters templates by *market*, and - Prompted Models holds both markets, so the shelf list and the chip - list are different things -- built from AGENT_SHELVES this row would emit a - single, meaningless "Prompted Models" chip that matches no template. - """ - body = _strip_js_comments(fn_body("function renderMarketplaceCategoryChips()")) - assert "MARKET_LABELS" in body - assert "'all'" in body + """The chip row reads labels from MARKET_LABELS rather than a second + hardcoded list, plus an 'all' chip that isn't a category at all. Membership + is catalog-derived (marketplaceMarketChips) so an empty-market chip cannot + ship. It is no longer built from AGENT_SHELVES: Community filters templates + by *market*, and Prompted Models holds both markets, so the shelf list and + the chip list are different things -- built from AGENT_SHELVES this row + would emit a single, meaningless "Prompted Models" chip that matches no + template. + """ + renderer = _strip_js_comments(fn_body("function renderMarketplaceCategoryChips()")) + helper = _strip_js_comments(fn_body("function marketplaceMarketChips(")) + assert "marketplaceMarketChips(marketplaceTemplates)" in renderer + assert "MARKET_LABELS" in helper + assert "'all'" in helper + assert "present.has(key)" in helper for label in ("U.S.", "China A-Share"): - assert label not in body, f"{label!r} hardcoded instead of read from MARKET_LABELS" + assert label not in helper, f"{label!r} hardcoded instead of read from MARKET_LABELS" + assert label not in renderer, f"{label!r} hardcoded instead of read from MARKET_LABELS" + + +def test_supermarket_second_shelf_is_titled_agents(): + """Community's second row is Agents, not Open Agents. My Agents keeps its + own Open Agents title -- that surface is hosted runtimes only. + """ + decl = js_const("MARKETPLACE_SHELVES") + assert "title: 'Agents'" in decl + assert "title: 'Open Agents'" not in decl + assert "Ready-made trading agents" in decl def test_navigate_to_page_resets_chip_filter_on_plain_community_entry(): @@ -382,7 +398,7 @@ def test_copy_to_my_agents_cta_is_gone(): PR #253 made "Add to My Agents" canonical everywhere else, so this one holdout ternary must go, not gain a permanent sibling. """ - body = _strip_js_comments(fn_body(_MARKETPLACE_RENDER_FN)) + body = _strip_js_comments(fn_body("function buildMarketplaceCardHtml")) assert "Copy to My Agents" not in body @@ -392,7 +408,7 @@ def test_add_to_my_agents_cta_is_a_single_unconditional_string(): apart again tomorrow. Assert the direct, unconditional assignment and that the now-dead branch variable is gone with it. """ - body = _strip_js_comments(fn_body(_MARKETPLACE_RENDER_FN)) + body = _strip_js_comments(fn_body("function buildMarketplaceCardHtml")) assert "cloneLabel = 'Add to My Agents'" in body assert "isAiHedgeFundTemplate" not in body @@ -405,7 +421,7 @@ def test_marketplace_submeta_never_renders_a_raw_category_or_model_slug(): 'anthropic/'/'nvidia/' prefix strings elsewhere in the function, so a whole-function check would false-positive on the table doing its job. """ - body = _strip_js_comments(fn_body(_MARKETPLACE_RENDER_FN)) + body = _strip_js_comments(fn_body("function buildMarketplaceCardHtml")) submeta_line = next(line for line in body.splitlines() if "agent-card-submeta" in line) assert "template.category" not in submeta_line assert "template.model_name" not in submeta_line @@ -413,9 +429,12 @@ def test_marketplace_submeta_never_renders_a_raw_category_or_model_slug(): def test_fallback_description_copy_is_updated(): - body = _strip_js_comments(fn_body(_MARKETPLACE_RENDER_FN)) + """LLM tiles stay description-free. Open Agents may show catalog copy. + Old fallback strings must stay gone.""" + body = _strip_js_comments(fn_body("function buildMarketplaceCardHtml")) assert "Open agent template." not in body - assert "No description provided yet." in body + assert "No description provided yet." not in body + assert "marketplace-card-description" in body def test_marketplace_category_chip_container_is_present_in_community_view(): @@ -458,7 +477,12 @@ def test_community_page_carries_the_no_real_money_sentence_once(): the brief says "once per page"). """ community_html = _community_view_html() - assert community_html.count(_CANONICAL_NO_REAL_MONEY_SENTENCE) == 1 + shortened = ( + "Performance is based on simulated tests and may vary between runs. " + "Live trading requires an explicitly connected brokerage account." + ) + assert community_html.count(shortened) == 1 + assert _CANONICAL_NO_REAL_MONEY_SENTENCE not in community_html def _strip_js_comments_from(source: str) -> str: diff --git a/dashboard/backend/tests/test_marketplace_catalog_models.py b/dashboard/backend/tests/test_marketplace_catalog_models.py index 8e04a3cf..9baec802 100644 --- a/dashboard/backend/tests/test_marketplace_catalog_models.py +++ b/dashboard/backend/tests/test_marketplace_catalog_models.py @@ -21,32 +21,143 @@ _SUPPORTED_SLUGS = set(re.findall(r"slug:\s*'([^']+)'", js_const("SUPPORTED_MODELS"))) -_EXPECTED_NEW = { - "contrarian-dip-buyer": ("openai/gpt-5.5", "us_stocks"), - "sector-rotator": ("google/gemini-3.1-pro-preview", "us_stocks"), - "volatility-guard": ("deepseek/deepseek-v4-pro", "us_stocks"), - "ashare-momentum-t1": ("qwen/qwen3.7-plus", "cn_ashares"), +# Nemotron is on the Competition Leaderboard via OpenRouter but is not in +# SUPPORTED_MODELS (the user-facing picker). The supermarket still ships a +# card for it so the catalog matches the board. +_LEADERBOARD_ONLY_SLUGS = {"nvidia/nemotron-3-nano-30b-a3b"} + +_EXPECTED_MODELS = { + "claude-haiku-4-5": ("anthropic/claude-haiku-4-5", "us_stocks"), + "claude-sonnet-4-6": ("anthropic/claude-sonnet-4-6", "us_stocks"), + "gpt-5-5": ("openai/gpt-5.5", "us_stocks"), + "gemini-3-1-pro-preview": ("google/gemini-3.1-pro-preview", "us_stocks"), + "deepseek-v4-pro": ("deepseek/deepseek-v4-pro", "us_stocks"), + "qwen3-7-plus": ("qwen/qwen3.7-plus", "us_stocks"), + "nemotron-3-nano-30b": ("nvidia/nemotron-3-nano-30b-a3b", "us_stocks"), } +_LEADERBOARD = json.loads( + (Path(__file__).resolve().parents[3] / "dashboard/config/leaderboard.json").read_text( + encoding="utf-8" + ) +) + @pytest.mark.parametrize("template", _CATALOG, ids=lambda t: t["template_id"]) def test_every_template_runs_a_supported_or_hosted_model(template): if template.get("runtime_type"): return # hosted runtime: its model is not user-selectable - assert template["model_name"] in _SUPPORTED_SLUGS, ( + assert template["model_name"] in (_SUPPORTED_SLUGS | _LEADERBOARD_ONLY_SLUGS), ( f"{template['template_id']} runs {template['model_name']!r}, " - "which is not in SUPPORTED_MODELS" + "which is not in SUPPORTED_MODELS or the leaderboard" ) -@pytest.mark.parametrize("template_id,expected", sorted(_EXPECTED_NEW.items())) -def test_new_templates_are_present_with_their_pairings(template_id, expected): +@pytest.mark.parametrize("template_id,expected", sorted(_EXPECTED_MODELS.items())) +def test_leaderboard_model_cards_are_present_with_their_pairings(template_id, expected): found = next((t for t in _CATALOG if t["template_id"] == template_id), None) assert found is not None, f"{template_id} missing from marketplace.json" assert (found["model_name"], found["category"]) == expected +def test_every_leaderboard_llm_has_a_supermarket_card(): + """The supermarket model cards are the board's llm_agent roster, by name. + + A new competition model that ships on the board without a card (or a card + that outlives its board entry) is otherwise invisible until someone notices. + AI Hedge Fund is a hosted runtime, not a board entry, and is excluded. + """ + board_names = { + entry["name"] + for entry in _LEADERBOARD["strategies"] + if entry.get("strategy") == "llm_agent" + } + catalog_names = { + template["name"] + for template in _CATALOG + if template.get("shelf") == "llms" + } + assert catalog_names == board_names + # The API payload exposes each entry's *model* string (service.py builds + # entries with "model", never "name"), and app.js joins cards to entries on + # it -- pin the join key itself, not just the config's display name. + board_models = { + entry["model"] + for entry in _LEADERBOARD["strategies"] + if entry.get("strategy") == "llm_agent" + } + assert catalog_names == board_models + + +def test_llm_template_ids_map_to_leaderboard_entry_ids(): + """app.js's fallback join is template_id with '-' -> '_' against entry_id. + + The primary join is the name/model string; this fallback exists for the day + those drift, so it must actually match -- 'gemini-3-1-pro' silently missed + 'gemini_3_1_pro_preview' until the template_id was renamed. + """ + board_ids = { + entry["id"] + for entry in _LEADERBOARD["strategies"] + if entry.get("strategy") == "llm_agent" + } + catalog_ids = { + template["template_id"].replace("-", "_") + for template in _CATALOG + if template.get("shelf") == "llms" + } + assert catalog_ids == board_ids + + +def test_llm_card_prompts_pin_the_default_starter_instruction(): + """The 7 LLM cards ship the starter instruction verbatim -- a third copy of + DEFAULT_STARTER_INSTRUCTION (defaults.py already mirrors it to app.js under + a pin test). Pin this copy too, so tuning the constant cannot silently + strand the cards on stale wording.""" + from dashboard.backend.domain.agents.defaults import DEFAULT_STARTER_INSTRUCTION + + for template in _CATALOG: + if template.get("shelf") != "llms": + continue + [step] = template["pipeline"] + assert step["prompt"] == DEFAULT_STARTER_INSTRUCTION, template["template_id"] + + +def test_restored_strategy_templates_sit_on_the_agents_shelf(): + restored = { + "balanced-starter", + "momentum-scout", + "pipeline-analyst", + "blue-chip-steady", + "even-split-dow", + "ashare-steady-t1", + "contrarian-dip-buyer", + "sector-rotator", + "volatility-guard", + "ashare-momentum-t1", + } + by_id = {template["template_id"]: template for template in _CATALOG} + assert restored <= set(by_id) + for template_id in restored: + assert by_id[template_id].get("shelf") == "open", template_id + + +def test_catalog_rows_declare_a_supermarket_shelf(): + shelves = {template["template_id"]: template.get("shelf") for template in _CATALOG} + assert shelves["ai-hedge-fund"] == "open" + for template_id in _EXPECTED_MODELS: + assert shelves[template_id] == "llms", template_id + + def test_catalog_covers_every_pickable_vendor(): """The facet is decorative if most of its chips are empty.""" vendors = {t["model_name"].split("/", 1)[0] for t in _CATALOG} assert {"anthropic", "openai", "google", "deepseek", "qwen"} <= vendors + + +def test_catalog_includes_both_markets(): + """Community chips follow the catalog. A-share templates are back on the + Agents shelf, so the China A-Share chip ships again without a hardcoded + chip list. + """ + assert {t.get("category") for t in _CATALOG} == {"us_stocks", "cn_ashares"} diff --git a/dashboard/config/marketplace.json b/dashboard/config/marketplace.json index 11e207b0..be95d9cb 100644 --- a/dashboard/config/marketplace.json +++ b/dashboard/config/marketplace.json @@ -1,12 +1,195 @@ { "templates": [ + { + "template_id": "claude-haiku-4-5", + "shelf": "llms", + "name": "Claude Haiku 4.5", + "model_name": "anthropic/claude-haiku-4-5", + "description": "The Competition Leaderboard's Claude Haiku 4.5 agent. Same long-only DJIA hourly backtest as the rest of the board — add it and edit the instruction to try this model yourself.", + "category": "us_stocks", + "tags": [ + "competition model", + "DJIA" + ], + "author": "Agentic Trading Lab", + "pipeline": [ + { + "id": "sub_claude_haiku", + "presetKey": "simple_instruction", + "label": "Trading instruction", + "prompt": "Spread the money across a few of the strongest available stocks. Buy on meaningful dips, take profits after strong run-ups, and never put everything into one stock.", + "outputFormat": "JSON: { \"orders\": [{ \"symbol\": \"...\", \"side\": \"buy|sell|hold\", \"qty\": number, \"order_type\": \"market|limit\", \"limit_price\": number|null, \"reason\": \"...\" }] }" + } + ] + }, + { + "template_id": "claude-sonnet-4-6", + "shelf": "llms", + "name": "Claude Sonnet 4.6", + "model_name": "anthropic/claude-sonnet-4-6", + "description": "The Competition Leaderboard's Claude Sonnet 4.6 agent. Same long-only DJIA hourly backtest as the rest of the board — add it and edit the instruction to try this model yourself.", + "category": "us_stocks", + "tags": [ + "competition model", + "DJIA" + ], + "author": "Agentic Trading Lab", + "pipeline": [ + { + "id": "sub_claude_sonnet", + "presetKey": "simple_instruction", + "label": "Trading instruction", + "prompt": "Spread the money across a few of the strongest available stocks. Buy on meaningful dips, take profits after strong run-ups, and never put everything into one stock.", + "outputFormat": "JSON: { \"orders\": [{ \"symbol\": \"...\", \"side\": \"buy|sell|hold\", \"qty\": number, \"order_type\": \"market|limit\", \"limit_price\": number|null, \"reason\": \"...\" }] }" + } + ] + }, + { + "template_id": "deepseek-v4-pro", + "shelf": "llms", + "name": "DeepSeek V4 Pro", + "model_name": "deepseek/deepseek-v4-pro", + "description": "The Competition Leaderboard's DeepSeek V4 Pro agent — the only model that beat the passive baselines in the April 2026 contest window. Same long-only DJIA hourly backtest; add it and edit the instruction to try this model yourself.", + "category": "us_stocks", + "tags": [ + "competition model", + "DJIA" + ], + "author": "Agentic Trading Lab", + "pipeline": [ + { + "id": "sub_deepseek_v4", + "presetKey": "simple_instruction", + "label": "Trading instruction", + "prompt": "Spread the money across a few of the strongest available stocks. Buy on meaningful dips, take profits after strong run-ups, and never put everything into one stock.", + "outputFormat": "JSON: { \"orders\": [{ \"symbol\": \"...\", \"side\": \"buy|sell|hold\", \"qty\": number, \"order_type\": \"market|limit\", \"limit_price\": number|null, \"reason\": \"...\" }] }" + } + ] + }, + { + "template_id": "gpt-5-5", + "shelf": "llms", + "name": "GPT-5.5", + "model_name": "openai/gpt-5.5", + "description": "The Competition Leaderboard's GPT-5.5 agent. Same long-only DJIA hourly backtest as the rest of the board — add it and edit the instruction to try this model yourself.", + "category": "us_stocks", + "tags": [ + "competition model", + "DJIA" + ], + "author": "Agentic Trading Lab", + "pipeline": [ + { + "id": "sub_gpt_5_5", + "presetKey": "simple_instruction", + "label": "Trading instruction", + "prompt": "Spread the money across a few of the strongest available stocks. Buy on meaningful dips, take profits after strong run-ups, and never put everything into one stock.", + "outputFormat": "JSON: { \"orders\": [{ \"symbol\": \"...\", \"side\": \"buy|sell|hold\", \"qty\": number, \"order_type\": \"market|limit\", \"limit_price\": number|null, \"reason\": \"...\" }] }" + } + ] + }, + { + "template_id": "gemini-3-1-pro-preview", + "shelf": "llms", + "name": "Gemini 3.1 Pro Preview", + "model_name": "google/gemini-3.1-pro-preview", + "description": "The Competition Leaderboard's Gemini 3.1 Pro Preview agent. Same long-only DJIA hourly backtest as the rest of the board — add it and edit the instruction to try this model yourself.", + "category": "us_stocks", + "tags": [ + "competition model", + "DJIA" + ], + "author": "Agentic Trading Lab", + "pipeline": [ + { + "id": "sub_gemini_pro", + "presetKey": "simple_instruction", + "label": "Trading instruction", + "prompt": "Spread the money across a few of the strongest available stocks. Buy on meaningful dips, take profits after strong run-ups, and never put everything into one stock.", + "outputFormat": "JSON: { \"orders\": [{ \"symbol\": \"...\", \"side\": \"buy|sell|hold\", \"qty\": number, \"order_type\": \"market|limit\", \"limit_price\": number|null, \"reason\": \"...\" }] }" + } + ] + }, + { + "template_id": "nemotron-3-nano-30b", + "shelf": "llms", + "name": "Nemotron 3 Nano 30B", + "model_name": "nvidia/nemotron-3-nano-30b-a3b", + "description": "The Competition Leaderboard's NVIDIA Nemotron 3 Nano 30B agent. An open-weight model on the same long-only DJIA hourly backtest as the rest of the board — add it and edit the instruction to try this model yourself.", + "category": "us_stocks", + "tags": [ + "competition model", + "DJIA" + ], + "author": "Agentic Trading Lab", + "pipeline": [ + { + "id": "sub_nemotron_nano", + "presetKey": "simple_instruction", + "label": "Trading instruction", + "prompt": "Spread the money across a few of the strongest available stocks. Buy on meaningful dips, take profits after strong run-ups, and never put everything into one stock.", + "outputFormat": "JSON: { \"orders\": [{ \"symbol\": \"...\", \"side\": \"buy|sell|hold\", \"qty\": number, \"order_type\": \"market|limit\", \"limit_price\": number|null, \"reason\": \"...\" }] }" + } + ] + }, + { + "template_id": "qwen3-7-plus", + "shelf": "llms", + "name": "Qwen3.7 Plus", + "model_name": "qwen/qwen3.7-plus", + "description": "The Competition Leaderboard's Qwen3.7 Plus agent. Same long-only DJIA hourly backtest as the rest of the board — add it and edit the instruction to try this model yourself.", + "category": "us_stocks", + "tags": [ + "competition model", + "DJIA" + ], + "author": "Agentic Trading Lab", + "pipeline": [ + { + "id": "sub_qwen3_7", + "presetKey": "simple_instruction", + "label": "Trading instruction", + "prompt": "Spread the money across a few of the strongest available stocks. Buy on meaningful dips, take profits after strong run-ups, and never put everything into one stock.", + "outputFormat": "JSON: { \"orders\": [{ \"symbol\": \"...\", \"side\": \"buy|sell|hold\", \"qty\": number, \"order_type\": \"market|limit\", \"limit_price\": number|null, \"reason\": \"...\" }] }" + } + ] + }, + { + "template_id": "ai-hedge-fund", + "shelf": "open", + "card_subtitle": "Open-source multi-agent system", + "name": "AI Hedge Fund", + "model_name": "nvidia/nemotron-3-nano-30b-a3b", + "description": "A team of AI investors that analyzes the market, develops trading ideas, and tests them through backtesting.", + "category": "us_stocks", + "tags": [ + "analyst team", + "fundamentals", + "official template" + ], + "author": "virattt / Agentic Trading Lab", + "repo_url": "https://github.com/virattt/ai-hedge-fund", + "runtime_type": "ai_hedge_fund", + "runtime_config": { + "analysts": [ + "fundamentals_analyst", + "technical_analyst", + "sentiment_analyst", + "valuation_analyst" + ] + } + }, { "template_id": "balanced-starter", + "shelf": "open", "name": "Balanced Starter", "model_name": "anthropic/claude-haiku-4-5", "description": "A simple starter agent that diversifies across strong stocks, buys dips, and takes profits after run-ups.", "category": "us_stocks", - "tags": ["starter", "diversified"], + "tags": [ + "starter", + "diversified" + ], "author": "Agentic Trading Lab", "pipeline": [ { @@ -20,11 +203,15 @@ }, { "template_id": "momentum-scout", + "shelf": "open", "name": "Momentum Scout", "model_name": "anthropic/claude-haiku-4-5", "description": "Focus on recent price strength and volume. Favor leaders with positive momentum and trim laggards quickly.", "category": "us_stocks", - "tags": ["momentum", "trend"], + "tags": [ + "momentum", + "trend" + ], "author": "Agentic Trading Lab", "pipeline": [ { @@ -38,11 +225,15 @@ }, { "template_id": "pipeline-analyst", + "shelf": "open", "name": "Three-Step Analyst", "model_name": "anthropic/claude-sonnet-4-6", "description": "A three-step strategy: gather market facts, convert them into signals, then produce executable orders.", "category": "us_stocks", - "tags": ["multi-step strategy", "official template"], + "tags": [ + "multi-step strategy", + "official template" + ], "author": "Agentic Trading Lab", "pipeline": [ { @@ -68,32 +259,17 @@ } ] }, - { - "template_id": "ai-hedge-fund", - "name": "AI Hedge Fund", - "model_name": "nvidia/nemotron-3-nano-30b-a3b", - "description": "A hosted panel of AI analysts that weigh in on every trade, run through Agentic Trading Lab's long-only backtest engine. Based on the open-source AI Hedge Fund project by virattt.", - "category": "us_stocks", - "tags": ["analyst team", "fundamentals", "official template"], - "author": "virattt / Agentic Trading Lab", - "repo_url": "https://github.com/virattt/ai-hedge-fund", - "runtime_type": "ai_hedge_fund", - "runtime_config": { - "analysts": [ - "fundamentals_analyst", - "technical_analyst", - "sentiment_analyst", - "valuation_analyst" - ] - } - }, { "template_id": "blue-chip-steady", + "shelf": "open", "name": "Blue-Chip Steady", "model_name": "anthropic/claude-haiku-4-5", "description": "Buy and hold a handful of the strongest Dow companies, selling only when a position deteriorates badly. Mirrors the buy-and-hold benchmark on our leaderboard.", "category": "us_stocks", - "tags": ["buy and hold", "blue chips"], + "tags": [ + "buy and hold", + "blue chips" + ], "author": "Agentic Trading Lab", "pipeline": [ { @@ -107,11 +283,15 @@ }, { "template_id": "even-split-dow", + "shelf": "open", "name": "Even-Split Dow", "model_name": "anthropic/claude-haiku-4-5", "description": "Spread the money evenly across all available Dow stocks and keep the split even. Mirrors the equal-weight benchmark on our leaderboard.", "category": "us_stocks", - "tags": ["equal weight", "diversified"], + "tags": [ + "equal weight", + "diversified" + ], "author": "Agentic Trading Lab", "pipeline": [ { @@ -125,11 +305,15 @@ }, { "template_id": "ashare-steady-t1", + "shelf": "open", "name": "A-Share Steady (T+1)", "model_name": "anthropic/claude-haiku-4-5", "description": "A patient strategy for Chinese A-shares, built for that market's rule that shares bought today cannot be sold until the next trading day.", "category": "cn_ashares", - "tags": ["a-shares", "patient"], + "tags": [ + "a-shares", + "patient" + ], "author": "Agentic Trading Lab", "pipeline": [ { @@ -143,6 +327,7 @@ }, { "template_id": "contrarian-dip-buyer", + "shelf": "open", "name": "Contrarian Dip Buyer", "model_name": "openai/gpt-5.5", "description": "Buys stocks that have sold off hard and trims them back once they have recovered. The opposite instinct to a momentum strategy.", @@ -164,6 +349,7 @@ }, { "template_id": "sector-rotator", + "shelf": "open", "name": "Sector Rotator", "model_name": "google/gemini-3.1-pro-preview", "description": "Concentrates into whichever part of the market is leading, and moves on when leadership changes.", @@ -185,6 +371,7 @@ }, { "template_id": "volatility-guard", + "shelf": "open", "name": "Volatility Guard", "model_name": "deepseek/deepseek-v4-pro", "description": "Holds a steady portfolio in calm markets and cuts exposure when prices start swinging. Runs on the only model that has beaten the passive baselines on our leaderboard.", @@ -206,6 +393,7 @@ }, { "template_id": "ashare-momentum-t1", + "shelf": "open", "name": "A-Share Momentum (T+1)", "model_name": "qwen/qwen3.7-plus", "description": "Rides the strongest Chinese A-shares while respecting that market's rule that shares bought today cannot be sold until the next trading day.", diff --git a/dashboard/frontend/app.html b/dashboard/frontend/app.html index a87a5218..4e04517a 100644 --- a/dashboard/frontend/app.html +++ b/dashboard/frontend/app.html @@ -13,7 +13,7 @@ because every API call is a CORS request. --> - + @@ -1740,17 +1740,16 @@

Community

Agent Supermarket

-

Browse open agent templates and add them to My Agents to customize and backtest.

+

Discover models and open agents tested by the Agentic Trading Lab community. Add one to your workspace and test it yourself.

-

Every test here uses simulated money. Real money is involved only if you explicitly connect a brokerage account and turn on live trading.

+

Performance is based on simulated tests and may vary between runs. Live trading requires an explicitly connected brokerage account.

-
-
+
@@ -2491,7 +2490,7 @@

Refund Credits purchase

- + diff --git a/dashboard/frontend/app.js b/dashboard/frontend/app.js index b485dc80..e6ee3b9a 100644 --- a/dashboard/frontend/app.js +++ b/dashboard/frontend/app.js @@ -505,8 +505,9 @@ const LEGACY_RUNTIME_MARKET = { ai_hedge_fund: 'us_stocks' }; /** Category slug -> market display name. The single place these strings are * written: the Prompted Models shelf's market chips, the Community category - * chips, the agent-card submeta and the Configure picker all read this map, so - * renaming a market is one edit. Key order is chip order and mirrors the + * chips (labels only -- that row also filters to markets present in the + * catalog), the agent-card submeta and the Configure picker all read this map, + * so renaming a market is one edit. Key order is chip order and mirrors the * AgentCategory Literal's declaration order in * dashboard/backend/domain/agents/taxonomy.py. * @@ -2191,41 +2192,51 @@ async function loadAgentsNow() { let marketplaceTemplates = []; let marketplaceCloneInFlight = false; let marketplaceLoadInFlight = null; +/** null = contest board not fetched yet, or the last fetch failed (the next + * Community visit retries). [] = fetched and genuinely empty — never + * invent ranks. */ +let marketplaceLeaderboardEntries = null; +let marketplaceLeaderboardLoadInFlight = null; +/** Window / capital / field count from the same contest payload. */ +let marketplaceContestMeta = { + start_date: null, + end_date: null, + display_capital: null, + total_entries: null, +}; + +/** Community supermarket rows. Map-rendered; adding a shelf is a new entry + * here plus ``shelf`` on the catalog row, not a one-off card layout. */ +const MARKETPLACE_SHELVES = [ + { key: 'llms', title: 'LLMs', sub: 'LLMs tested on the ATL leaderboard' }, + { key: 'open', title: 'Agents', sub: 'Ready-made trading agents' }, +]; /** 'all' or one of MARKET_LABELS' keys. Set by the chip row and by the Prompted * Models shelf's empty-state Community button (via navigateToPage's options). */ let marketplaceCategoryFilter = 'all'; -/** 'all' or one of MODEL_VENDORS' keys. ANDs with marketplaceCategoryFilter. */ -let marketplaceVendorFilter = 'all'; /** The model-vendor axis: who makes a model, and how it is licensed. * - * Promoted from a submeta label lookup into the source of truth for the whole - * axis -- Community's vendor chips, the open-source badge and the card submeta - * all derive from this one table, so a badge cannot drift from the vendor it - * describes. A wrong badge is a factual claim about someone else's product. + * Single source of truth for vendor identity: `company` feeds the LLM tile + * submeta (formatModelCompanyLabel), `key`/`label`/`licence` are pinned by the + * facet tests. Nothing renders `licence` since the open-source badge retired + * with the vendor chip row (PR #427); it stays as vendor metadata because a + * wrong entry is a factual claim about someone else's product. * * Matched by PREFIX, not exact slug, so a new model version under a known - * vendor needs no entry here. Declaration order is chip order, mirroring how - * MARKET_LABELS' key order mirrors the AgentCategory Literal. - * - * All eight are listed even though only six are pickable: a card whose model - * matches nothing renders as the generic "AI-powered" with no chip and no - * badge, which is invisible until someone notices. The chip ROW is still - * derived from what the loaded catalog actually contains (see - * renderMarketplaceVendorChips), so listing a vendor here never ships an - * empty chip. */ + * vendor needs no entry here. */ const MODEL_VENDORS = [ - { key: 'anthropic', prefix: 'anthropic/', label: 'Claude', licence: 'closed' }, - { key: 'openai', prefix: 'openai/', label: 'GPT', licence: 'closed' }, - { key: 'google', prefix: 'google/', label: 'Gemini', licence: 'closed' }, - { key: 'deepseek', prefix: 'deepseek/', label: 'DeepSeek', licence: 'open' }, - { key: 'qwen', prefix: 'qwen/', label: 'Qwen', licence: 'open' }, - // "NVIDIA Nemotron", not "Nemotron": this label also feeds - // formatModelProviderLabel, whose shipped output must not change. - { key: 'nvidia', prefix: 'nvidia/nemotron', label: 'NVIDIA Nemotron', licence: 'open' }, - { key: 'meta', prefix: 'meta-llama/', label: 'Llama', licence: 'open' }, - { key: 'xai', prefix: 'x-ai/', label: 'Grok', licence: 'closed' }, + { key: 'anthropic', prefix: 'anthropic/', label: 'Claude', licence: 'closed', company: 'Anthropic' }, + { key: 'openai', prefix: 'openai/', label: 'GPT', licence: 'closed', company: 'OpenAI' }, + { key: 'google', prefix: 'google/', label: 'Gemini', licence: 'closed', company: 'Google' }, + { key: 'deepseek', prefix: 'deepseek/', label: 'DeepSeek', licence: 'open', company: 'DeepSeek' }, + { key: 'qwen', prefix: 'qwen/', label: 'Qwen', licence: 'open', company: 'Alibaba' }, + // "NVIDIA Nemotron", not "Nemotron": EXPECTED_VENDORS pins this label, + // and `company` carries the tile-subtitle name. + { key: 'nvidia', prefix: 'nvidia/nemotron', label: 'NVIDIA Nemotron', licence: 'open', company: 'NVIDIA' }, + { key: 'meta', prefix: 'meta-llama/', label: 'Llama', licence: 'open', company: 'Meta' }, + { key: 'xai', prefix: 'x-ai/', label: 'Grok', licence: 'closed', company: 'xAI' }, ]; /** Vendor key for a model slug, or '' when the platform genuinely doesn't know. @@ -2250,10 +2261,11 @@ function modelVendorLicence(modelName) { return (MODEL_VENDORS.find((vendor) => vendor.key === key) || {}).licence || ''; } -function formatModelProviderLabel(modelName) { +/** Company name for a tile subtitle (Anthropic, NVIDIA). */ +function formatModelCompanyLabel(modelName) { const key = modelVendorKey(modelName); const vendor = MODEL_VENDORS.find((entry) => entry.key === key); - return vendor ? `Powered by ${vendor.label}` : 'AI-powered'; + return vendor ? (vendor.company || vendor.label) : ''; } /** Select a Community category chip and re-render, without a route or API @@ -2271,27 +2283,33 @@ function setMarketplaceCategoryFilter(category) { renderMarketplaceGrid(); } -/** Select a vendor chip and re-render. Mirrors setMarketplaceCategoryFilter, - * including the reset-to-'all' fallback for an unrecognized key. */ -function setMarketplaceVendorFilter(vendorKey) { - marketplaceVendorFilter = MODEL_VENDORS.some((vendor) => vendor.key === vendorKey) - ? vendorKey - : 'all'; - renderMarketplaceGrid(); +/** Chip keys/labels for the Community market row. Pure so the catalog-filter + * contract can run under node: 'All' plus one chip per MARKET_LABELS key that + * the loaded catalog actually contains. Shipping every enum key put a China + * A-Share chip on a 100% us_stocks catalog -- a permanent empty state. Adding + * an A-share template brings the chip back; do not hardcode it. Order still + * comes from MARKET_LABELS so the row does not reshuffle with catalog order. */ +function marketplaceMarketChips(templates) { + const present = new Set( + (templates || []).map((t) => String(t.category || '').toLowerCase()), + ); + return [ + { key: 'all', label: 'All' }, + ...Object.entries(MARKET_LABELS) + .filter(([key]) => present.has(key)) + .map(([key, label]) => ({ key, label })), + ]; } -/** Chip row above the marketplace grid: 'All' plus one chip per market, built - * from MARKET_LABELS rather than a second hardcoded list. Built from the label - * map rather than AGENT_SHELVES because Community filters templates by - * *market*, and Prompted Models holds both markets -- the shelf - * list and the chip list are different things. */ +/** Chip row above the marketplace grid. Labels come from MARKET_LABELS; + * membership comes from the loaded catalog (see marketplaceMarketChips). + * Built from the label map rather than AGENT_SHELVES because Community + * filters templates by *market*, and Prompted Models holds both markets -- + * the shelf list and the chip list are different things. */ function renderMarketplaceCategoryChips() { const container = document.getElementById('marketplaceCategoryChips'); if (!container) return; - const chips = [ - { key: 'all', label: 'All' }, - ...Object.entries(MARKET_LABELS).map(([key, label]) => ({ key, label })), - ]; + const chips = marketplaceMarketChips(marketplaceTemplates); // Build once, then only toggle state. This runs from renderMarketplaceGrid, // which is bound to the search box's `input` event -- rebuilding innerHTML // per keystroke would blow away the focused chip on every character typed. @@ -2308,68 +2326,32 @@ function renderMarketplaceCategoryChips() { }); } -/** Second chip row: 'All' plus one chip per vendor PRESENT IN THE CATALOG. - * - * Deliberately asymmetric with the market row, which is hardcoded from - * MARKET_LABELS: markets are a closed, backend-validated enum, vendors are - * open-ended. Hardcoding all of MODEL_VENDORS would ship chips that can never - * match anything. Order still comes from MODEL_VENDORS, not from catalog order, - * so the row does not reshuffle when a template is added. */ -function renderMarketplaceVendorChips() { - const container = document.getElementById('marketplaceVendorChips'); - if (!container) return; - const present = new Set(marketplaceTemplates.map((t) => modelVendorKey(t.model_name))); - const chips = [ - { key: 'all', label: 'All models' }, - ...MODEL_VENDORS.filter((vendor) => present.has(vendor.key)).map((vendor) => ({ - key: vendor.key, - label: vendor.label, - })), - ]; - // Build once, then only toggle state -- same reason as the market row: this - // runs from renderMarketplaceGrid, which is bound to the search box's `input`. - const existing = container.querySelectorAll('[data-marketplace-vendor]'); - if (existing.length !== chips.length) { - container.innerHTML = chips - .map((chip) => ``) - .join(''); - } - container.querySelectorAll('[data-marketplace-vendor]').forEach((button) => { - const active = button.dataset.marketplaceVendor === marketplaceVendorFilter; - button.classList.toggle('active', active); - button.setAttribute('aria-pressed', String(active)); - }); -} - -/** Empty-state copy. Three cases, deliberately worded apart -- the same concern - * promptedEmptyHtml records for My Agents. - * - * A typed query wins over the facet case: when a search is what emptied the - * grid, offering "Clear filters" sends the user to fix the wrong thing. */ -function marketplaceEmptyHtml({ searching, categoryFilter, vendorFilter }) { +/** Empty-state copy. Two cases, deliberately worded apart -- the same concern + * promptedEmptyHtml records for My Agents. A typed query wins over the market + * chip: when a search is what emptied the grid, "No X templates yet" would + * send the user to fix the wrong thing. */ +function marketplaceEmptyHtml({ searching, categoryFilter }) { if (searching) return 'No templates match your search.'; - if (categoryFilter !== 'all' && vendorFilter !== 'all') { - return `No templates match both filters. `; - } if (categoryFilter !== 'all') { return `No ${escapeHtml(MARKET_LABELS[categoryFilter] || '')} templates yet.`; } - if (vendorFilter !== 'all') { - const vendor = MODEL_VENDORS.find((entry) => entry.key === vendorFilter); - return `No ${escapeHtml(vendor?.label || '')} templates yet.`; - } return 'No templates match your search.'; } +function templateMarketplaceShelf(template) { + const explicit = String(template?.shelf || '').toLowerCase(); + if (explicit === 'llms' || explicit === 'open') return explicit; + // Mirrors the backend's _normalize_shelf fallback: any hosted runtime is an + // open agent, with no per-runtime special case to remember. + return template?.mode === 'runtime' ? 'open' : 'llms'; +} + function getFilteredMarketplaceTemplates() { const query = (document.getElementById('marketplaceSearchInput')?.value || '').trim().toLowerCase(); let list = marketplaceTemplates.slice(); if (marketplaceCategoryFilter !== 'all') { list = list.filter((template) => String(template.category || '').toLowerCase() === marketplaceCategoryFilter); } - if (marketplaceVendorFilter !== 'all') { - list = list.filter((template) => modelVendorKey(template.model_name) === marketplaceVendorFilter); - } if (query) { list = list.filter((template) => { const haystack = [ @@ -2377,6 +2359,7 @@ function getFilteredMarketplaceTemplates() { template.description, template.category, template.author, + template.card_subtitle, ...(template.tags || []), template.model_name, ] @@ -2389,6 +2372,324 @@ function getFilteredMarketplaceTemplates() { return list; } +function findMarketplaceLeaderboardEntry(template) { + const entries = marketplaceLeaderboardEntries; + if (!Array.isArray(entries)) return null; + const name = String(template?.name || '').trim().toLowerCase(); + const id = String(template?.template_id || '').replace(/-/g, '_'); + return entries.find((entry) => { + if (!entry || !entry.is_model) return false; + if (name && String(entry.model || '').trim().toLowerCase() === name) return true; + return Boolean(id) && String(entry.entry_id || '') === id; + }) || null; +} + +/** Contest-board stats for a supermarket card. + * + * Wired to GET /api/v1/leaderboard?period=contest (same payload as the + * Competition Leaderboard, window 2026-04-15 → 2026-05-15). That payload + * has a single-window ``cumulative_return``, official ``rank``, and + * hourly ``equity_curve``. Do not invent values. + */ +const MARKETPLACE_MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']; + +function applyMarketplaceLeaderboardPayload(data) { + const entries = Array.isArray(data?.entries) ? data.entries : []; + marketplaceLeaderboardEntries = entries; + marketplaceContestMeta = { + start_date: data?.window?.start_date || null, + end_date: data?.window?.end_date || null, + display_capital: data?.display_capital ?? null, + total_entries: Number(data?.total_entries) || entries.length || null, + }; +} + +function marketplaceBenchmarkEntry() { + const entries = marketplaceLeaderboardEntries; + if (!Array.isArray(entries)) return null; + return entries.find((entry) => ( + entry?.entry_id === 'djia_index' || String(entry?.model || '').toUpperCase() === 'DJIA' + )) || null; +} + +function downsampleMarketplaceCurve(curve, maxPoints = 48) { + if (!Array.isArray(curve) || curve.length <= maxPoints) return curve || []; + const out = []; + const last = curve.length - 1; + for (let i = 0; i < maxPoints; i += 1) { + out.push(curve[Math.round((i / (maxPoints - 1)) * last)]); + } + return out; +} + +function marketplaceIndexedPctSeries(curve) { + // Cumulative return from the first equity point, in percent (0 = start). + if (!Array.isArray(curve) || curve.length < 2) return null; + const points = []; + for (const point of curve) { + const equity = Number(point?.equity); + if (!Number.isFinite(equity)) continue; + points.push({ t: point.timestamp, equity }); + } + if (points.length < 2) return null; + const initial = points[0].equity; + if (!initial) return null; + return points.map((point) => ({ t: point.t, pct: ((point.equity / initial) - 1) * 100 })); +} + +function formatMarketplaceMd(iso) { + const match = String(iso || '').match(/^(\d{4})-(\d{2})-(\d{2})/); + if (!match) return ''; + return `${MARKETPLACE_MONTHS[Number(match[2]) - 1]} ${Number(match[3])}`; +} + +function formatMarketplaceWindowRange(start, end) { + const from = formatMarketplaceMd(start); + const to = formatMarketplaceMd(end); + if (from && to) return `${from}–${to}`; + return from || to || ''; +} + +function formatMarketplaceCapital(value) { + const n = Number(value); + if (!Number.isFinite(n)) return ''; + if (n >= 1000 && n % 1000 === 0) return `$${(n / 1000).toFixed(0)}K`; + return `$${Math.round(n).toLocaleString('en-US')}`; +} + +function marketplaceNicePctTicks(min, max) { + const lo = Math.min(0, Math.floor(min / 5) * 5); + const hi = Math.max(0, Math.ceil(max / 5) * 5); + const ticks = []; + for (let v = lo; v <= hi; v += 5) ticks.push(v); + if (ticks.length < 2) ticks.push(lo + 5); + return ticks; +} + +function marketplaceLinePath(series, xOf, yOf) { + return series.map((point, i) => { + const cmd = i === 0 ? 'M' : 'L'; + return `${cmd}${xOf(i).toFixed(1)},${yOf(point.pct).toFixed(1)}`; + }).join(' '); +} + +/** Agent vs DJIA comparison chart from real contest equity_curve points. */ +function buildMarketplaceCompareChartHtml(agentCurve, benchmarkCurve, { positive = true, modelName = 'Model' } = {}) { + const agent = marketplaceIndexedPctSeries(downsampleMarketplaceCurve(agentCurve)); + if (!agent) return ''; + const bench = marketplaceIndexedPctSeries(downsampleMarketplaceCurve(benchmarkCurve)); + const agentColor = positive ? '#4ade80' : '#f87171'; + const benchColor = '#94a3b8'; + const pcts = agent.map((p) => p.pct).concat(bench ? bench.map((p) => p.pct) : []); + const ticks = marketplaceNicePctTicks(Math.min(...pcts), Math.max(...pcts)); + const yMin = ticks[0]; + const yMax = ticks[ticks.length - 1]; + const yRange = yMax - yMin || 1; + const w = 220; + const left = 32; + const right = 6; + const top = 6; + const plotBottom = 78; + const plotW = w - left - right; + const plotH = plotBottom - top; + const n = agent.length; + // Each series is scaled by ITS OWN length: the two curves are downsampled + // independently, and plotting the benchmark against the agent's point count + // draws it past the plot edge (or squashed) whenever the lengths differ. + const xAt = (i, len) => left + (len <= 1 ? 0 : (i / (len - 1)) * plotW); + const xOf = (i) => xAt(i, n); + const yOf = (pct) => top + (1 - (pct - yMin) / yRange) * plotH; + const xTicks = [0, Math.round((n - 1) / 2), n - 1].filter((v, i, arr) => arr.indexOf(v) === i); + const yLines = ticks.map((tick) => { + const y = yOf(tick); + return ` + ${tick}%`; + }).join(''); + const xLabels = xTicks.map((i) => { + const raw = String(agent[i]?.t || ''); + const label = formatMarketplaceMd(raw.slice(0, 10)); + if (!label) return ''; + return `${escapeHtml(label)}`; + }).join(''); + const agentPath = marketplaceLinePath(agent, xOf, yOf); + const benchPath = bench && bench.length >= 2 + ? marketplaceLinePath(bench, (i) => xAt(i, bench.length), yOf) + : ''; + return ` + `; +} + +function marketplacePerformanceFor(template) { + const loading = marketplaceLeaderboardEntries === null; + const meta = marketplaceContestMeta || {}; + const empty = { + leaderboardRank: null, + contestReturn: null, + agentCurve: null, + benchmarkCurve: null, + totalEntries: meta.total_entries || null, + startDate: meta.start_date || null, + endDate: meta.end_date || null, + displayCapital: meta.display_capital ?? null, + loading, + }; + if (loading) return empty; + const entry = findMarketplaceLeaderboardEntry(template); + const benchmark = marketplaceBenchmarkEntry(); + if (!entry) { + return { + ...empty, + loading: false, + benchmarkCurve: benchmark?.equity_curve || null, + }; + } + const rank = Number(entry.rank); + const ret = entry.cumulative_return; + const contestReturn = ret == null || ret === '' ? null : Number(ret); + return { + ...empty, + loading: false, + leaderboardRank: Number.isFinite(rank) ? rank : null, + contestReturn: Number.isFinite(contestReturn) ? contestReturn : null, + agentCurve: entry.equity_curve || null, + benchmarkCurve: benchmark?.equity_curve || null, + }; +} + +function compareMarketplaceTemplatesByRank(a, b) { + const ra = marketplacePerformanceFor(a).leaderboardRank; + const rb = marketplacePerformanceFor(b).leaderboardRank; + if (ra == null && rb == null) return 0; + if (ra == null) return 1; + if (rb == null) return -1; + return ra - rb; +} + +function formatMarketplaceReturnPct(value) { + // null/'' is "no data", not 0%: Number(null) is 0, which Number.isFinite + // accepts, so without this guard a missing return renders as a green "0.0%". + if (value == null || value === '') return null; + const n = Number(value); + if (!Number.isFinite(n)) return null; + // Round to one decimal BEFORE choosing the sign, so -0.04% shows as "0.0%" + // rather than "-0.0%". + const pct = Math.round(n * 1000) / 10; + const abs = Math.abs(pct).toFixed(1); + return `${pct > 0 ? '+' : pct < 0 ? '-' : ''}${abs}%`; +} + +function marketplaceRepoLabel(template) { + if (!template?.repo_url) return ''; + try { + const path = new URL(template.repo_url).pathname.replace(/^\/+|\/+$/g, ''); + return path || template.author || 'GitHub'; + } catch { + return template.author || 'GitHub'; + } +} + +/** Compact leaderboard-first card. Shared by both supermarket shelves. */ +function buildMarketplaceCardHtml(template) { + const stats = marketplacePerformanceFor(template); + const isOpen = templateMarketplaceShelf(template) === 'open'; + const cloneLabel = 'Add to My Agents'; + const categoryLabel = MARKET_LABELS[String(template.category || '').toLowerCase()] || ''; + const companyLabel = formatModelCompanyLabel(template.model_name); + const submeta = (isOpen && template.card_subtitle) + ? template.card_subtitle + : [companyLabel, categoryLabel].filter(Boolean).join(' · '); + const returnPositive = Number(stats.contestReturn) >= 0; + const formattedReturn = formatMarketplaceReturnPct(stats.contestReturn); + const returnValue = (!stats.loading && formattedReturn) ? formattedReturn : '—'; + const returnClass = (!stats.loading && formattedReturn) + ? (returnPositive ? 'return-positive' : 'return-negative') + : 'mp-stat-value--muted'; + + const rankBadge = (!isOpen && stats.leaderboardRank != null && stats.totalEntries) + ? ` + + #${stats.leaderboardRank} of ${stats.totalEntries} + ` + : (template.repo_url ? 'Open Source' : ''); + + const chartHtml = !isOpen + ? buildMarketplaceCompareChartHtml(stats.agentCurve, stats.benchmarkCurve, { + positive: returnPositive, + modelName: template.name, + }) + : ''; + + const windowLabel = formatMarketplaceWindowRange(stats.startDate, stats.endDate); + const capitalLabel = formatMarketplaceCapital(stats.displayCapital); + const metaParts = ['DJIA 30', windowLabel, capitalLabel].filter(Boolean); + const contestMeta = !isOpen && metaParts.length + ? `

+ + ${escapeHtml(metaParts.join(' · '))} +

` + : ''; + + const competitionHtml = !isOpen + ? `
+
+

Competition result

+ ${contestMeta} +
+
+
+ ${escapeHtml(returnValue)} + Return +
+ ${chartHtml} +
+
` + : ''; + + const description = isOpen ? String(template.description || '').trim() : ''; + const descriptionHtml = description + ? `

${escapeHtml(description)}

` + : ''; + + const repoLabel = marketplaceRepoLabel(template); + const identityExtra = isOpen && template.repo_url + ? ` + + ${escapeHtml(repoLabel)} + ` + : ''; + + return ` +
+
+
+ ${agentRobotIcon()} +
+

${escapeHtml(template.name)}

+

${escapeHtml(submeta)}

+
+
+ ${rankBadge} +
+ ${competitionHtml} + ${descriptionHtml} + ${identityExtra} +
+ +
+
`; +} + function renderMarketplaceGrid() { const grid = document.getElementById('marketplaceGrid'); const emptyEl = document.getElementById('marketplaceEmptyState'); @@ -2396,98 +2697,51 @@ function renderMarketplaceGrid() { if (!grid) return; renderMarketplaceCategoryChips(); - renderMarketplaceVendorChips(); if (errorEl) errorEl.hidden = true; const templates = getFilteredMarketplaceTemplates(); - grid.innerHTML = ''; + const searching = Boolean((document.getElementById('marketplaceSearchInput')?.value || '').trim()); if (!templates.length) { + grid.innerHTML = ''; // Keep it hidden before the first load, so it doesn't flash while // marketplaceTemplates is still empty. if (emptyEl) { emptyEl.hidden = marketplaceTemplates.length === 0; emptyEl.innerHTML = marketplaceEmptyHtml({ - searching: Boolean((document.getElementById('marketplaceSearchInput')?.value || '').trim()), + searching, categoryFilter: marketplaceCategoryFilter, - vendorFilter: marketplaceVendorFilter, - }); - emptyEl.querySelector('.marketplace-clear-filters')?.addEventListener('click', () => { - marketplaceCategoryFilter = 'all'; - marketplaceVendorFilter = 'all'; - renderMarketplaceGrid(); }); } return; } if (emptyEl) emptyEl.hidden = true; + const byShelf = Object.fromEntries(MARKETPLACE_SHELVES.map((shelf) => [shelf.key, []])); templates.forEach((template) => { - const card = document.createElement('div'); - card.className = 'section-card agent-card marketplace-card'; - const modeLabel = template.mode === 'runtime' - ? 'Hosted' - : (template.mode === 'pipeline' ? 'Multi-step strategy' : 'Simple instruction'); - const cloneLabel = 'Add to My Agents'; - const categoryLabel = MARKET_LABELS[String(template.category || '').toLowerCase()] || 'General'; - const modelLabel = formatModelProviderLabel(template.model_name); - // Open weights get a badge; closed models get nothing. Licence comes from - // MODEL_VENDORS so it cannot drift from the vendor it describes. - const licenceBadge = modelVendorLicence(template.model_name) === 'open' - ? 'Open-source model' - : ''; - const tags = (template.tags || []) - .slice(0, 3) - .map((tag) => `${escapeHtml(tag)}`) - .join(''); - const repoLabel = (() => { - if (!template.repo_url) return ''; - try { - const path = new URL(template.repo_url).pathname.replace(/^\/+|\/+$/g, ''); - return path || template.author || 'GitHub'; - } catch { - return template.author || 'GitHub'; - } - })(); - const authorMeta = template.repo_url - ? ` - - ${escapeHtml(repoLabel)} - ` - : `By ${escapeHtml(template.author || 'Community')}`; - card.innerHTML = ` -
-
- ${agentRobotIcon()} -
-

${escapeHtml(template.name)}

-

${escapeHtml(modelLabel)} · ${escapeHtml(categoryLabel)}

-
-
- ${escapeHtml(modeLabel)} -
-
-

${escapeHtml(template.description || 'No description provided yet.')}

-
- ${authorMeta} - ${template.step_count ? `${template.step_count} step${template.step_count === 1 ? '' : 's'}` : ''} + const key = templateMarketplaceShelf(template); + if (byShelf[key]) byShelf[key].push(template); + else byShelf.llms.push(template); + }); + + grid.innerHTML = MARKETPLACE_SHELVES.map((shelf) => { + const cards = byShelf[shelf.key] || []; + if (!cards.length) return ''; + return ` +
+
+

${escapeHtml(shelf.title)}

+

${escapeHtml(shelf.sub)}

- ${(licenceBadge || tags) ? `
${licenceBadge}${tags}
` : ''} -
-
-
- - ${template.runtime_type === 'pipeline' ? ` - - ` : ''} +
+ ${(shelf.key === 'llms' ? cards.slice().sort(compareMarketplaceTemplatesByRank) : cards) + .map((template) => buildMarketplaceCardHtml(template)).join('')}
-
`; - grid.appendChild(card); - }); + `; + }).join(''); grid.querySelectorAll('.marketplace-clone-btn').forEach((btn) => { - btn.addEventListener('click', async () => { + btn.addEventListener('click', async (event) => { + event.stopPropagation(); const templateId = btn.dataset.templateId; const template = marketplaceTemplates.find((item) => item.template_id === templateId); if (!template || marketplaceCloneInFlight) return; @@ -2506,37 +2760,6 @@ function renderMarketplaceGrid() { } }); }); - - grid.querySelectorAll('.marketplace-clone-model-btn').forEach((btn) => { - btn.addEventListener('click', (event) => { - event.stopPropagation(); - const menu = btn.parentElement?.querySelector('.marketplace-model-menu'); - if (!menu) return; - const opening = menu.hidden; - // Close every other card's menu first: two open menus overlap. - grid.querySelectorAll('.marketplace-model-menu').forEach((el) => { el.hidden = true; }); - grid.querySelectorAll('.marketplace-clone-model-btn').forEach((el) => el.setAttribute('aria-expanded', 'false')); - menu.hidden = !opening; - btn.setAttribute('aria-expanded', String(opening)); - }); - }); - - grid.querySelectorAll('.marketplace-model-option').forEach((option) => { - option.addEventListener('click', async () => { - const template = marketplaceTemplates.find((item) => item.template_id === option.dataset.templateId); - if (!template || marketplaceCloneInFlight) return; - marketplaceCloneInFlight = true; - option.disabled = true; - try { - await cloneMarketplaceTemplate(template, option.dataset.modelSlug); - } catch (error) { - alert(error.message || `Couldn't add this template. Please try again.`); - } finally { - marketplaceCloneInFlight = false; - option.disabled = false; - } - }); - }); } function renderMarketplaceError() { @@ -2548,6 +2771,41 @@ function renderMarketplaceError() { if (errorEl) errorEl.hidden = false; } +/** Fetch the contest-board stats behind the LLM cards, at most once. + * + * Reuses the Leaderboard tab's already-loaded payload only when it is the + * CONTEST board: js/leaderboard.js writes the same `leaderboardPayload` global + * for period=live, which mirrors contest today but is expected to diverge once + * the season engine ships. A failed fetch resets the cache to null so the next + * Community visit retries instead of pinning blank stats for the session. + */ +async function loadMarketplaceLeaderboard() { + if (marketplaceLeaderboardEntries !== null) return; + if ( + typeof leaderboardPayload !== 'undefined' + && leaderboardPayload?.period === 'contest' + && Array.isArray(leaderboardPayload.entries) + ) { + applyMarketplaceLeaderboardPayload(leaderboardPayload); + renderMarketplaceGrid(); + return; + } + if (marketplaceLeaderboardLoadInFlight) return marketplaceLeaderboardLoadInFlight; + marketplaceLeaderboardLoadInFlight = (async () => { + try { + const data = await API.get(`${API_BASE}/api/v1/leaderboard?period=contest`); + applyMarketplaceLeaderboardPayload(data); + } catch (error) { + console.warn('Marketplace leaderboard stats failed:', error.message); + marketplaceLeaderboardEntries = null; + } finally { + marketplaceLeaderboardLoadInFlight = null; + renderMarketplaceGrid(); + } + })(); + return marketplaceLeaderboardLoadInFlight; +} + /** * Fetch the template catalog, at most once per page load. * @@ -2559,6 +2817,7 @@ function renderMarketplaceError() { * retries rather than showing the error forever. */ async function loadMarketplace() { + loadMarketplaceLeaderboard(); if (marketplaceTemplates.length) { renderMarketplaceGrid(); return; @@ -9139,11 +9398,6 @@ function navigateToPage(page, options = {}) { // set on one visit would leak into the next, unrelated visit made // through the plain nav tab, the most common entry path. marketplaceCategoryFilter = MARKET_LABELS[options.communityCategory] ? options.communityCategory : 'all'; - // The vendor chip resets for the same reason, and nothing rides in - // via options: a vendor left selected on one visit would AND with an - // incoming category and strand the empty-shelf deep links on an - // empty grid. - marketplaceVendorFilter = 'all'; if (communityView) communityView.style.display = 'block'; loadMarketplace(); } else if (page === 'account') { @@ -9292,11 +9546,6 @@ function initNavigation() { if (!chipBtn) return; setMarketplaceCategoryFilter(chipBtn.dataset.marketplaceCategory); }); - document.getElementById('marketplaceVendorChips')?.addEventListener('click', (event) => { - const chip = event.target.closest('[data-marketplace-vendor]'); - if (!chip) return; - setMarketplaceVendorFilter(chip.dataset.marketplaceVendor); - }); document.getElementById('agentsCategories')?.addEventListener('click', (event) => { const marketChip = event.target.closest('[data-agent-market]'); if (marketChip) { diff --git a/dashboard/frontend/styles.css b/dashboard/frontend/styles.css index f80d49e9..ab37046a 100644 --- a/dashboard/frontend/styles.css +++ b/dashboard/frontend/styles.css @@ -8951,7 +8951,8 @@ html[data-nav-page="home"] #homeView .dashboard-bottom-grid .home-module--market } .agent-card-top > .status-badge, -.agent-card-top > .marketplace-mode-chip { +.agent-card-top > .marketplace-mode-chip, +.agent-card-top > .mp-rank-badge { flex-shrink: 0; white-space: nowrap; } @@ -10896,34 +10897,238 @@ body.agent-editor-open { margin-top: 4px; } +.marketplace-shelves { + display: flex; + flex-direction: column; + gap: 28px; + margin-top: 4px; +} + +.marketplace-shelf-head { + margin-bottom: 14px; +} + +.marketplace-shelf-title { + margin: 0 0 4px; + font-size: 1.05rem; + font-weight: 600; + color: var(--text-primary); +} + +.marketplace-shelf-sub { + margin: 0; + font-size: 0.85rem; + color: var(--text-secondary); +} + +.marketplace-shelf-grid { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 16px; + align-items: stretch; +} + .marketplace-card { display: flex; flex-direction: column; - gap: 14px; - min-height: 100%; + gap: 12px; + height: 100%; + padding: 16px; + box-sizing: border-box; +} + +.marketplace-card .agent-card-cta { + width: 100%; } -.marketplace-card-body { +.marketplace-card .agent-card-actions--status { + margin-top: auto; + padding-top: 18px; +} + +.mp-rank-badge { + display: inline-flex; + align-items: center; + gap: 5px; + flex-shrink: 0; + padding: 5px 9px; + border-radius: 999px; + font-size: 0.78rem; + font-weight: 700; + color: var(--home-accent-cyan, #22d3ee); + background: rgba(34, 211, 238, 0.1); + border: 1px solid rgba(34, 211, 238, 0.28); + white-space: nowrap; +} + +.mp-rank-badge-icon { + width: 13px; + height: 13px; +} + +.mp-competition { display: flex; flex-direction: column; - gap: 10px; - flex: 1; + gap: 8px; + min-width: 0; } -.marketplace-card-description { +.mp-competition-head { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 8px; + min-width: 0; +} + +.mp-competition-kicker { margin: 0; + font-size: 0.68rem; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--text-muted, var(--text-secondary)); + flex-shrink: 0; +} + +.mp-competition-body { + display: grid; + grid-template-columns: minmax(72px, 0.38fr) minmax(0, 1fr); + gap: 10px; + align-items: stretch; +} + +.mp-total-return { + display: flex; + flex-direction: column; + justify-content: center; + gap: 2px; + min-width: 0; +} + +.mp-total-return-value { + font-size: 1.45rem; + font-weight: 700; + line-height: 1.15; + font-variant-numeric: tabular-nums; +} + +.mp-total-return-value.return-positive { + color: var(--success-color); +} + +.mp-total-return-value.return-negative { + color: var(--danger-color); +} + +.mp-total-return-value.mp-stat-value--muted { color: var(--text-secondary); - line-height: 1.5; - font-size: 0.92rem; + font-weight: 600; + font-size: 1.2rem; } -.marketplace-card-meta { +.mp-total-return-label { + font-size: 0.78rem; + font-weight: 600; + color: var(--text-secondary); +} + +.mp-compare-chart { + min-width: 0; +} + +.mp-compare-chart svg { + display: block; + width: 100%; + height: 96px; +} + +.mp-chart-tick { + fill: #94a3b8; + font-size: 8px; +} + +.mp-compare-legend { display: flex; flex-wrap: wrap; - gap: 10px 16px; + gap: 4px 10px; + margin: 4px 0 0; + font-size: 0.68rem; + font-weight: 600; + color: var(--text-muted, var(--text-secondary)); +} + +.mp-compare-legend-item { + display: inline-flex; align-items: center; + gap: 5px; +} + +.mp-compare-swatch { + width: 12px; + height: 2px; + border-radius: 1px; + flex-shrink: 0; +} + +.mp-compare-swatch--djia { + background: repeating-linear-gradient( + 90deg, + #94a3b8 0 4px, + transparent 4px 7px + ); +} + +.mp-contest-meta { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 6px; + margin: 0; + font-size: 0.72rem; + font-weight: 600; + color: var(--text-muted, var(--text-secondary)); + min-width: 0; + text-align: right; +} + +.mp-contest-meta-icon { + width: 13px; + height: 13px; + flex-shrink: 0; + color: #60a5fa; +} + +@media (max-width: 960px) { + .marketplace-shelf-grid { + grid-template-columns: repeat(3, minmax(0, 1fr)); + } +} + +@media (max-width: 720px) { + .marketplace-shelf-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} + +@media (max-width: 520px) { + .marketplace-shelf-grid { + grid-template-columns: 1fr; + } +} + +.marketplace-card .agent-card-submeta { + height: auto; + white-space: normal; + overflow: visible; + text-overflow: unset; +} + +.marketplace-card-description { + margin: 0; color: var(--text-secondary); - font-size: 0.82rem; + line-height: 1.45; + font-size: 0.88rem; } .marketplace-repo-btn { @@ -10953,24 +11158,6 @@ body.agent-editor-open { flex-shrink: 0; } -.marketplace-tag-row { - display: flex; - flex-wrap: wrap; - gap: 8px; -} - -.marketplace-tag { - display: inline-flex; - align-items: center; - padding: 2px 10px; - border-radius: 999px; - font-size: 0.72rem; - font-weight: 600; - color: #67e8f9; - background: rgba(103, 232, 249, 0.1); - border: 1px solid rgba(103, 232, 249, 0.25); -} - .marketplace-mode-chip { display: inline-flex; align-items: center; @@ -10991,68 +11178,6 @@ body.agent-editor-open { margin: 4px 0 16px; } -/* Second facet row (model vendor). Pulled up under the market row so the two - read as one stacked filter block rather than two unrelated controls. */ -.marketplace-vendor-chips { - margin-top: -8px; -} - -/* Open-weight marker. Sits in the tag row so it wraps with the tags rather - than competing with the mode chip for the card's top-right corner. */ -.marketplace-licence-badge { - display: inline-flex; - align-items: center; - padding: 2px 8px; - border-radius: 999px; - font-size: 0.72rem; - font-weight: 600; - color: var(--success-color); - background: rgba(34, 197, 94, 0.12); - border: 1px solid rgba(34, 197, 94, 0.35); -} - -/* Split CTA: the primary Add button keeps its weight; the model picker is a - quieter sibling so it cannot compete with the conversion click. */ -.marketplace-clone-split { - position: relative; - display: flex; - gap: 6px; - width: 100%; -} - -.marketplace-clone-split .marketplace-clone-btn { - flex: 1 1 auto; -} - -.marketplace-clone-model-btn { - flex: 0 0 auto; - background: transparent; - color: var(--text-secondary); - border: 1px solid var(--border-color); -} - -.marketplace-clone-model-btn:hover { - color: var(--text-primary); - border-color: var(--info-color); -} - -.marketplace-model-menu { - position: absolute; - right: 0; - bottom: calc(100% + 6px); - z-index: 20; - min-width: 200px; - padding: 6px; - border-radius: 10px; - background: var(--bg-surface); - border: 1px solid var(--border-color); - box-shadow: 0 12px 28px rgba(0, 0, 0, 0.35); -} - -.marketplace-model-menu[hidden] { - display: none !important; -} - .marketplace-category-chip { display: inline-flex; align-items: center;