Strict, testable scenarios for the equity= URL query parameter parser.
These define the v1 acceptance contract — any behavior not listed here is undefined and must be rejected.
v1 is equal-weight only. Every ticker in a portfolio receives weight 1/N. Custom weights are reserved for v2 (see §7 and §14).
URL format (single portfolio):
?equity=TICKER,TICKER,...URL format (multi-portfolio):?equity=TICKER,TICKER,...&equity=TICKER,TICKER,...
The following invariants are pinned by the v1 contract and enforced by unit tests. Any change to these is a breaking change. This section is the SCENARIOS.md counterpart to the v1 Parsing Invariants (Contract) section in the agent doc.
- Exact error strings are pinned. The colon-rejection message (
"Weights (:) are not supported in v1. Use a comma-separated list of tickers like \"AAPL,MSFT\".") is established in #117/#114 and tested with exact-match assertions (.toBe()). See §7 (especially §7.7–§7.10) for the full specification. - Reserved-character rejection covers all entry points. Both the client-side parser (
common/parser.ts) and the server-side validate endpoint (app/api/compare/validate/route.ts) reject:and=with the same pinned messages. See §7.9 for the entry-point coverage table. - Validation pipeline order is the contract. The fail-fast, left-to-right, first-error-wins pipeline defined below is normative. Implementation must follow it.
- Test assertion strategy: Colon-rejection tests use exact match (
.toBe()), not substring matching. See §7.10 for the rationale and policy.
The user-facing query parameter for specifying equities is equity (singular). This was chosen over alternatives (portfolios, tickers, stocks) for consistency with the codebase and URL brevity. The API route internally uses tickers (see LIL-INTDEV-AGENTS.md §3.1), but the client-side parser reads equity from the browser URL.
When the parser processes the equity param(s), checks run in this order per portfolio, and the first failure stops all processing (fail-fast):
- Portfolio count — reject if
> MAX_PORTFOLIOS(5) - Empty value — reject if an
equity=param has an empty string value - Split on comma — tokenize the value
- Per-token, left to right:
a. Trim whitespace (leading/trailing)
b. Empty token — reject if token is empty after trim
c. Reserved characters — reject if token contains
:or=(v2-reserved). For:, use pinned error from #117 (see §7.7) d. Illegal characters — reject if token contains chars outside[A-Za-z0-9.\-]e. Must start with letter — reject if first char is not[A-Za-z]f. Max length — reject if token length >MAX_TICKER_LENGTH(10) g. Uppercase — normalize to uppercase h. Duplicate check — reject if ticker already seen in this portfolio (post-normalization) - Ticker count — reject if portfolio has
> MAX_TICKERS_PER_PORTFOLIO(20)
Portfolios are processed left to right (first equity= param is portfolio 1, etc.). Processing halts on the first error across all portfolios.
Given the URL query string is "?equity=AAPL"
When the v1 query parser processes the input
Then portfolio 1 contains ["AAPL"]
And no error is returned
Given the URL query string is "?equity=AAPL,MSFT,GOOG"
When the v1 query parser processes the input
Then portfolio 1 contains ["AAPL", "MSFT", "GOOG"]
And no error is returned
Given the URL query string is "?equity=GOOG,AAPL,MSFT"
When the v1 query parser processes the input
Then portfolio 1 contains ["GOOG", "AAPL", "MSFT"]
And the order matches the input order exactly
Given the URL query string is "?equity=AAPL,MSFT&equity=GOOG,TSLA"
When the v1 query parser processes the input
Then portfolio 1 contains ["AAPL", "MSFT"]
And portfolio 2 contains ["GOOG", "TSLA"]
And no error is returned
Given the URL query string is "?equity=AAPL&equity=MSFT&equity=GOOG"
When the v1 query parser processes the input
Then portfolio 1 contains ["AAPL"]
And portfolio 2 contains ["MSFT"]
And portfolio 3 contains ["GOOG"]
And no error is returned
Given the URL query string is "?equity=aapl,msft"
When the v1 query parser processes the input
Then portfolio 1 contains ["AAPL", "MSFT"]
And no error is returned
Given the URL query string is "?equity=Aapl,mSfT,gOOg"
When the v1 query parser processes the input
Then portfolio 1 contains ["AAPL", "MSFT", "GOOG"]
And no error is returned
Given the URL query string is "?equity= AAPL , MSFT "
When the v1 query parser processes the input
Then portfolio 1 contains ["AAPL", "MSFT"]
And no error is returned
Given the URL query string is "?equity=AAPL, ,MSFT"
When the v1 query parser processes the input
Then an error is returned with message: "Empty ticker at position 2"
Given the URL query string is "?equity= AAPL,MSFT "
When the v1 query parser processes the input
Then portfolio 1 contains ["AAPL", "MSFT"]
And no error is returned
Given the URL query string is "?equity=AAPL,MSFT,AAPL"
When the v1 query parser processes the input
Then an error is returned with message: "Duplicate ticker: AAPL"
Given the URL query string is "?equity=AAPL,aapl"
When the v1 query parser processes the input
Then an error is returned with message: "Duplicate ticker: AAPL"
Given the URL query string is "?equity=msft, Msft"
When the v1 query parser processes the input
Then an error is returned with message: "Duplicate ticker: MSFT"
Given the URL query string is "?equity=AAPL,MSFT&equity=AAPL,GOOG"
When the v1 query parser processes the input
Then portfolio 1 contains ["AAPL", "MSFT"]
And portfolio 2 contains ["AAPL", "GOOG"]
And no error is returned
MAX_TICKERS_PER_PORTFOLIO = 20
Given the URL query string is "?equity=A,B,C,D,E,F,G,H,I,J,K,L,M,N,O,P,Q,R,S,T"
When the v1 query parser processes the input
Then portfolio 1 contains ["A", "B", "C", "D", "E", "F", "G", "H", "I", "J", "K", "L", "M", "N", "O", "P", "Q", "R", "S", "T"]
And no error is returned
Given the URL query string is "?equity=A,B,C,D,E,F,G,H,I,J,K,L,M,N,O,P,Q,R,S,T,U"
When the v1 query parser processes the input
Then an error is returned with message: "Too many tickers in portfolio 1: 21 exceeds maximum of 20"
Given the URL query string is ""
When the v1 query parser processes the input
Then the parsed portfolios are []
And no error is returned
Given the URL query string is "?equity="
When the v1 query parser processes the input
Then an error is returned with message: "Empty equity parameter"
Given the URL query string is "?equity=,AAPL"
When the v1 query parser processes the input
Then an error is returned with message: "Empty ticker at position 1"
Given the URL query string is "?equity=AAPL,"
When the v1 query parser processes the input
Then an error is returned with message: "Empty ticker at position 2"
Given the URL query string is "?equity=AAPL,,MSFT"
When the v1 query parser processes the input
Then an error is returned with message: "Empty ticker at position 2"
Given the URL query string is "?equity=,,,"
When the v1 query parser processes the input
Then an error is returned with message: "Empty ticker at position 1"
v1 is strictly equal-weight. The colon (:) and equals (=) characters are reserved for v2 weight syntax (e.g. equity=AAPL:0.6,MSFT:0.4). Any occurrence of : or = inside a ticker token — literal or URL-encoded (%3A, %3D) — must be rejected. For colons, the pinned v1 error message (established in #117/#114) is:
Weights (:) are not supported in v1. Use a comma-separated list of tickers like "AAPL,MSFT".
Given the URL query string is "?equity=AAPL:0.5"
When the v1 query parser processes the input
Then an error is returned with message: "Weights (:) are not supported in v1. Use a comma-separated list of tickers like \"AAPL,MSFT\"."
Given the URL query string is "?equity=AAPL%3A0.5"
When the v1 query parser processes the input
Then an error is returned with message: "Weights (:) are not supported in v1. Use a comma-separated list of tickers like \"AAPL,MSFT\"."
Implementation note: URL decoding happens before parsing, so
%3Abecomes:and is caught by the same colon-rejection rule.
Given the URL query string is "?equity=AAPL=0.5"
When the v1 query parser processes the input
Then an error is returned with message: "Invalid character '=' in ticker 'AAPL=0.5' — equals signs are reserved"
Given the URL query string is "?equity=AAPL%3D0.5"
When the v1 query parser processes the input
Then an error is returned with message: "Invalid character '=' in ticker 'AAPL=0.5' — equals signs are reserved"
Implementation note: Same as colon — URL decoding happens before parsing, so
%3Dbecomes=and is caught by the same rule.
Given the URL query string is "?equity=AAPL,MSFT:0.3,GOOG"
When the v1 query parser processes the input
Then an error is returned with message: "Weights (:) are not supported in v1. Use a comma-separated list of tickers like \"AAPL,MSFT\"."
Given the URL query string is "?equity=AAPL;MSFT"
When the v1 query parser processes the input
Then an error is returned with message: "Invalid character ';' in ticker 'AAPL;MSFT'"
Given the URL query string is "?equity=AAPL|MSFT"
When the v1 query parser processes the input
Then an error is returned with message: "Invalid character '|' in ticker 'AAPL|MSFT'"
Pinned error string: The user-facing error for any
:weight syntax MUST be exactly the message below. This was established in #117/#114 and is the canonical v1 rejection message for weight syntax.
Given the URL query string is "?equity=AAPL:0.5"
When the v1 query parser processes the input
Then an error is returned with message: "Weights (:) are not supported in v1. Use a comma-separated list of tickers like \"AAPL,MSFT\"."
And no API fetch is made
And no chart is rendered
Given the URL query string is "?equity=AAPL:0.5,MSFT"
When the v1 query parser processes the input
Then an error is returned with message: "Weights (:) are not supported in v1. Use a comma-separated list of tickers like \"AAPL,MSFT\"."
And no API fetch is made
And no chart is rendered
Implementation note: The
:is detected during per-token validation (step 4c in the validation pipeline). The error message is the same regardless of whether the colon appears in the first or a subsequent token — the pinned string from #117 takes precedence over the per-character error format used in §7.1–7.4. Scenarios 7.1–7.4 are updated to use this canonical message. See also #114, #118, #121.
The : rejection contract applies everywhere user-entered tickers or portfolios are parsed. In v1, the only query parameter that accepts ticker input is equity=. This parameter is parsed in two code paths:
| Entry point | File | How : is rejected |
|---|---|---|
| Client-side parser | common/parser.ts → parsePortfolios() |
Checks each token for : before any other character validation. Returns the pinned error string. |
| Server-side validate endpoint | app/api/compare/validate/route.ts |
Delegates to parsePortfolios() — same parser, same error. Returns HTTP 400 with { "error": "..." }. |
No other v1 query parameter (benchmark, range, amount) accepts ticker-like input, so : rejection is scoped to equity= only.
Concrete example — full URL showing expected failure:
URL: http://localhost:10000/?equity=AAPL:0.6,MSFT:0.4&benchmark=gold&range=1y
Result: HTTP 400 (via validate endpoint) / "Invalid query" error banner (via client parser)
Error: "Weights (:) are not supported in v1. Use a comma-separated list of tickers like \"AAPL,MSFT\"."
# Verify via the server-side validate endpoint:
curl -s "http://localhost:10000/api/compare/validate?equity=AAPL:0.6,MSFT:0.4" | jq '.error'
# Expected: "Weights (:) are not supported in v1. Use a comma-separated list of tickers like \"AAPL,MSFT\"."Why : is rejected: The colon character is reserved for v2 weight syntax (e.g., equity=AAPL:0.6,MSFT:0.4). Rejecting it in v1 ensures forward compatibility — users receive a clear message explaining the v2 reservation rather than a confusing parse failure. See §14 for the full v2 design and docs/weights-v2.md for the detailed v2 contract.
Tests for the : rejection use exact string equality (.toBe()), not substring matching (.toContain()). This is the default and recommended approach.
| Assertion method | Used for | Rationale |
|---|---|---|
Exact match (.toBe()) |
All colon-rejection tests (§7.1, §7.2, §7.4, §7.7, §7.8, §9.4, §12.3, §12.4, §14 table rows) | The pinned error string is a v1 contract commitment — any change to the message wording, punctuation, or casing is a breaking change that must be intentional. Exact match catches unintended drift. |
Substring match (.toContain()) |
Only §13.2 (generic "error includes context" check) | Used in the meta-test that verifies errors are debuggable, where the exact message format varies by error type. |
If a future change requires relaxing to substring matching, update this section to document the decision and the stable substring to assert (e.g., "Weights (:) are not supported in v1"). Until then, default to exact match.
MAX_TICKER_LENGTH = 10
Given the URL query string is "?equity=1234"
When the v1 query parser processes the input
Then an error is returned with message: "Invalid ticker format: '1234' — must start with a letter"
Given the URL query string is "?equity=AA$PL"
When the v1 query parser processes the input
Then an error is returned with message: "Invalid character '$' in ticker 'AA$PL'"
Given the URL query string is "?equity=AA PL"
When the v1 query parser processes the input
Then an error is returned with message: "Invalid character ' ' in ticker 'AA PL'"
Given the URL query string is "?equity=AAPL#B"
When the v1 query parser processes the input
Then an error is returned with message: "Invalid character '#' in ticker 'AAPL#B'"
Given the URL query string is "?equity=@AAPL"
When the v1 query parser processes the input
Then an error is returned with message: "Invalid character '@' in ticker '@AAPL'"
Given the URL query string is "?equity=ABCDEFGHIJK"
When the v1 query parser processes the input
Then an error is returned with message: "Ticker too long: 'ABCDEFGHIJK' exceeds 10 character limit"
Given the URL query string is "?equity=BRK.B"
When the v1 query parser processes the input
Then portfolio 1 contains ["BRK.B"]
And no error is returned
Given the URL query string is "?equity=BF-B"
When the v1 query parser processes the input
Then portfolio 1 contains ["BF-B"]
And no error is returned
Given the URL query string is "?equity=AAPL%2CMSFT"
When the v1 query parser processes the input
Then portfolio 1 contains ["AAPL", "MSFT"]
And no error is returned
Given the URL query string is "?equity=%20AAPL%20"
When the v1 query parser processes the input
Then portfolio 1 contains ["AAPL"]
And no error is returned
Given the URL query string is "?equity=+AAPL+"
When the v1 query parser processes the input
Then portfolio 1 contains ["AAPL"]
And no error is returned
Given the URL query string is "?equity=MSFT%3A0.5"
When the v1 query parser processes the input
Then an error is returned with message: "Weights (:) are not supported in v1. Use a comma-separated list of tickers like \"AAPL,MSFT\"."
MAX_PORTFOLIOS = 5
Each equity query parameter defines one portfolio. Multiple equity params define multiple portfolios for comparison. Each portfolio is independently validated.
Given the URL query string is "?equity=AAPL,MSFT&equity=GOOG,TSLA,NVDA"
When the v1 query parser processes the input
Then portfolio 1 contains ["AAPL", "MSFT"]
And portfolio 2 contains ["GOOG", "TSLA", "NVDA"]
And no error is returned
Given the URL query string is "?equity=AAPL&equity=MSFT&equity=GOOG&equity=TSLA&equity=NVDA"
When the v1 query parser processes the input
Then 5 portfolios are parsed, each with one ticker
And no error is returned
Given the URL query string is "?equity=A&equity=B&equity=C&equity=D&equity=E&equity=F"
When the v1 query parser processes the input
Then an error is returned with message: "Too many portfolios: 6 exceeds maximum of 5"
Given the URL query string is "?equity=AAPL,MSFT&equity=GOOG,,TSLA"
When the v1 query parser processes the input
Then an error is returned with message: "Empty ticker at position 2 in portfolio 2"
Given the URL query string is "?equity=AAPL&equity="
When the v1 query parser processes the input
Then an error is returned with message: "Empty equity parameter in portfolio 2"
Given the URL query string is "?equity=AAPL&foo=bar"
When the v1 query parser processes the input
Then portfolio 1 contains ["AAPL"]
And no error is returned
And the "foo" parameter is ignored
Given the URL query string is "?equity= aapl , AAPL "
When the v1 query parser processes the input
Then an error is returned with message: "Duplicate ticker: AAPL"
Given the URL query string is "?equity=A,B,C,D,E,F,G,H,I,J,K,L,M,N,O,P,Q,R,S,T,"
When the v1 query parser processes the input
Then an error is returned with message: "Empty ticker at position 21"
Given the URL query string is "?equity=AAPL:0.5,AAPL"
When the v1 query parser processes the input
Then an error is returned with message: "Weights (:) are not supported in v1. Use a comma-separated list of tickers like \"AAPL,MSFT\"."
Given the URL query string is "?equity=AAPL,MSFT&equity=GOOG:0.5"
When the v1 query parser processes the input
Then an error is returned with message: "Weights (:) are not supported in v1. Use a comma-separated list of tickers like \"AAPL,MSFT\"."
Note: The parser should reject on the first error encountered during left-to-right, portfolio-by-portfolio processing. It does not accumulate multiple errors.
Given the URL query string is "?equity=,AAPL:0.5,AAPL"
When the v1 query parser processes the input
Then an error is returned with message: "Empty ticker at position 1"
And parsing stops immediately
Given any error is returned by the v1 query parser
Then the error message includes the offending value or position
And the error message is a single human-readable sentence
Given an error occurs in portfolio N (where N > 1)
Then the error message includes "in portfolio N"
And the position is relative to that portfolio's token list
v2 will introduce custom per-ticker weights using the colon syntax:
?equity=AAPL:0.6,MSFT:0.4
In v1, all of the following are explicitly rejected:
| Input pattern | Rejection reason | Pinned error message (#117) |
|---|---|---|
AAPL:0.5 |
Colon reserved for v2 weight syntax | Weights (:) are not supported in v1. Use a comma-separated list of tickers like "AAPL,MSFT". |
AAPL%3A0.5 |
URL-encoded colon — same rule applies after decoding | (same as above) |
AAPL:60,MSFT:40 |
Colon reserved for v2 weight syntax | (same as above) |
AAPL:0.5,MSFT |
Colon in first token with valid second token | (same as above) |
AAPL=0.5 |
Equals sign reserved | Invalid character '=' in ticker 'AAPL=0.5' — equals signs are reserved |
AAPL%3D0.5 |
URL-encoded equals — same rule applies after decoding | (same as above) |
v2 design notes (out of scope for v1 implementation):
The full v2 weights contract — including token grammar, validation rules, error messages, and rebalancing semantics — is specified in
docs/weights-v2.md. The summary below captures the key decisions.
- Syntax: Weight values follow the colon:
TICKER:WEIGHT(e.g.,AAPL:0.6orAAPL:60%) - Format: Both decimal fractions (
0.6) and explicit percent (60%) are accepted. Bare integers > 1 without%are rejected as ambiguous (e.g.,AAPL:60→ error; useAAPL:60%orAAPL:0.6) - Sum constraint: All weights in a portfolio must sum to 1.0 (±0.01 tolerance for rounding)
- No negatives: Negative weights (short positions) are rejected
- No zeros: Zero-weight tickers are rejected — remove the ticker instead
- No duplicates: Same as v1 — duplicate tickers within a portfolio are rejected
- No mixing: Mixed weighted/unweighted tickers within a portfolio are rejected (all or nothing)
- Equal-weight fallback: If no ticker has a weight, v2 falls back to v1 equal-weight (1/N) behavior — full backward compatibility
- Per-portfolio independence: One portfolio can be weighted while another is equal-weight
- Rebalancing: Not in v2. Weights represent initial allocation; portfolio drifts with price changes (buy-and-hold). Rebalancing is deferred to v3 with a
rebalance=param - Precision: Max 4 decimal places per weight
The validation pipeline defines a strict step ordering (4a → 4h). When a single token violates multiple rules simultaneously, the parser must report the error from the earliest step in the pipeline. These scenarios pin that ordering so refactors cannot silently reorder checks.
Given the URL query string is "?equity=1AAPL:0.5"
When the v1 query parser processes the input
Then an error is returned with message: "Weights (:) are not supported in v1. Use a comma-separated list of tickers like \"AAPL,MSFT\"."
And the error is NOT "Invalid ticker format: '1AAPL:0.5' — must start with a letter"
Why this matters: Token
1AAPL:0.5violates both step 4c (reserved:) and step 4e (starts with digit). The pipeline mandates 4c fires first.
Given the URL query string is "?equity=ABCDEFGHIJK:99"
When the v1 query parser processes the input
Then an error is returned with message: "Weights (:) are not supported in v1. Use a comma-separated list of tickers like \"AAPL,MSFT\"."
And the error is NOT "Ticker too long: 'ABCDEFGHIJK:99' exceeds 10 character limit"
Why this matters: Token
ABCDEFGHIJK:99violates both step 4c (reserved:) and step 4f (length > 10). The pipeline mandates 4c fires first.
Given the URL query string is "?equity=aapl:0.5"
When the v1 query parser processes the input
Then an error is returned with message: "Weights (:) are not supported in v1. Use a comma-separated list of tickers like \"AAPL,MSFT\"."
Why this matters: Step 4c (reserved char) runs on the trimmed but not-yet-uppercased token. A refactor that moves normalization (step 4g) before reserved-char checking must not change the error behavior for lowercase input containing colons.
Given the URL query string is "?equity=,AAPL:0.5"
When the v1 query parser processes the input
Then an error is returned with message: "Empty ticker at position 1"
Why this matters: Left-to-right token processing means the empty token at position 1 (step 4b) is detected before the colon at position 2 (step 4c). This re-pins the §13.1 invariant with an explicit pipeline-step rationale.
| Rule | Limit / Behavior |
|---|---|
| Parameter name | equity |
| Canonical meaning | Each equity param = one portfolio |
| Separator (within portfolio) | , (comma) |
| Max tickers per portfolio | 20 (MAX_TICKERS_PER_PORTFOLIO) |
| Max portfolios | 5 (MAX_PORTFOLIOS) |
| Max ticker length | 10 characters |
| Allowed ticker chars | Letters (A-Z), digits (0-9), dot (.), hyphen (-) |
| Ticker must start with | A letter |
| Case normalization | All tickers uppercased |
| Whitespace | Trimmed per-token; inner whitespace rejected |
| Duplicates (within portfolio) | Rejected (post-normalization) |
| Duplicates (across portfolios) | Allowed |
| Empty tokens | Rejected with position |
Reserved chars (:, =) |
Rejected — : uses pinned message from #117: Weights (:) are not supported in v1. Use a comma-separated list of tickers like "AAPL,MSFT". |
URL-encoded reserved (%3A, %3D) |
Rejected (decoded before parsing) |
| Other special chars | Rejected |
| Unknown params | Silently ignored |
| Error strategy | Fail-fast, first error wins (see validation pipeline order above) |
| Ordering | Input order preserved within each portfolio |
| Weight model | Equal-weight (1/N) only — v2 reserved |
The scenarios below cover the full user experience: opening URLs with both equity and benchmark params, viewing charts and summaries, handling errors, and operating without API keys.
Full URL format:
/?equity=TICKER,TICKER,...&benchmark=gold|eth|usd&range=1y&amount=10000
Each scenario is a concrete "do X, observe Y" step list that a reviewer can execute locally against http://localhost:10000. Scenarios marked [Implemented] describe behavior that works today. Scenarios marked [Not yet implemented] describe expected future v1 behavior — the feature is designed but not yet built (currently only A23: chart hover/tooltip).
Steps:
- Start the dev server:
npm run dev - Open
http://localhost:10000/?equity=AAPL,MSFT&benchmark=goldin a browser
Expected:
- A "PORTFOLIO COMPARE" card is displayed showing:
- AAPL (50.0%) and MSFT (50.0%) with "equal weight (1/2)" label
- Benchmarks: GOLD
- Range: 1y
- Investment: $10,000
- Data loaded line showing tickers and source (Yahoo Finance)
- A "PERFORMANCE" card is displayed containing an SVG line chart
- The chart shows normalized % change lines for AAPL, MSFT, and Gold
- Each line is a different color; Gold is rendered with a dashed line
- Below the chart: a legend with color swatches and ticker labels
- Below the legend: attribution text "Indexed to % change from start date — Source: Yahoo Finance"
- A "SUMMARY" card is displayed with a table showing per-ticker start price, end price, return %, and simulated dollar value (equal-weight split of $10,000)
- No error banners are visible
Verification:
# Verify API routes return data independently:
curl -s "http://localhost:10000/api/market-data?tickers=AAPL,MSFT&range=1y" | jq '.series | length'
# Expected: 2
curl -s "http://localhost:10000/api/benchmark?benchmarks=gold&range=1y" | jq '.series | length'
# Expected: 1Steps:
- Open
http://localhost:10000/?equity=TSMC,AAPL,MSFT&benchmark=eth
Expected:
- Portfolio card shows TSMC (33.3%), AAPL (33.3%), MSFT (33.3%) with "equal weight (1/3)"
- Benchmarks: ETH
- Range: 1y (default)
- Investment: $10,000
- Chart shows four lines: TSMC, AAPL, MSFT (solid), ETH (dashed)
Steps:
- Open
http://localhost:10000/?equity=AAPL&benchmark=usd
Expected:
- Portfolio card shows AAPL (100.0%) with "equal weight (1/1)"
- Benchmarks: USD
- Investment: $10,000
- Chart shows AAPL performance as a solid line and USD as a dashed flat line at 0%
- The 0% reference line is visible on the chart
Verification:
curl -s "http://localhost:10000/api/benchmark?benchmarks=usd&range=1y" | jq '.series[0].source'
# Expected: "Cash baseline"Steps:
- Open
http://localhost:10000/?equity=AAPL,MSFT&benchmark=gold|eth|usd
Expected:
- Chart includes five lines: AAPL, MSFT (solid), Gold, ETH, USD (all dashed)
- Legend shows all five tickers with distinct colors
- Benchmark lines are visually distinct from equity lines (dashed vs solid)
Steps:
- Open
http://localhost:10000/?equity=AAPL,MSFT&equity=GOOG,TSLA&benchmark=gold
Expected:
- Portfolio card shows:
- Portfolio 1: AAPL (50.0%), MSFT (50.0%) — equal weight (1/2)
- Portfolio 2: GOOG (50.0%), TSLA (50.0%) — equal weight (1/2)
- Benchmarks: GOLD
- Investment: $10,000
- Chart shows individual ticker lines for AAPL, MSFT, GOOG, TSLA (solid) and Gold (dashed)
- Each line is visually distinguishable by color
Note: v1 renders individual ticker lines, not composite portfolio-level lines. Composite equal-weight portfolio lines are a future enhancement.
Steps:
- Open
http://localhost:10000/?equity=AAPL&benchmark=gold&range=5y
Expected:
- Portfolio card shows Range: 5y
- Chart displays approximately 5 years of data (weekly interval from Yahoo)
- X-axis labels span ~5 years
Verification:
curl -s "http://localhost:10000/api/market-data?tickers=AAPL&range=5y" | jq '.series[0].points | length'
# Expected: ~250 weekly data pointsSteps:
- Open
http://localhost:10000/?equity=MSFT&benchmark=eth&range=ytd
Expected:
- Portfolio card shows Range: ytd
- Chart displays data from approximately January 1 of the current year to today
- X-axis labels begin around Jan of the current year
Steps:
- Open any valid URL, e.g.
http://localhost:10000/?equity=AAPL&benchmark=gold
Expected:
- Below the chart and legend, attribution text reads:
"Indexed to % change from start date — Source: Yahoo Finance" - If both equity and benchmark data come from different sources (e.g., Yahoo Finance + Cash baseline), both sources are listed
Steps:
- Open
http://localhost:10000/?equity=ZZZZZZ&benchmark=gold
Expected:
- The URL parses without error (ZZZZZZ is a valid format)
- A loading indicator appears while data is fetched
- After the fetch completes, a "Fetch error" banner is displayed with text:
"No data found for ticker: ZZZZZZ" - No chart is rendered
Verification:
curl -s "http://localhost:10000/api/market-data?tickers=ZZZZZZ&range=1y"
# Expected: HTTP 404, body: { "error": "No data found for ticker: ZZZZZZ" }Steps:
- Open
http://localhost:10000/?equity=AAPL,ZZZZZZ,MSFT&benchmark=gold
Expected:
- A "Fetch error" banner is displayed:
"No data found for ticker: ZZZZZZ" - No chart is rendered — the entire request fails, no partial render
- The portfolio summary card may still be visible showing the parsed query
Verification:
curl -s "http://localhost:10000/api/market-data?tickers=AAPL,ZZZZZZ,MSFT&range=1y"
# Expected: HTTP 404 with error for ZZZZZZ (sequential fetch fails on first bad ticker)Steps:
- Open
http://localhost:10000/?equity=AAPL&benchmark=banana
Expected:
- An "Invalid query" error banner is displayed with text:
"Unknown benchmark: 'banana'. Valid benchmarks: gold, eth, usd" - No chart is rendered
- No API fetch is made (the parser rejects before fetching)
Steps:
- Open
http://localhost:10000/?equity=AAPL&benchmark=
Expected:
- The empty benchmark is treated as no benchmark (empty string tokens are skipped)
- The page displays AAPL performance without a benchmark line
- Portfolio card shows no Benchmarks section (or empty)
Steps:
- Open
http://localhost:10000/?equity=AAPL
Expected:
- The page displays AAPL performance chart without any benchmark lines
- No call is made to
/api/benchmark - Portfolio card shows AAPL (100.0%), Range: 1y, Investment: $10,000, with no Benchmarks line
Steps:
- Open
http://localhost:10000/
Expected:
- No error banners are displayed
- No chart is rendered
- No loading indicator appears
- No API fetches are made
- A
LandingStatecard is displayed with:- Title: "PORTFOLIO COMPARE"
- Instructional text: "Compare equity portfolio performance against benchmarks. Add equities to the URL to get started:"
- Example URL:
?equity=AAPL,MSFT,GOOG&benchmark=gold&range=1y - Hint text: "Add
&amount=10000to simulate a dollar investment."
Steps:
- Open
http://localhost:10000/?benchmark=gold
Expected:
- The parser returns no error (missing equity param = empty portfolios, scenario 6.1)
- No equity fetch is made
- The benchmark is fetched and rendered as a single dashed line
- Because portfolios are empty, the
LandingStatecard is shown (with example URL) instead of a portfolio summary card - A "PERFORMANCE" card renders the benchmark line
- A "SUMMARY" card renders with the benchmark row
Note: Unlike the original A15 which expected an error, the v1 implementation treats missing
equity=as an empty portfolio list (not an error). This matches parser scenario 6.1. The portfolio summary card is replaced byLandingStatewhen no equities are present.
Steps:
- Ensure no
MARKET_DATA_API_KEYis set in.env.local - Start
npm run dev - Open
http://localhost:10000/?equity=AAPL&benchmark=gold
Expected:
- The page loads and displays the chart and summary
- No login, API key prompt, or auth wall is shown
- Data is fetched from Yahoo Finance (free, no key required)
This scenario applies only when the data provider requires an API key. The current provider (Yahoo Finance) does not require one. If Yahoo becomes unavailable and a keyed provider is substituted, this scenario becomes active.
Steps:
- Configure the app to use a provider that requires
MARKET_DATA_API_KEY - Start the app without setting that variable
- Open any valid URL
Expected:
- The page displays a configuration error:
"Missing API key. Set the MARKET_DATA_API_KEY environment variable." - No chart is rendered
Same as A17 — only applies when using a keyed provider.
Steps:
- Open a valid URL while the Yahoo Finance endpoint is rate-limited or unreachable
Expected:
- A "Fetch error" banner is displayed:
"Data temporarily unavailable. Please try again." - No chart is rendered
Verification (simulated):
# Rate limit response from API route:
# If Yahoo returns 429, the API route returns:
# HTTP 429, { "error": "Data temporarily unavailable. Please try again." }
# If Yahoo returns 5xx, the API route returns:
# HTTP 502, { "error": "Data temporarily unavailable. Please try again." }Steps:
- Open
http://localhost:10000/?equity=TSMC,AAPL,MSFT&benchmark=gold|eth&range=1y - Copy the URL from the browser address bar
- Open a new browser tab (or incognito window)
- Paste the URL and press Enter
Expected:
- The exact same chart and summary are displayed (data may differ slightly due to cache timing)
- No server-side session or cookie is required
- The URL alone encodes the complete application state
Steps:
- Open
http://localhost:10000/?equity=AAPL&benchmark=gold - Wait for the chart to render
- Manually change the URL to
http://localhost:10000/?equity=MSFT&benchmark=ethand press Enter
Expected:
- The page re-renders: MSFT replaces AAPL, ETH replaces Gold
- A loading indicator appears while new data is fetched
- The chart and portfolio summary update with the new data
Note: In v1, this requires a full page navigation (not SPA-style client-side routing). The
popstatelistener handles browser back/forward navigation within the same session.
Steps:
- Open
http://localhost:10000/?equity=AAPL,MSFT&benchmark=gold&range=1y
Expected:
- A summary table is displayed below the chart with columns: Ticker, Start, End, Return
- Each row shows:
- Ticker — symbol (e.g., AAPL, MSFT, Gold)
- Start — price at beginning of range, formatted as USD (e.g., "$123.45")
- End — price at end of range, formatted as USD
- Return — total return % with sign (e.g., "+15.23%" or "−3.45%")
- Positive returns are displayed in green; negative returns in red
- Both equity and benchmark rows appear in the same table
With ?amount= param (dollar-amount simulation):
- Open
http://localhost:10000/?equity=AAPL,MSFT&benchmark=gold&range=1y&amount=10000
Expected (additional):
- A Value column appears showing the simulated end value for each equity
- The investment amount is split equally across equity tickers only ($5,000 each for 2 equities)
- Benchmark rows show "—" in the Value column (benchmarks do not receive an allocation)
- A TOTAL row appears at the bottom showing:
- Average return % across equities
- Total portfolio end value (sum of all equity end values)
The v1 chart is a static SVG with no interactive features.
Steps:
- Open a valid URL and hover over the chart
Expected (current):
- No tooltip appears — this is expected for v1
- The chart is a static SVG line chart
Expected (future):
- A tooltip shows the date and value for each line at that date
Steps:
- Open a valid URL on a mobile device or in a browser with viewport < 768px
Expected:
- The chart SVG scales to fit the viewport width (uses
viewBoxwithpreserveAspectRatio) - All text in the portfolio card remains legible
- The page scrolls vertically if content overflows
This is the definitive v1 smoke test. It verifies parse → fetch → normalize → render.
Steps:
- Start the dev server:
npm run dev - Open
http://localhost:10000/?equity=AAPL,MSFT&benchmark=gold&range=1y
Expected:
- No error banners
- A "PORTFOLIO COMPARE" card showing:
- AAPL (50.0%) and MSFT (50.0%) with "equal weight (1/2)" label
- Benchmarks: GOLD
- Range: 1y
- Investment: $10,000
- Data loaded: AAPL, MSFT, Gold — Source: Yahoo Finance
- A "PERFORMANCE" card with an SVG line chart:
- Solid lines for AAPL and MSFT
- Dashed line for Gold
- Legend with color swatches
- Attribution text
- A "SUMMARY" card with a table showing per-ticker start/end prices, return %, and simulated dollar value
- A loading indicator appeared briefly before the chart rendered
Steps:
- Open
http://localhost:10000/?equity=AAPL:0.5
Expected:
- An "Invalid query" error banner is displayed:
"Weights (:) are not supported in v1. Use a comma-separated list of tickers like "AAPL,MSFT"." - No portfolio summary is displayed
- No API fetch is made
- No chart is rendered
Verifies the fetch layer works independently of the page UI.
Steps:
# Equity data:
curl -s "http://localhost:10000/api/market-data?tickers=AAPL,MSFT&range=1y" | jq '.'
# Expected: HTTP 200, { "series": [ { "ticker": "AAPL", "points": [...], "source": "Yahoo Finance" }, { "ticker": "MSFT", ... } ] }
# Benchmark data:
curl -s "http://localhost:10000/api/benchmark?benchmarks=gold|eth&range=1y" | jq '.'
# Expected: HTTP 200, { "series": [ { "ticker": "Gold", "points": [...], "source": "Yahoo Finance" }, { "ticker": "ETH", ... } ] }
# USD baseline:
curl -s "http://localhost:10000/api/benchmark?benchmarks=usd&range=1y" | jq '.series[0].source'
# Expected: "Cash baseline"Verify response shape:
- Each series entry has
ticker(string),points(array of{date, close}), andsource(string) - Points are sorted oldest → newest
- Dates are ISO 8601 format (e.g., "2024-01-15")
This is the most comprehensive "Try it" URL from the README. It exercises the full pipeline with multiple equities and all three benchmarks at once.
Steps:
- Start the dev server:
npm run dev - Open
http://localhost:10000/?equity=TSMC,AAPL,MSFT&benchmark=gold|eth|usd
Expected:
- A "PORTFOLIO COMPARE" card is displayed showing:
- TSMC (33.3%), AAPL (33.3%), MSFT (33.3%) with "equal weight (1/3)" label
- Benchmarks: GOLD, ETH, USD
- Range: 1y (default, since range param is omitted)
- Investment: $10,000
- A "PERFORMANCE" card is displayed containing an SVG line chart
- The chart shows six lines: TSMC, AAPL, MSFT (solid) and Gold, ETH, USD (dashed)
- USD appears as a flat 0% baseline
- Each line is a different color; benchmark lines are visually distinct (dashed)
- Legend shows all six tickers with color swatches
- Attribution text is visible below the chart
Verification:
curl -s "http://localhost:10000/api/market-data?tickers=TSMC,AAPL,MSFT&range=1y" | jq '.series | length'
# Expected: 3
curl -s "http://localhost:10000/api/benchmark?benchmarks=gold|eth|usd&range=1y" | jq '.series | length'
# Expected: 3Matches the README "More examples" entry for
http://localhost:10000/— verifies the idle state a first-time user sees.
Steps:
- Start the dev server:
npm run dev - Open
http://localhost:10000/
Expected:
- No error banners are displayed
- No chart is rendered
- No loading indicator appears
- No API fetches are made
- A
LandingStatecard is displayed with title "PORTFOLIO COMPARE", an example URL (?equity=AAPL,MSFT,GOOG&benchmark=gold&range=1y), and a hint about the&amount=param
Steps:
- Open
http://localhost:10000/?equity=AAPL,MSFT&benchmark=gold&range=1y&amount=25000
Expected:
- The summary table includes a Value column
- Each equity ticker shows its simulated end value based on an equal split of $25,000 ($12,500 per ticker)
- Benchmark rows (Gold) show "—" in the Value column
- A TOTAL row at the bottom shows average return and total portfolio value
- The chart and portfolio summary card are unaffected by the
amountparam
Default behavior (no amount param):
- Open
http://localhost:10000/?equity=AAPL,MSFT&benchmark=gold&range=1y
Expected:
- The summary table's Value column still appears (default amount is 10000)
- Equal split: $5,000 per ticker for 2 equities
Invalid amount:
- Open
http://localhost:10000/?equity=AAPL&amount=abc
Expected:
- An "Invalid query" error banner:
"Invalid amount: 'abc'. Must be a positive number." - No chart or summary table rendered
- Open
http://localhost:10000/?equity=AAPL&amount=-100
Expected:
- An "Invalid query" error banner:
"Invalid amount: '-100'. Must be a positive number."
- Open
http://localhost:10000/?equity=AAPL&amount=0
Expected:
- An "Invalid query" error banner:
"Invalid amount: '0'. Must be a positive number."
| Benchmark value | Description |
|---|---|
gold |
Spot gold price in USD (XAU/USD) |
eth |
Ethereum price in USD (ETH/USD) |
usd |
US Dollar cash baseline (flat 0% line) |
- Benchmark names are case-insensitive (e.g.
Gold,GOLD,goldall valid) - Multiple benchmarks are separated by pipe:
benchmark=gold|eth|usd - Unknown benchmark names produce a clear error listing valid options
- The benchmark param is optional — omitting it shows equity-only performance
| Range value | Description |
|---|---|
1m |
1 month |
3m |
3 months |
6m |
6 months |
ytd |
Year to date |
1y |
1 year (default) |
3y |
3 years |
5y |
5 years |
max |
Maximum available history |
- Range values are case-insensitive
- Invalid range values produce a clear error listing valid options
- If omitted, defaults to
1y
| Aspect | Detail |
|---|---|
| Parameter name | amount |
| Type | Positive number |
| Default | 10000 (if omitted or empty) |
| Effect | Adds a Value column to the summary table showing simulated end value per equity |
| Allocation | Split equally across equity tickers only (benchmarks receive no allocation) |
| Invalid values | 0, negative numbers, and non-numeric strings produce a clear error |
- The
amountparam does not affect the chart — it only affects the summary table - Benchmarks show "—" in the Value column
- A TOTAL row appears when
amount > 0and at least one equity is present
The scenarios below cover the full compare page behavior across the wired pipeline: parse → fetch → compute → render. They define loading, success, error, and edge-case states for a reviewer running the app locally at http://localhost:10000.
Steps:
- Open
http://localhost:10000/?equity=AAPL,MSFT&benchmark=gold&range=1y
Expected:
- The page fetches data from
/api/market-data?tickers=AAPL,MSFT&range=1y - The page fetches data from
/api/benchmark?benchmarks=gold&range=1y - The fetched series are normalized to % change from start date
- A line chart is rendered with one solid line per equity (AAPL, MSFT) and one dashed line for Gold
- A "PORTFOLIO COMPARE" card is displayed with ticker weights, benchmarks, range, and investment amount
- A "SUMMARY" card is displayed with a table showing per-ticker start/end prices, return %, and simulated dollar value
Verification:
# Confirm both API routes return data:
curl -s -o /dev/null -w "%{http_code}" "http://localhost:10000/api/market-data?tickers=AAPL,MSFT&range=1y"
# Expected: 200
curl -s -o /dev/null -w "%{http_code}" "http://localhost:10000/api/benchmark?benchmarks=gold&range=1y"
# Expected: 200Steps:
- Open
http://localhost:10000/?equity=AAPL,MSFT&range=1y
Expected:
- The page fetches data from
/api/market-data?tickers=AAPL,MSFT&range=1y - No call is made to
/api/benchmark - The chart renders solid lines for AAPL and MSFT only
- No dashed benchmark lines appear
Steps:
- Open
http://localhost:10000/?equity=AAPL,MSFT&equity=GOOG,TSLA&benchmark=eth&range=1y
Expected:
- The page fetches data for all four tickers (AAPL, MSFT, GOOG, TSLA) and the ETH benchmark
- The chart shows individual ticker lines (solid for equities, dashed for ETH)
- The portfolio summary card shows two portfolio groups
- Each line is visually distinguishable by color
Steps:
- Open
http://localhost:10000/?equity=AAPL,MSFT&benchmark=gold&range=1y - Observe the page while data is being fetched
Expected:
- A "LOADING" card is displayed containing a
BlockLoaderanimation and text "Fetching market data…" - No empty or broken chart is shown during loading
- The loading card disappears once data arrives and is replaced by the chart
Steps:
- Open a valid URL and observe the page during the loading phase
Expected:
- The portfolio summary card is displayed immediately (it shows parsed query info, not fetched data)
- The "Data loaded" line in the summary card only appears after the fetch completes
- No stale or placeholder data is displayed
Steps:
- Open a valid URL while the API endpoint is unreachable (e.g., network disconnected)
Expected:
- A "Fetch error" banner is displayed (e.g., "Failed to fetch" or "Data temporarily unavailable. Please try again.")
- No empty chart is rendered
- The portfolio summary card may still be visible (it only depends on parse, not fetch)
Steps:
- Open
http://localhost:10000/?equity=AAPL&benchmark=gold&range=1ywhile the benchmark API endpoint fails
Expected:
- A "Fetch error" banner is displayed about the benchmark failure
- No chart is rendered with partial data
Implementation detail: The
fetchCompareDatafunction incommon/compare-fetcher.tscatches any fetch error and returns a single error. Both equity and benchmark fetches must succeed for the chart to render.
Steps:
- Open a valid URL when Yahoo Finance returns HTTP 429
Expected:
- A "Fetch error" banner is displayed:
"Data temporarily unavailable. Please try again." - No chart is rendered
Steps:
- Open
http://localhost:10000/?equity=AAPL:0.5&benchmark=gold
Expected:
- The client-side parser rejects the query immediately
- An "Invalid query" error banner shows:
"Weights (:) are not supported in v1. Use a comma-separated list of tickers like "AAPL,MSFT"." - No fetch is made to
/api/market-dataor/api/benchmark(parse error prevents fetch) - No loading indicator is shown
Steps:
- Open
http://localhost:10000/?equity=&benchmark=gold
Expected:
- An "Invalid query" error banner shows:
"Empty equity parameter" - No fetch is made to any API route
- No chart is rendered
Steps:
- Open
http://localhost:10000/
Expected:
- The page shows the
LandingStatecard — no chart, no error, no loading - No fetch is made to any API route
- The landing card displays a title, example URL, and
&amount=hint
Steps:
- Open
http://localhost:10000/?equity=AAPL,ZZZZZZ&benchmark=gold&range=1y
Expected:
- A "Fetch error" banner shows:
"No data found for ticker: ZZZZZZ" - No chart is rendered (the API route fails the entire batch on the first bad ticker)
Verification:
curl -s "http://localhost:10000/api/market-data?tickers=AAPL,ZZZZZZ&range=1y"
# Expected: HTTP 404, { "error": "No data found for ticker: ZZZZZZ" }Steps:
- Open a valid URL where the benchmark provider returns no data points
Expected:
- A "Fetch error" banner shows a message about missing benchmark data
- No chart is rendered with a missing benchmark line
Steps:
- Open
http://localhost:10000/?equity=AAPL,MSFT&benchmark=gold&range=5y
Expected:
- The chart renders using only dates common to all series (
normalizeAllSeriesintersects date sets) - Some data points at the edges may be dropped if one series has a shorter history
- The chart still renders correctly with the overlapping range
Steps:
- Open
http://localhost:10000/?equity=AAPL&benchmark=gold&range=1y - Wait for the chart to render
- Change the URL to
http://localhost:10000/?equity=MSFT&benchmark=eth&range=3yand press Enter
Expected:
- The page re-parses the new query
- A loading indicator appears while new data is fetched
- The chart and portfolio summary update with MSFT and ETH data
- No stale data from the previous query (AAPL / Gold) is visible
Quick reference for which scenarios are testable today vs. awaiting UI work.
| Scenario | Status | Notes |
|---|---|---|
| §1–§13 (parser) | 110 unit tests passing | Run npm test to verify. All parser scenarios have dedicated tests. |
| §14 (v2 reserved) | Tested | Parser rejects : and = with v2-reserved messages. |
| A1–A8 (happy path) | Implemented | Chart renders, attribution visible, all ranges work. |
| A9–A10 (invalid ticker) | Implemented | Fetch error displayed, no partial render. |
| A11 (invalid benchmark) | Implemented | Parser rejects with valid-benchmark list. |
| A12–A13 (empty/missing benchmark) | Implemented | Equity-only chart renders. |
| A14 (landing state) | Implemented | LandingState.tsx component with example URLs and ?amount= hint. |
| A15 (benchmark only) | Implemented | Benchmark-only chart renders (no error — empty portfolios allowed). |
| A16 (auth-free) | Implemented | Yahoo Finance requires no API key. |
| A17–A18 (API key) | Not testable | Only relevant if keyed provider is configured. |
| A19 (rate limit) | Implemented | API routes return appropriate error on 429. |
| A20 (shareable URL) | Implemented | URL encodes all state. |
| A21 (URL change) | Implemented | Page re-renders on navigation. |
| A22 (summary table) | Implemented | Summary.tsx shows per-ticker start/end prices, return %, and simulated dollar value. |
| A23 (hover/tooltip) | Not yet implemented | Chart is static SVG. |
| A24 (mobile) | Implemented | SVG scales via viewBox. |
| A25 (sanity check) | Implemented | Full pipeline works. |
| A26 (v2 rejection) | Implemented | Parser error displayed in UI. |
| A27 (API routes) | Implemented | curl-testable. |
| A28 (all benchmarks Try It) | Implemented | 3 equities vs gold|eth|usd — README "Try it" example. |
| A29 (landing Try It) | Implemented | LandingState.tsx component shows example URLs for first-time users. |
| A30 (amount simulation) | Implemented | ?amount= param adds Value column to Summary table; default 10000. |
| B1–B5 (success + loading) | Implemented | Chart, loading, and summary all work. |
| B6–B8 (fetch errors) | Implemented | Error banners displayed. |
| B9–B11 (invalid/empty/idle) | Implemented | Parse errors prevent fetch; idle state works. |
| B12–B14 (partial data) | Implemented | Batch fail on bad ticker; date alignment works. |
| B15 (re-fetch) | Implemented | URL change triggers full re-render. |
This is the canonical "Try it" procedure. README's Quick Verification Checklist is a 3-item summary that points here. If the two ever diverge, this checklist is authoritative.
Run these steps to verify the v1 pipeline end-to-end:
# 1. Install dependencies
npm install
# 2. Run unit tests (parser + query + portfolio + validate endpoint)
npm test
# Expected: 110 tests passing, 0 failures
# 3. Start the dev server
npm run dev
# Expected: Server running on http://localhost:10000
# 4. Test API routes with curl
curl -s "http://localhost:10000/api/market-data?tickers=AAPL&range=1y" | jq '.series[0].ticker'
# Expected: "AAPL"
curl -s "http://localhost:10000/api/benchmark?benchmarks=gold&range=1y" | jq '.series[0].ticker'
# Expected: "Gold"
curl -s "http://localhost:10000/api/benchmark?benchmarks=usd&range=1y" | jq '.series[0].source'
# Expected: "Cash baseline"
# 5. Test error cases
curl -s "http://localhost:10000/api/market-data?tickers=ZZZZZZ&range=1y" | jq '.error'
# Expected: "No data found for ticker: ZZZZZZ"
curl -s "http://localhost:10000/api/market-data?range=1y"
# Expected: HTTP 400, { "error": "Missing tickers parameter" }
# 6. Test validation endpoint
curl -s "http://localhost:10000/api/compare/validate?equity=AAPL,MSFT&benchmark=gold&range=1y"
# Expected: HTTP 200 with validation result
curl -s "http://localhost:10000/api/compare/validate?equity=AAPL:0.5"
# Expected: HTTP 400, error: "Weights (:) are not supported in v1. Use a comma-separated list of tickers like \"AAPL,MSFT\"."
# 7. Open in browser — verify each scenario:
# http://localhost:10000/?equity=AAPL,MSFT&benchmark=gold&range=1y (A1 / A25 — full pipeline)
# http://localhost:10000/?equity=TSMC,AAPL,MSFT&benchmark=gold|eth|usd (A28 — all benchmarks, README "Try it")
# http://localhost:10000/?equity=AAPL,MSFT&equity=GOOG,TSLA&benchmark=gold (A5 — multi-portfolio)
# http://localhost:10000/?equity=AAPL&benchmark=usd (A3 — USD baseline)
# http://localhost:10000/?equity=AAPL:0.5 (A26 — v2 rejection)
# http://localhost:10000/ (A14 / A29 — idle state)
# http://localhost:10000/?equity=ZZZZZZ (A9 — invalid ticker)
# http://localhost:10000/?equity=AAPL&benchmark=banana (A11 — invalid benchmark)
# http://localhost:10000/?equity=AAPL,MSFT&benchmark=gold&amount=25000 (A30 — dollar-amount simulation)