# 52-Week Low/High Market Movers — Change=0 Investigation

**Status:** READ-ONLY research complete. Bug is mobile-side. High confidence.
**Date:** 2026-04-21
**Scope:** Market Movers screen, 52-Week High and 52-Week Low tabs — both `change` and `changePercent` render as `0.00` / `+$0.00`.

---

## TL;DR

The DTO mapper `mapWeek52DtoToDomain` hardcodes `change: 0` (because the 52w backend only returns `change_pct`, not absolute dollar change). The downstream UI row component then **re-derives** `changePercent` from `price - (price - change)` — which is `0` whenever `change === 0` — and **ignores** the mapper's real `changePercent` value. Net effect: both dollar change AND percent change render as 0, even though the domain model has the correct percent.

Fix is mobile-only. One-line mapper change suffices: compute the dollar `change` from `change_pct` and `current_price` (the math is exact).

---

## Data Flow Trace (52-week tab)

```
MarketMoverDetailScreen.tsx
  └─ useMarketMoverDetailViewModel()
      └─ container.market.getMarketMovers.execute('52w_highs' | '52w_lows', 20)
          └─ GetMarketMoversUseCase
              └─ MarketRepositoryImpl.getMovers(direction, count)
                  └─ fetch52wMovers()              // NOT fetchStandardMovers()
                      ├─ api.fetch52wHighs()       // GET /market/52w-highs
                      │   or api.fetch52wLows()    // GET /market/52w-lows
                      ├─ Zod: week52ResponseDtoSchema
                      └─ dto.items.map(mapWeek52DtoToDomain)   // ⚠ hardcodes change:0
  └─ mapMoversToStocks(movers)
      └─ { priceChange: m.change, changePercent: m.changePercent }
  └─ <StockList> → <MarketMoverStockItem stock={...}>
      └─ <PriceChange stock={...}>    // ⚠ re-derives BOTH values from priceChange
```

---

## The Bug — Two Compounding Problems

### Problem 1: Mapper drops dollar change (by design, with comment)

`src/features/market/data/mappers/marketMapper.ts:55-63`

```typescript
export function mapWeek52DtoToDomain(dto: Week52MoverDto): MarketMover {
  return {
    ticker: dto.ticker,
    price: dto.current_price,
    change: 0, // absolute dollar change not available from 52w endpoint
    changePercent: dto.change_pct,
    volume: 0,
  };
}
```

The 52w backend payload schema (`Week52MoverDto`, marketDto.ts:81-90) is:
```
{ ticker, name, current_price, week_52_high, week_52_low, proximity_score, change_pct, logo_url? }
```
No `change` field. The implementer chose `change: 0` with a comment noting the field isn't available. The domain model's `changePercent` IS correctly populated from `dto.change_pct` at this point.

Historical note: the original plan (`docs/superpowers/plans/2026-03-26-52week-market-movers.md` Task 1 Step 2) actually wrote `change: dto.change_pct` — which would have been semantically wrong (mixing $ and %) but would have accidentally kept the UI working. The merged implementation (commit e2c3ca4f) correctly refused the mis-typed field but didn't update the downstream consumer.

### Problem 2: UI row re-derives changePercent from priceChange, discarding the real value

`src/features/market/presentation/components/MarketMoverStockItem.tsx:26-35`

```typescript
function PriceChange({ stock }: { stock: MarketMoverStock }) {
  const live = useLiveTickerPrice(stock.ticker);
  const prevClose = stock.price - stock.priceChange;           // = price - 0 = price
  const price = live?.close ?? stock.price;                    // = price (no WS delta)
  const change = price - prevClose;                            // = 0
  const changePercent = prevClose !== 0
    ? (change / prevClose) * 100                               // = 0 / price * 100 = 0 ✗
    : stock.changePercent;                                     // only fires when prevClose===0
  ...
  // renders: "+$0.00 (+0.00%)"
}
```

When `stock.priceChange === 0` and `stock.price > 0`, the fallback to `stock.changePercent` never triggers (because `prevClose` equals `price`, which is non-zero). The computed `change` is 0, the computed `changePercent` is 0, and the real `stock.changePercent` carried in the domain model is silently dropped.

The same pattern exists in `MarketMoverRow.tsx:27-33` (an older row component; likely unused in the 52w path today, but would exhibit the same bug if wired in).

---

## Divergence vs Working Tabs (top gainers / losers / most active)

