From 9942044b55596b6506cc1d9ecc1910f3f83a6691 Mon Sep 17 00:00:00 2001 From: Allan-Feng Date: Sun, 30 Aug 2026 23:00:35 -0400 Subject: [PATCH 1/4] ux(community): split Agent Supermarket into LLM and Open Agent shelves Replace mixed strategy tiles with compact leaderboard-first cards that reuse the contest board's rank, return, and DJIA equity curves. Co-authored-by: Cursor --- .../backend/domain/agents/marketplace.py | 46 +- .../tests/test_agent_starter_defaults.py | 26 +- dashboard/backend/tests/test_agents_api.py | 56 +- .../backend/tests/test_app_copy_register.py | 8 +- .../tests/test_frontend_model_facets.py | 473 ++++++++-------- .../backend/tests/test_frontend_shelves.py | 20 +- .../tests/test_marketplace_catalog_models.py | 76 ++- dashboard/config/marketplace.json | 224 +++----- dashboard/frontend/app.html | 7 +- dashboard/frontend/app.js | 506 ++++++++++++++---- dashboard/frontend/styles.css | 225 +++++++- 11 files changed, 1104 insertions(+), 563 deletions(-) diff --git a/dashboard/backend/domain/agents/marketplace.py b/dashboard/backend/domain/agents/marketplace.py index 2a5a4ee2..e6befb4a 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 Open 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,17 +100,19 @@ 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 + Open 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 "")), + key=lambda t: MARKETPLACE_SHELVES.index(t["shelf"]) + if t.get("shelf") in MARKETPLACE_SHELVES + else len(MARKETPLACE_SHELVES), ) 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..bf8c6bc2 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,33 @@ 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 Open 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 Open Agents shelf is empty" + assert templates[0]["shelf"] == "llms" + assert templates[-1]["shelf"] == "open" + assert [t["name"] for t in opens] == ["AI Hedge Fund"] def test_uncategorized_templates_sort_last_and_carry_no_fake_shelf(): @@ -1416,7 +1406,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 +1419,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 +1431,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_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_frontend_model_facets.py b/dashboard/backend/tests/test_frontend_model_facets.py index 51d78316..5afdbe9a 100644 --- a/dashboard/backend/tests/test_frontend_model_facets.py +++ b/dashboard/backend/tests/test_frontend_model_facets.py @@ -143,14 +143,12 @@ 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('
null }}; const marketplaceTemplates = [ {{ template_id: 't1', category: 'us_stocks', model_name: 'anthropic/claude-haiku-4-5' }}, @@ -245,25 +238,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,17 +257,9 @@ 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"} + assert set(data["marketUs"]) == {"t1", "t2", "t5"} + assert set(data["marketCn"]) == {"t3", "t4"} + assert set(data["marketAll"]) == {"t1", "t2", "t3", "t4", "t5"} @pytest.mark.skipif(shutil.which("node") is None, reason="node is not installed") @@ -345,11 +323,12 @@ def test_shipped_vendor_chip_order_follows_model_vendors_not_catalog_order(): 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. Open Agents use an explicit + Open Source mark; closed models 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 @@ -360,144 +339,150 @@ def test_licence_badge_has_a_style_rule(): 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 an Open Agents mark, not an open-weight-model 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' }}), +]; +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] @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'), + 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 +490,90 @@ 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["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 +581,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 +607,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 @@ -829,13 +824,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..72bac913 100644 --- a/dashboard/backend/tests/test_frontend_shelves.py +++ b/dashboard/backend/tests/test_frontend_shelves.py @@ -382,7 +382,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 +392,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 +405,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 +413,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 +461,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..816d0bd1 100644 --- a/dashboard/backend/tests/test_marketplace_catalog_models.py +++ b/dashboard/backend/tests/test_marketplace_catalog_models.py @@ -21,31 +21,89 @@ _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": ("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["template_id"] != "ai-hedge-fund" + } + assert catalog_names == board_names + + +def test_retired_strategy_templates_are_gone(): + retired = { + "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", + } + present = {template["template_id"] for template in _CATALOG} + assert not (retired & present) + + +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} diff --git a/dashboard/config/marketplace.json b/dashboard/config/marketplace.json index 11e207b0..6259980e 100644 --- a/dashboard/config/marketplace.json +++ b/dashboard/config/marketplace.json @@ -1,16 +1,20 @@ { "templates": [ { - "template_id": "balanced-starter", - "name": "Balanced Starter", + "template_id": "claude-haiku-4-5", + "shelf": "llms", + "name": "Claude Haiku 4.5", "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.", + "description": "The Competition Leaderboard's Claude Haiku 4.5 agent. Same long-only DJIA hourly backtest as the rest of the board \u2014 add it and edit the instruction to try this model yourself.", "category": "us_stocks", - "tags": ["starter", "diversified"], + "tags": [ + "competition model", + "DJIA" + ], "author": "Agentic Trading Lab", "pipeline": [ { - "id": "sub_balanced_starter", + "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.", @@ -19,211 +23,161 @@ ] }, { - "template_id": "momentum-scout", - "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"], - "author": "Agentic Trading Lab", - "pipeline": [ - { - "id": "sub_momentum_instruction", - "presetKey": "simple_instruction", - "label": "Trading instruction", - "prompt": "Prioritize stocks showing the strongest recent momentum and healthy volume. Add to winners on pullbacks, cut positions that lose momentum, and keep cash when the tape is unclear.", - "outputFormat": "JSON: { \"orders\": [{ \"symbol\": \"...\", \"side\": \"buy|sell|hold\", \"qty\": number, \"order_type\": \"market|limit\", \"limit_price\": number|null, \"reason\": \"...\" }] }" - } - ] - }, - { - "template_id": "pipeline-analyst", - "name": "Three-Step Analyst", + "template_id": "claude-sonnet-4-6", + "shelf": "llms", + "name": "Claude Sonnet 4.6", "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"], - "author": "Agentic Trading Lab", - "pipeline": [ - { - "id": "sub_gather", - "presetKey": "info_gather", - "label": "Information Gathering", - "prompt": "You are the information-gathering sub-agent. Collect key facts relevant to trading decisions from market data, news, and macro events. Filter noise and keep high-confidence facts and indicator changes.", - "outputFormat": "JSON: { \"timestamp\": \"ISO8601\", \"symbols\": [\"...\"], \"facts\": [{ \"source\": \"...\", \"summary\": \"...\", \"impact\": \"bullish|bearish|neutral\" }], \"confidence\": 0.0-1.0 }" - }, - { - "id": "sub_signal", - "presetKey": "info_to_signal", - "label": "Information to Signal", - "prompt": "You are the signal-generation sub-agent. Based on upstream information-gathering output, convert facts and indicators into executable trading signals (direction, strength, time horizon).", - "outputFormat": "JSON: { \"signals\": [{ \"symbol\": \"...\", \"direction\": \"long|short|flat\", \"strength\": 0.0-1.0, \"horizon\": \"1h|4h|1d\", \"rationale\": \"...\" }] }" - }, - { - "id": "sub_exec", - "presetKey": "signal_to_execution", - "label": "Signal to Execution", - "prompt": "You are the trade-execution sub-agent. Turn signals into concrete order instructions, respecting position limits, liquidity, and slippage. Output a submit-ready buy/sell plan.", - "outputFormat": "JSON: { \"orders\": [{ \"symbol\": \"...\", \"side\": \"buy|sell|hold\", \"qty\": number, \"order_type\": \"market|limit\", \"limit_price\": number|null, \"reason\": \"...\" }] }" - } - ] - }, - { - "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", - "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.", + "description": "The Competition Leaderboard's Claude Sonnet 4.6 agent. Same long-only DJIA hourly backtest as the rest of the board \u2014 add it and edit the instruction to try this model yourself.", "category": "us_stocks", - "tags": ["buy and hold", "blue chips"], + "tags": [ + "competition model", + "DJIA" + ], "author": "Agentic Trading Lab", "pipeline": [ { - "id": "sub_blue_chip_steady", + "id": "sub_claude_sonnet", "presetKey": "simple_instruction", "label": "Trading instruction", - "prompt": "Buy and hold a handful of the strongest Dow companies. Sell only if a company's position deteriorates badly. Do not chase short-term moves.", + "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": "even-split-dow", - "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.", + "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 \u2014 the only model that has beaten the passive baselines on that board. Same long-only DJIA hourly backtest; add it and edit the instruction to try this model yourself.", "category": "us_stocks", - "tags": ["equal weight", "diversified"], - "author": "Agentic Trading Lab", - "pipeline": [ - { - "id": "sub_even_split_dow", - "presetKey": "simple_instruction", - "label": "Trading instruction", - "prompt": "Spread the money evenly across all available Dow stocks and keep the split even, rebalancing when any position drifts far from its equal share.", - "outputFormat": "JSON: { \"orders\": [{ \"symbol\": \"...\", \"side\": \"buy|sell|hold\", \"qty\": number, \"order_type\": \"market|limit\", \"limit_price\": number|null, \"reason\": \"...\" }] }" - } - ] - }, - { - "template_id": "ashare-steady-t1", - "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": [ + "competition model", + "DJIA" + ], "author": "Agentic Trading Lab", "pipeline": [ { - "id": "sub_ashare_steady", + "id": "sub_deepseek_v4", "presetKey": "simple_instruction", "label": "Trading instruction", - "prompt": "Trade the available Chinese A-share stocks patiently. Because shares bought today cannot be sold until the next trading day, avoid quick in-and-out trades; build positions you are willing to hold overnight.", + "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": "contrarian-dip-buyer", - "name": "Contrarian Dip Buyer", + "template_id": "gpt-5-5", + "shelf": "llms", + "name": "GPT-5.5", "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.", + "description": "The Competition Leaderboard's GPT-5.5 agent. Same long-only DJIA hourly backtest as the rest of the board \u2014 add it and edit the instruction to try this model yourself.", "category": "us_stocks", "tags": [ - "contrarian", - "mean reversion" + "competition model", + "DJIA" ], "author": "Agentic Trading Lab", "pipeline": [ { - "id": "sub_contrarian_dip", + "id": "sub_gpt_5_5", "presetKey": "simple_instruction", "label": "Trading instruction", - "prompt": "Look for stocks that have fallen well below where they were trading recently and buy those, in small pieces rather than all at once. Sell back into strength once a position has recovered. Do not chase stocks that are already running.", + "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": "sector-rotator", - "name": "Sector Rotator", + "template_id": "gemini-3-1-pro", + "shelf": "llms", + "name": "Gemini 3.1 Pro Preview", "model_name": "google/gemini-3.1-pro-preview", - "description": "Concentrates into whichever part of the market is leading, and moves on when leadership changes.", + "description": "The Competition Leaderboard's Gemini 3.1 Pro Preview agent. Same long-only DJIA hourly backtest as the rest of the board \u2014 add it and edit the instruction to try this model yourself.", "category": "us_stocks", "tags": [ - "rotation", - "trend" + "competition model", + "DJIA" ], "author": "Agentic Trading Lab", "pipeline": [ { - "id": "sub_sector_rotator", + "id": "sub_gemini_pro", "presetKey": "simple_instruction", "label": "Trading instruction", - "prompt": "Group the available stocks by the kind of business they are in. Put most of the money into the group that has been performing best, and hold two or three names from it rather than one. When a different group takes the lead, sell out of the old one before building the new position.", + "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": "volatility-guard", - "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.", + "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 \u2014 add it and edit the instruction to try this model yourself.", "category": "us_stocks", "tags": [ - "risk management", - "defensive" + "competition model", + "DJIA" ], "author": "Agentic Trading Lab", "pipeline": [ { - "id": "sub_volatility_guard", + "id": "sub_nemotron_nano", "presetKey": "simple_instruction", "label": "Trading instruction", - "prompt": "Judge how violently prices have been moving lately compared with earlier in the period. While things are calm, stay invested across several stocks. When the swings get noticeably larger, sell part of every position and hold the cash rather than switching stocks. Rebuild the positions gradually once the market settles down. Protecting the money matters more here than catching every rally.", + "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": "ashare-momentum-t1", - "name": "A-Share Momentum (T+1)", + "template_id": "qwen3-7-plus", + "shelf": "llms", + "name": "Qwen3.7 Plus", "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.", - "category": "cn_ashares", + "description": "The Competition Leaderboard's Qwen3.7 Plus agent. Same long-only DJIA hourly backtest as the rest of the board \u2014 add it and edit the instruction to try this model yourself.", + "category": "us_stocks", "tags": [ - "a-shares", - "momentum" + "competition model", + "DJIA" ], "author": "Agentic Trading Lab", "pipeline": [ { - "id": "sub_ashare_momentum", + "id": "sub_qwen3_7", "presetKey": "simple_instruction", "label": "Trading instruction", - "prompt": "Buy the Chinese A-shares that have been climbing most steadily and hold them while they keep leading. Shares bought today cannot be sold until the next trading day, so only buy what you are happy to still own tomorrow, and plan any exit at least a day ahead. Sell a position once it stops leading.", + "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" + ] + } } ] } diff --git a/dashboard/frontend/app.html b/dashboard/frontend/app.html index bd5c69eb..bc520a61 100644 --- a/dashboard/frontend/app.html +++ b/dashboard/frontend/app.html @@ -1727,17 +1727,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.

-
-
+
diff --git a/dashboard/frontend/app.js b/dashboard/frontend/app.js index 2e6a9853..7e248f82 100644 --- a/dashboard/frontend/app.js +++ b/dashboard/frontend/app.js @@ -2191,6 +2191,24 @@ async function loadAgentsNow() { let marketplaceTemplates = []; let marketplaceCloneInFlight = false; let marketplaceLoadInFlight = null; +/** null = contest board not fetched yet (card stats show a reserved loading + * state). [] = fetched, including a failed fetch — 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: 'Open Agents', sub: 'Open-source 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'; @@ -2216,16 +2234,16 @@ let marketplaceVendorFilter = 'all'; * renderMarketplaceVendorChips), so listing a vendor here never ships an * empty chip. */ 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' }, + { 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": 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: '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. @@ -2256,6 +2274,14 @@ function formatModelProviderLabel(modelName) { return vendor ? `Powered by ${vendor.label}` : 'AI-powered'; } +/** Company name for a tile subtitle (Anthropic, NVIDIA). Distinct from + * formatModelProviderLabel, which is the older "Powered by Claude" line. */ +function formatModelCompanyLabel(modelName) { + const key = modelVendorKey(modelName); + const vendor = MODEL_VENDORS.find((entry) => entry.key === key); + return vendor ? (vendor.company || vendor.label) : ''; +} + /** Select a Community category chip and re-render, without a route or API * change -- this is in-memory UI state, not navigation. Used by the chip * row's own click handler for in-page filtering while already on Community. @@ -2361,15 +2387,20 @@ function marketplaceEmptyHtml({ searching, categoryFilter, vendorFilter }) { return 'No templates match your search.'; } +function templateMarketplaceShelf(template) { + const explicit = String(template?.shelf || '').toLowerCase(); + if (explicit === 'llms' || explicit === 'open') return explicit; + return (template?.mode === 'runtime' || template?.runtime_type === 'ai_hedge_fund') + ? '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 +2408,7 @@ function getFilteredMarketplaceTemplates() { template.description, template.category, template.author, + template.card_subtitle, ...(template.tags || []), template.model_name, ] @@ -2389,6 +2421,314 @@ 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; + const xOf = (i) => left + (n <= 1 ? 0 : (i / (n - 1)) * plotW); + 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, xOf, 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, + medianReturn: null, + positiveRuns: null, + totalRuns: 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) { + const n = Number(value); + if (!Number.isFinite(n)) return null; + const pct = n * 100; + 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 || 'Open-source trading agent') + : [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} + ` + : (isOpen ? '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

+
+
+ ${escapeHtml(returnValue)} + Return +
+ ${chartHtml} +
+
+ ${contestMeta}` + : ''; + + 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,18 +2736,18 @@ 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, }); @@ -2421,73 +2761,32 @@ function renderMarketplaceGrid() { } 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 +2805,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() { @@ -2558,7 +2826,31 @@ function renderMarketplaceError() { * skip the network entirely. A failure clears the cache, so the next visit * retries rather than showing the error forever. */ +async function loadMarketplaceLeaderboard() { + if (marketplaceLeaderboardEntries !== null) return; + if (typeof leaderboardPayload !== 'undefined' && 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); + applyMarketplaceLeaderboardPayload({ entries: [] }); + } finally { + marketplaceLeaderboardLoadInFlight = null; + renderMarketplaceGrid(); + } + })(); + return marketplaceLeaderboardLoadInFlight; +} + async function loadMarketplace() { + loadMarketplaceLeaderboard(); if (marketplaceTemplates.length) { renderMarketplaceGrid(); return; diff --git a/dashboard/frontend/styles.css b/dashboard/frontend/styles.css index 93a012fb..9034d94c 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,11 +10897,218 @@ 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 .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: 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)); +} + +.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); + font-weight: 600; + font-size: 1.2rem; +} + +.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: 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; + gap: 6px; + margin: 0; + font-size: 0.75rem; + font-weight: 600; + color: var(--text-muted, var(--text-secondary)); +} + +.mp-contest-meta-icon { + width: 13px; + height: 13px; + flex-shrink: 0; + color: #60a5fa; +} + +@media (max-width: 1280px) { + .marketplace-shelf-grid { + grid-template-columns: repeat(4, minmax(0, 1fr)); + } +} + +@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-body { @@ -10910,11 +11118,18 @@ body.agent-editor-open { flex: 1; } +.marketplace-card .agent-card-submeta { + height: auto; + white-space: normal; + overflow: visible; + text-overflow: unset; +} + .marketplace-card-description { margin: 0; color: var(--text-secondary); - line-height: 1.5; - font-size: 0.92rem; + line-height: 1.45; + font-size: 0.88rem; } .marketplace-card-meta { From a4a617a83f1da19b77df1586e6dfe2c3a5756df7 Mon Sep 17 00:00:00 2001 From: FlyM1ss Date: Mon, 31 Aug 2026 23:49:20 +0800 Subject: [PATCH 2/4] fix(community): review follow-ups for the two-shelf supermarket MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Correctness: - formatMarketplaceReturnPct: null/'' is "no data", not 0% — Number(null) is 0, so a failed leaderboard fetch painted every LLM card with a green "0.0%" instead of the muted em-dash; sign now chosen after rounding so -0.04% renders "0.0%", not "-0.0%". - Mini chart: scale the DJIA path by its own point count — reusing the agent-length x-scale drew the benchmark past the plot edge whenever the two downsampled curves differed in length (align_equity_curve_asof can legitimately return shorter curves). - loadMarketplaceLeaderboard: only reuse the shared leaderboardPayload when it is the CONTEST board (js/leaderboard.js writes the same global for period=live), and reset the cache to null on a failed fetch so the next Community visit retries instead of pinning blank stats; the catalog docstring this PR displaced moves back onto loadMarketplace. - marketplace.json: rename gemini-3-1-pro -> gemini-3-1-pro-preview so the entry_id fallback join ('-'->'_') actually matches the board's gemini_3_1_pro_preview; scope the DeepSeek "only model that beat the baselines" claim to the fixed April 2026 contest window. Tests: - Pin the REAL join key: catalog names must equal the board entries' `model` strings (the API never exposes `name`), plus a new guard that every llms template_id maps onto its leaderboard entry_id. - Pin the 7 LLM card prompts to DEFAULT_STARTER_INSTRUCTION. - Bump app.js/styles.css cache-busters (v125/v131) and their four pins. Cleanup (dead code this redesign orphaned): - Remove the vendor-chip subsystem left wired to the deleted container: marketplaceVendorFilter/setMarketplaceVendorFilter/ renderMarketplaceVendorChips, the dead listener, the unreachable both-filters empty state, and formatModelProviderLabel; guard tests now assert the machinery stays gone. - Drop orphaned CSS (vendor chips, licence badge, clone-split/model menu, card-body/meta/tag rows) and the no-op 1280px breakpoint. - marketplace.py: the sort-key fallback branch was unreachable; templateMarketplaceShelf uses the generic runtime fallback instead of a per-runtime special case; drop never-read stats fields. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_011ww26Uycvhrf4C5jShgQ6r --- .../backend/domain/agents/marketplace.py | 9 +- .../tests/test_admin_analytics_frontend.py | 4 +- .../backend/tests/test_analytics_frontend.py | 2 +- .../test_backtest_comparison_frontend.py | 4 +- .../backend/tests/test_frontend_fast_boot.py | 4 +- .../tests/test_frontend_model_facets.py | 174 ++++-------------- .../tests/test_marketplace_catalog_models.py | 45 ++++- dashboard/config/marketplace.json | 4 +- dashboard/frontend/app.html | 4 +- dashboard/frontend/app.js | 173 ++++++----------- dashboard/frontend/styles.css | 102 ---------- 11 files changed, 146 insertions(+), 379 deletions(-) diff --git a/dashboard/backend/domain/agents/marketplace.py b/dashboard/backend/domain/agents/marketplace.py index e6befb4a..85602ddc 100644 --- a/dashboard/backend/domain/agents/marketplace.py +++ b/dashboard/backend/domain/agents/marketplace.py @@ -108,12 +108,9 @@ def list_marketplace_templates() -> List[Dict[str, Any]]: order without a second sort key. """ items = [_public_template(raw) for raw in _load_catalog().values()] - return sorted( - items, - key=lambda t: MARKETPLACE_SHELVES.index(t["shelf"]) - if t.get("shelf") in MARKETPLACE_SHELVES - else len(MARKETPLACE_SHELVES), - ) + # _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 96e2456f..13e4921a 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=124' in APP_HTML + assert 'styles.css?v=131' in APP_HTML + assert 'app.js?v=125' 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_analytics_frontend.py b/dashboard/backend/tests/test_analytics_frontend.py index 2a878b25..b6f61650 100644 --- a/dashboard/backend/tests/test_analytics_frontend.py +++ b/dashboard/backend/tests/test_analytics_frontend.py @@ -41,7 +41,7 @@ def _function_body(source: str, signature: str) -> str: def test_analytics_script_loads_between_app_and_page_scripts(): - app_at = APP_HTML.index('') + app_at = APP_HTML.index('') analytics_at = APP_HTML.index( '' ) diff --git a/dashboard/backend/tests/test_backtest_comparison_frontend.py b/dashboard/backend/tests/test_backtest_comparison_frontend.py index 37e8c0eb..7bd67298 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=131"' 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 37b45204..34108882 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=124" in APP_HTML + assert "app.js?v=125" 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=131" 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 5afdbe9a..9da53ccf 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 @@ -151,28 +122,26 @@ def test_vendor_chip_container_exists_in_the_community_view(): assert 'id="marketplaceCategoryChips"' in community -def test_vendor_chips_are_derived_not_hardcoded(): - """Chips come from MODEL_VENDORS intersected with the loaded catalog, so a - vendor with no templates never ships an empty chip.""" - body = fn_body("function renderMarketplaceVendorChips") - assert "MODEL_VENDORS" in body - assert "marketplaceTemplates" in body - for literal in ("'anthropic'", "'openai'", "'deepseek'", "'qwen'"): - assert literal not in body, f"{literal} hardcoded in the chip builder" - +def test_vendor_chip_machinery_is_fully_gone(): + """The chip row's container left app.html with this redesign; the renderer, + its filter state and the both-filters empty state must not outlive it as + silent no-ops.""" + for remnant in ( + "renderMarketplaceVendorChips", + "marketplaceVendorFilter", + "setMarketplaceVendorFilter", + "marketplace-clear-filters", + ): + assert remnant not in APP_JS, remnant -def test_vendor_chips_are_built_once_then_toggled(): - """renderMarketplaceGrid runs on every search keystroke; rebuilding innerHTML - per keystroke would blow away the focused chip.""" - body = fn_body("function renderMarketplaceVendorChips") - assert "existing.length !== chips.length" in body - -def test_three_empty_states_stay_distinguishable(): +def test_empty_states_stay_distinguishable(): + """Search-empty vs market-empty keep distinct copy (the vendor chips are + gone, and the old both-filters branch went with them).""" body = fn_body("function marketplaceEmptyHtml") assert "No templates match your search." in body - assert "No templates match both filters" in body - assert "marketplace-clear-filters" in body + assert "templates yet." in body + assert "vendorFilter" not in body @pytest.mark.skipif(shutil.which("node") is None, reason="node is not installed") @@ -180,13 +149,11 @@ def test_empty_state_precedence(): script = f""" function escapeHtml(s) {{ return String(s); }} const MARKET_LABELS = {{ us_stocks: 'U.S.', cn_ashares: 'China A-Share' }}; -{js_const("MODEL_VENDORS")} {fn_body("function marketplaceEmptyHtml")} const out = [ - marketplaceEmptyHtml({{searching: true, categoryFilter: 'us_stocks', vendorFilter: 'qwen'}}), - marketplaceEmptyHtml({{searching: false, categoryFilter: 'us_stocks', vendorFilter: 'qwen'}}), - marketplaceEmptyHtml({{searching: false, categoryFilter: 'us_stocks', vendorFilter: 'all'}}), - marketplaceEmptyHtml({{searching: false, categoryFilter: 'all', vendorFilter: 'all'}}), + marketplaceEmptyHtml({{searching: true, categoryFilter: 'us_stocks'}}), + marketplaceEmptyHtml({{searching: false, categoryFilter: 'us_stocks'}}), + marketplaceEmptyHtml({{searching: false, categoryFilter: 'all'}}), ]; console.log(JSON.stringify(out)); """ @@ -194,11 +161,10 @@ def test_empty_state_precedence(): ["node", "-e", script], capture_output=True, text=True, timeout=30 ) assert result.returncode == 0, result.stderr - search_empty, both, one_chip, none_at_all = json.loads(result.stdout) - # A typed query wins: clearing the chips would not bring anything back. + search_empty, market_only, none_at_all = json.loads(result.stdout) + # A typed query wins: clearing the chip would not bring anything back. assert search_empty == "No templates match your search." - assert "both filters" in both and "marketplace-clear-filters" in both - assert "U.S." in one_chip and "both filters" not in one_chip + assert "U.S." in market_only and market_only.endswith("templates yet.") assert none_at_all == "No templates match your search." @@ -262,66 +228,6 @@ def test_shipped_filter_ands_market_and_vendor_not_or(): assert set(data["marketAll"]) == {"t1", "t2", "t3", "t4", "t5"} -@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"] - - def test_only_open_weight_models_get_a_badge(): """LLM tiles no longer claim licence. Open Agents use an explicit Open Source mark; closed models get nothing.""" @@ -333,12 +239,6 @@ def test_only_open_weight_models_get_a_badge(): 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" - - _CARD_HELPERS = f""" {fn_body("function escapeHtml")} {fn_body("function agentRobotIcon")} @@ -775,17 +675,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. +def test_entering_community_resets_the_category_filter(): + """A chip 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. - - 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'") @@ -793,11 +688,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") diff --git a/dashboard/backend/tests/test_marketplace_catalog_models.py b/dashboard/backend/tests/test_marketplace_catalog_models.py index 816d0bd1..643b21f3 100644 --- a/dashboard/backend/tests/test_marketplace_catalog_models.py +++ b/dashboard/backend/tests/test_marketplace_catalog_models.py @@ -30,7 +30,7 @@ "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": ("google/gemini-3.1-pro-preview", "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"), @@ -78,6 +78,49 @@ def test_every_leaderboard_llm_has_a_supermarket_card(): if template["template_id"] != "ai-hedge-fund" } 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_retired_strategy_templates_are_gone(): diff --git a/dashboard/config/marketplace.json b/dashboard/config/marketplace.json index 6259980e..21e34f4c 100644 --- a/dashboard/config/marketplace.json +++ b/dashboard/config/marketplace.json @@ -49,7 +49,7 @@ "shelf": "llms", "name": "DeepSeek V4 Pro", "model_name": "deepseek/deepseek-v4-pro", - "description": "The Competition Leaderboard's DeepSeek V4 Pro agent \u2014 the only model that has beaten the passive baselines on that board. Same long-only DJIA hourly backtest; add it and edit the instruction to try this model yourself.", + "description": "The Competition Leaderboard's DeepSeek V4 Pro agent \u2014 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", @@ -89,7 +89,7 @@ ] }, { - "template_id": "gemini-3-1-pro", + "template_id": "gemini-3-1-pro-preview", "shelf": "llms", "name": "Gemini 3.1 Pro Preview", "model_name": "google/gemini-3.1-pro-preview", diff --git a/dashboard/frontend/app.html b/dashboard/frontend/app.html index bc520a61..5cc7190f 100644 --- a/dashboard/frontend/app.html +++ b/dashboard/frontend/app.html @@ -13,7 +13,7 @@ because every API call is a CORS request. --> - + @@ -2477,7 +2477,7 @@

Refund Credits purchase

- + diff --git a/dashboard/frontend/app.js b/dashboard/frontend/app.js index 7e248f82..8b9a1b92 100644 --- a/dashboard/frontend/app.js +++ b/dashboard/frontend/app.js @@ -2191,8 +2191,9 @@ async function loadAgentsNow() { let marketplaceTemplates = []; let marketplaceCloneInFlight = false; let marketplaceLoadInFlight = null; -/** null = contest board not fetched yet (card stats show a reserved loading - * state). [] = fetched, including a failed fetch — never invent ranks. */ +/** 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. */ @@ -2213,34 +2214,25 @@ const MARKETPLACE_SHELVES = [ * 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', 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": this label also feeds - // formatModelProviderLabel, whose shipped output must not change. + // "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' }, @@ -2268,14 +2260,7 @@ function modelVendorLicence(modelName) { return (MODEL_VENDORS.find((vendor) => vendor.key === key) || {}).licence || ''; } -function formatModelProviderLabel(modelName) { - const key = modelVendorKey(modelName); - const vendor = MODEL_VENDORS.find((entry) => entry.key === key); - return vendor ? `Powered by ${vendor.label}` : 'AI-powered'; -} - -/** Company name for a tile subtitle (Anthropic, NVIDIA). Distinct from - * formatModelProviderLabel, which is the older "Powered by Claude" line. */ +/** Company name for a tile subtitle (Anthropic, NVIDIA). */ function formatModelCompanyLabel(modelName) { const key = modelVendorKey(modelName); const vendor = MODEL_VENDORS.find((entry) => entry.key === key); @@ -2297,15 +2282,6 @@ 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 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 @@ -2334,65 +2310,24 @@ 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; - return (template?.mode === 'runtime' || template?.runtime_type === 'ai_hedge_fund') - ? 'open' - : 'llms'; + // 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() { @@ -2542,7 +2477,11 @@ function buildMarketplaceCompareChartHtml(agentCurve, benchmarkCurve, { positive const plotW = w - left - right; const plotH = plotBottom - top; const n = agent.length; - const xOf = (i) => left + (n <= 1 ? 0 : (i / (n - 1)) * plotW); + // 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) => { @@ -2557,7 +2496,9 @@ function buildMarketplaceCompareChartHtml(agentCurve, benchmarkCurve, { positive return `${escapeHtml(label)}`; }).join(''); const agentPath = marketplaceLinePath(agent, xOf, yOf); - const benchPath = bench && bench.length >= 2 ? marketplaceLinePath(bench, xOf, yOf) : ''; + const benchPath = bench && bench.length >= 2 + ? marketplaceLinePath(bench, (i) => xAt(i, bench.length), yOf) + : ''; return ` ` : ''; const description = isOpen ? String(template.description || '').trim() : ''; diff --git a/dashboard/frontend/styles.css b/dashboard/frontend/styles.css index 6f0cfdfe..c2413f5d 100644 --- a/dashboard/frontend/styles.css +++ b/dashboard/frontend/styles.css @@ -10973,6 +10973,14 @@ body.agent-editor-open { min-width: 0; } +.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; @@ -10980,6 +10988,7 @@ body.agent-editor-open { letter-spacing: 0.08em; text-transform: uppercase; color: var(--text-muted, var(--text-secondary)); + flex-shrink: 0; } .mp-competition-body { @@ -11073,11 +11082,14 @@ body.agent-editor-open { .mp-contest-meta { display: flex; align-items: center; + justify-content: flex-end; gap: 6px; margin: 0; - font-size: 0.75rem; + font-size: 0.72rem; font-weight: 600; color: var(--text-muted, var(--text-secondary)); + min-width: 0; + text-align: right; } .mp-contest-meta-icon {