| Step | Working tabs (gainers/losers/most_active) | 52-week tab |
|---|---|---|
| Repository method | `fetchStandardMovers()` | `fetch52wMovers()` |
| DTO schema | `marketMoverDtoSchema` — has `change`, `change_percent` | `week52MoverDtoSchema` — only has `change_pct`, no `change` |
| Mapper | `mapMoverDtoToDomain` — `change: dto.change ?? 0` (real $ change) | `mapWeek52DtoToDomain` — `change: 0` (hardcoded) |
| Row component | Same `MarketMoverStockItem` → re-derives from `priceChange`, works because `priceChange` carries real $ delta | Same component → re-derives from `priceChange=0`, yields `0`/`0%` |

The divergence is entirely inside the mapper. The UI layer treats all tabs identically.

---

## Hypothesis & Confidence

**Primary hypothesis (HIGH confidence, ~95%):** Mobile-side bug. The mapper's `change: 0` hardcode combined with the row component's re-derivation produces the observed symptom. The arithmetic check is deterministic.

**Why not backend-side:** Even if the backend sends a real `change_pct`, the domain model carries it correctly (see `MarketRepositoryImpl.test.ts:153` — the test asserts `changePercent` is `1.5` for CVX). The value is dropped at the UI layer, not at the API boundary.

**Backend-side contribution (low likelihood, ~5%):** If `change_pct` is itself `0` in the backend response (e.g., broken calc server-side), the `changePercent` would *also* be 0 at the domain model. But that would be an independent bug; the mobile-side re-derivation issue would still exist.

**What I'd need to fully confirm:**
1. `curl` to `/market/52w-highs` and `/market/52w-lows` with a valid bearer token — verify `change_pct` is non-zero in the raw response (rules out backend contribution).
2. Optional: log `stock.changePercent` vs the derived `changePercent` inside `PriceChange` on a real build — will show real percent discarded.

---

## Suggested Fix Scope (mobile-only)

### Option A — one-line mapper fix (**recommended**)

Compute dollar change from the identity `current_price = prev_close * (1 + change_pct/100)`:

```typescript
// src/features/market/data/mappers/marketMapper.ts
export function mapWeek52DtoToDomain(dto: Week52MoverDto): MarketMover {
  const prevClose = dto.change_pct !== -100
    ? dto.current_price / (1 + dto.change_pct / 100)
    : 0;
  return {
    ticker: dto.ticker,
    price: dto.current_price,
    change: dto.current_price - prevClose,   // exact $-change derived from change_pct
    changePercent: dto.change_pct,
    volume: 0,
  };
}
```

- Mathematically exact (no rounding loss beyond float precision).
- Keeps `UI row component` behavior unchanged — it keeps working, live WS merges still combine correctly.
- Tests: update `MarketRepositoryImpl.test.ts` 52w cases (lines 141–180) to also assert `movers[0].change ≈ current_price - current_price/(1+change_pct/100)`.
- Edge: guard `change_pct === -100` (hypothetical total-loss case; `prevClose` would be `+Inf`).

### Option B — change UI fallback logic

Alternative: make `PriceChange` (and `MarketMoverRow`) prefer `stock.changePercent` whenever `stock.priceChange === 0`. More invasive, affects the gainers/losers tabs too, and has semantic issues around live WS merges where `change_pct` from snapshot may need recomputing.

**Not recommended** — fix closer to the data source is safer.

### Files touched (Option A)

- `src/features/market/data/mappers/marketMapper.ts` — 1 function body
- `src/features/market/data/repositories/MarketRepositoryImpl.test.ts` — extend existing 52w assertions with a `change` check (+ optional guard test for `change_pct=-100`)

No schema/DTO changes, no repository changes, no UI changes, no DI changes.

---

## References

- Mapper: `src/features/market/data/mappers/marketMapper.ts:55-63`
- 52w DTO schema: `src/features/market/data/dto/marketDto.ts:81-95`
- Repository path: `src/features/market/data/repositories/MarketRepositoryImpl.ts:100-106`
- View model: `src/features/market/presentation/hooks/useMarketMoverDetailViewModel.ts:26-35`
- Row (detail + home preview): `src/features/market/presentation/components/MarketMoverStockItem.tsx:26-48`
- Legacy row: `src/features/market/presentation/components/MarketMoverRow.tsx:27-33` (same bug if used)
- Plan (historical intent): `docs/superpowers/plans/2026-03-26-52week-market-movers.md` Task 1 Step 2
- Original implementation commit: `e2c3ca4f feat: integrate 52-week high/low market movers (#94)`
