# Research: Email Aliasing for Competition Accounts — Mobile Implications

**Date:** 2026-04-12
**Agent:** comp-research-mobile
**Status:** Complete

---

## 1. Current Competition Flow

### Screens
| Screen | Purpose |
|--------|---------|
| `SimTradeHomeScreen` | Main hub — toggles between "practice" and "competitions" modes |
| `CompetitionLobbyScreen` | Detail view — participants, invite code, join/leave/share actions |
| `CreateCompetitionScreen` | Form — name, capital, duration, trading settings |
| `CompetitionRulesScreen` | Read-only settings display |

### Data Flow
```
Browse:   CompetitionModeContent → useCompetitionsViewModel → GET /competitions?view=extended
Detail:   CompetitionLobbyScreen → useCompetitionLobbyViewModel(competitionId) → GET /competitions/{id} + /participants
Create:   CreateCompetitionScreen → useCreateCompetitionViewModel → POST /competitions
Join:     CompetitionLobbyScreen → handleJoin() → POST /competitions/{id}/join → invalidates all simtrade cache
Leave:    CompetitionLobbyScreen → handleLeaveAction() → DELETE /competitions/{id}/leave
```

### Key Hooks
- `useCompetitionsViewModel` — lists my + available competitions
- `useCompetitionLobbyViewModel(competitionId)` — detail, participants, join state, actions
- `useCompetitionDetailQuery(competitionId)` — React Query wrapper for competition detail
- `useCreateCompetitionViewModel(competitionId?)` — form state for create/edit
- `useCompetitionParticipantsQuery(competitionId)` — participant list
- `useCompetitionLeaderboardQuery(competitionId)` — leaderboard

---

## 2. Current Portfolio Scoping — NOT SCOPED

**Critical finding: Portfolio data is ALWAYS the global practice portfolio, even inside a competition.**

| Hook | Accepts competitionId? | Actually passes it? |
|------|------------------------|---------------------|
| `useSimPortfolioQuery` | No | N/A |
| `useSimOrdersQuery` | No | N/A |
| `useSimPortfolioHistoryQuery` | API supports it | **NO — never passed** |
| `useSimPerformanceQuery` | API supports it | **NO — never passed** |
| `useSimInsightsQuery` | API supports it | **NO — never passed** |

The `useSimTradeHomeViewModel` tracks `selectedCompetitionId` as UI state (which competition pill is selected), but this value is never forwarded to portfolio/orders/history queries. When a user views a competition, the Portfolio tab still shows their global practice account.

Order submission (`SubmitOrderScreen`) has zero competition awareness — all orders go to the single practice account.

---

## 3. Account Switching — DOES NOT EXIST

### Current Model
- **One Alpaca account per user**, identified server-side via JWT
- `SimAccount` model has `alpacaAccountId` but it's metadata-only — never sent back in API calls
- No concept of "active account", "selected account", or account routing
- Query keys don't include accountId: `simTradeQueryKeys.account` = `['simtrade', 'account']`

### What Email Aliasing Would Require
If each competition gets its own Alpaca sub-account (via `user+comp123@nanostreet.ai`):

1. **Account context provider** — a new React context or Zustand store tracking `activeAccountId` that switches when user navigates into a competition
2. **All portfolio/orders/positions hooks need an accountId parameter** — currently they take none
3. **Query key scoping** — every simtrade query key must include `accountId` to prevent cache collisions between practice and competition portfolios
4. **Order routing** — `SubmitOrderScreen` needs to know which account to place the order against
5. **Account resolution** — backend needs a `GET /simtrade/competitions/{id}/account` endpoint that returns the competition-specific accountId for the authenticated user

### Proposed Architecture Change
```
SimTradeAccountContext {
  activeAccountId: string | null  // null = practice, string = competition account
  activeCompetitionId: string | null
  switchToCompetition(competitionId) → fetches competition account → sets activeAccountId
  switchToPractice() → resets to default account
}
```

All query hooks would consume this context:
```typescript
// Before
useSimPortfolioQuery() → GET /simtrade/portfolio

// After
useSimPortfolioQuery() → GET /simtrade/portfolio?account_id={activeAccountId}
// Query key: ['simtrade', 'portfolio', activeAccountId]
```

---

## 4. UI Impact Map — Screens Needing Competition Scoping

### Must show competition-scoped data when inside a competition:
| Screen/Component | Change needed |
|-----------------|---------------|
| `PortfolioTabContent` | Read `activeAccountId` from context, pass to portfolio query |
| `SubmitOrderScreen` | Route orders to competition account |
| `TradeHistoryScreen` | Filter orders by competition account |
| `HoldingsDetailScreen` | Show position from competition account |
| `ProgressDetailScreen` | Show competition-specific performance |
| `SimTradeNewsScreen` | Scope to competition portfolio holdings |
| `TabContentRenderer` | Wire `selectedCompetitionId` → account context |
| All sparkline/chart hooks | Pass accountId for competition-specific history |

### Always show global/aggregated data:
| Screen/Component | Reason |
|-----------------|--------|
| `SimTradeHomeScreen` (practice mode) | Default practice portfolio |
| `CompetitionLobbyScreen` | Competition metadata, not portfolio |
| `CreateCompetitionScreen` | Competition config, not portfolio |
| `CompetitionRulesScreen` | Settings only |

### Competition-specific (already scoped):
| Screen/Component | Status |
|-----------------|--------|
| Leaderboard tab | Already passes competitionId — OK |
| Participants list | Already passes competitionId — OK |
| Community tab | Already passes competitionId — OK |

---

## 5. Provisioning UX — New Account on Join

### Current Join Flow
```
User taps "Join" → POST /competitions/{id}/join → instant response → cache invalidated → done
```

### With Email Aliasing (10-min Alpaca provisioning)
```
User taps "Join" → POST /competitions/{id}/join 
  → Backend creates email alias + triggers Alpaca account creation
  → Returns { status: 'provisioning', estimated_wait: '5-10 min' }
  → Mobile shows provisioning state
```

### Proposed UX:
1. **Immediate feedback** — "Joining competition..." → success with "Setting up your competition portfolio..."
2. **Browsable while waiting** — user can view competition details, rules, participants, leaderboard. Only portfolio/trading is blocked.
3. **Polling for readiness** — similar to existing `useSimAccountQuery` which polls every 5s when `equity === 0` (line 22 in `useSimAccountQuery.ts`). Reuse this pattern: `useCompetitionAccountQuery(competitionId)` polls until `status === 'ACTIVE'`.
4. **States:**
   - `PROVISIONING` → show skeleton + "Setting up your trading account..." message
   - `ACTIVE` → full portfolio/trading enabled
   - `FAILED` → error state with retry button
5. **Disable trading CTA** until account is active (existing pattern: SimTrade already disables CTA when `equity === 0`)

### Existing Pattern to Reuse
The app already handles provisioning at login:
```typescript
// prefetchHomeData.ts lines 126-138
// Provision is fire-and-forget, non-blocking
await container.simtrade.provisionAccount.execute().catch(() => {})
```

Same pattern works: fire-and-forget provision on join, poll for status on competition detail screen.

---

## 6. Prior Decisions and Linear Context

### DECISION_PENDING file (2026-04-06)
File: `.agent-status/DECISION_PENDING_competition_portfolio_scoping.md`

**Key constraint identified:** "Alpaca does NOT support multiple paper trading accounts per user (email + tax ID uniqueness, regulatory blocker). We're stuck with 1 Alpaca account per user."

**Two options were proposed:**
- **Phase 1 (Quick Win):** Per-competition snapshots + leaderboard. No trade isolation. Same equity delta in overlapping competitions.
- **Phase 2 (Full Isolation):** Tag trades by `competition_id`, dual-write, per-competition position computation.

**Email aliasing is effectively a Phase 3** — it bypasses the "1 Alpaca account per user" constraint entirely by creating distinct Alpaca accounts per competition. This is the most powerful option but has the highest complexity.

### NAN-96 (Linear — DONE as of 2026-04-02)
The competition system was implemented with the **snapshot-based approach** (Phase 1 from the decision doc):
- `start_equity_snapshot` captured at join time
- `competition_return_pct = (current_equity - snapshot) / snapshot × 100`
- All users share one Alpaca account — no trade isolation
- Leaderboard cached with 5-min TTL

**The current implementation is snapshot-based. Email aliasing would be a fundamental architecture change.**

---

## 7. Limitations & Scale Concerns

### How many competitions can a user join simultaneously?
- **No hard limit in the code** — no `MAX_COMPETITIONS` constant found
- The `sim_competition_entries` table has `unique(competition_id, user_id)` — prevents joining the same competition twice, but no limit on distinct competitions
- **With email aliasing:** Each competition = 1 Alpaca account. Alpaca may have per-user or per-API-key limits on sub-accounts. Need to verify with Alpaca docs.

### Is the app hardcoded to one account?
**YES.** Fundamentally single-account throughout:
- `SimAccount` model — singular, no array/collection concept
- `useSimAccountQuery` — fetches one account, query key `['simtrade', 'account']`
- All portfolio/order hooks — no accountId parameter
- Provisioning — idempotent, assumes one account
- DI container — one `SimTradeApi` adapter, one repository instance

### Scale implications of email aliasing:
| Concern | Impact |
|---------|--------|
| N accounts per user | Query keys must include accountId; cache size grows linearly |
| Account list management | New `useSimAccountsQuery` (plural) needed |
| Account switching latency | Each switch = new portfolio/positions fetch |
| Provisioning queue | 10 users joining simultaneously = 10 Alpaca provisions |
| Alpaca rate limits | N accounts × polling frequency could hit API limits |
| Storage | Encrypted storage currently stores one account token — needs key-per-account |

---

## Summary: Effort Estimate by Layer

| Layer | Changes | Effort |
|-------|---------|--------|
| **Business models** | `SimAccount` → support collection; add `CompetitionAccount` model | Medium |
| **Data/DTOs** | Add `accountId` to portfolio/order DTOs; new account-list DTO | Medium |
| **Service/API** | Add `account_id` param to all portfolio/order endpoints | Low-Medium |
| **Repository** | Thread `accountId` through all methods | Medium |
| **Use cases** | Add accountId param to Get* use cases | Low |
| **Hooks** | Add accountId to all query hooks + query keys | High (many hooks) |
| **Context/State** | New `SimTradeAccountContext` provider | Medium |
| **Screens** | Wire account context into ~8 screens | High |
| **Provisioning UX** | New polling hook + provisioning states in lobby | Medium |
| **Tests** | Update all simtrade tests for multi-account | High |

**Total estimate: Large effort (2-3 sprints) if going full email aliasing.**

---

## 8. Spec Verification (from Linear, Notion, Figma, and Code)

### Source: NAN-101 (Linear — "[Mobile] SimTrade competitions screen", DONE 2026-04-11)

**Quoted from spec — Competition Detail UI Requirements:**
> "Full tab bar: **My Portfolio | Leaderboard | Insights | Progress | News | Community | Settings**"
> "My Portfolio tab: same portfolio chart + metrics as Practice mode **but scoped to competition**"
> "Insights tab: **competition-specific** insights"
> "Progress tab: user's **progress within competition**"

**Verdict:** The spec explicitly says "My Portfolio" inside a competition should be **scoped to competition**, not global. The current implementation does NOT do this — portfolio hooks never pass competitionId. This is a gap between spec and implementation.

**Unchecked task in NAN-101:**
> "- [ ] Build `CompetitionPortfolio` (scoped portfolio view within competition)"

This task was NOT completed. The competition portfolio scoping is still TODO.

---

### Source: NAN-96 (Linear — "[Backend] SimTrade competition system", DONE 2026-04-02)

**Quoted — Architecture Decision:**
> "Alpaca has no competition concept — each user has one shared paper account. Competition return is computed as:
> `competition_return_pct = (current_equity - start_equity_snapshot) / start_equity_snapshot × 100`"
> "Trades made before joining carry over (the user doesn't start with a clean slate), but ranking is always measured from join time."

**Quoted — Starting Capital field in DB schema:**
> `starting_capital numeric not null default 100000`

**Quoted — Create endpoint request body:**
> `"starting_capital": 100000`

**Verdict:** Backend spec defines `starting_capital` as a configurable numeric field with default $100k. No min/max range validation is specified in the Linear ticket.

---

### Source: Notion — "SimTrade — Status Update (Apr 11)"

**Quoted verbatim:**
> "Initial balance capped at **$50k per user** (Alpaca sandbox ACH daily limit). NAN-93 spec targets $100k but we're blocked by sandbox limits."
> "Each competition account would also be capped at **$50k initial capital** (same ACH limit)."
> "**Proposed workaround:** Create email aliases per user-competition (e.g. `user+comp1@nanostreet.ai`), each provisioned as a separate Alpaca sub-account. Limitation: still $50k per alias, and adds complexity to the provisioning flow."

**Verdict:** Notion confirms email aliasing is the proposed approach. Starting capital is capped at **$50k** per alias due to Alpaca sandbox ACH limits, NOT the $100k default in the spec. The $200k the user saw in a design placeholder may be from a different Figma frame (see Figma section below).

---

### Source: Figma — Create Competition Screen (node 6340:64332)

**What the design shows:**
- Field: "Starting Portfolio Balance per Player"
- Placeholder value: **$100,000.00**
- Free-form numeric input (no dropdown, no predefined options, no slider)
- No visible min/max range labels in the design

**Verdict:** Figma shows starting capital as a **free-text numeric input** with $100k placeholder. The user can type any amount. There is no range restriction visible in the design.

---

### Source: Figma — Create Competition Additional Settings (node 6340:72079)

**What the design shows (full settings list):**
- Starting Portfolio Balance per Player: $100,000.00
- Duration: Start Date / End Date
- Additional Settings section:
  - Link for sharing (competition.link/your-name)
  - Privacy Options: Player Portfolio toggle
  - Enable Chat toggle
  - Short Selling toggle (with "Enable Short Selling" sub-toggle)
  - Interest on borrowed shares: $0.00
  - Interest on borrowed cash: $0.00
  - Commission fee per trade (Simulated): $0.00
  - Min. Stock Price / Max. Stock Price fields
  - "Limit trading to selection of stocks" toggle
  - Custom Stocks section (Add Custom Stocks button, with AAPL shown)

**Verdict:** All settings from the Figma design are implemented in the mobile code (`useCompetitionSettings.ts` defaults match). Starting capital is configurable — not a fixed value.

---

### Source: Figma — Competition Portfolio Screen (node 6338:96698)

**What the design shows:**
- Tab bar: "My Portfolio | Leaderboard | Insights | Progress..."
- Inside "Davidson Alumni League" competition
- Portfolio value: **$123,380** with +$23,380 (+23.4%) return
- Time period selectors: 1D / 3M / All Time
- "Your Portfolio" vs "S&P" toggle on chart
- Full portfolio breakdown: Overall Return $23,380, Overall Return % 23.4%, Cash Remaining $38,000, Buying Power $38,000
- Holdings: AAPL 30.9%, TSLA 23.6%, NVDA (Cash 30.7%)
- Individual position cards with shares, cost basis, current value
- AI insight card: "Your portfolio gained +1.9% this week"
- "Similar Stocks to Explore" section
- Pending + Executed trades listed

**Verdict:** The Figma design clearly shows a **fully scoped competition portfolio** — this is NOT the global practice portfolio. The $123,380 value and $23,380 return are competition-specific. The design expects isolated portfolio data per competition, with its own holdings, trades, cash, buying power, and AI insights.

---

### Source: Mobile Code — CreateCompetitionScreen.tsx + useCreateCompetitionViewModel.ts

**Starting capital in code:**
- Default value: `useState(100000)` (line 72, useCreateCompetitionViewModel.ts)
- Validation: `startingCapital > 0` (line 84) — only checks > 0, no max limit
- Input type: free-form decimal (`inputMode="decimal"`, `keyboardType="decimal-pad"`)
- Placeholder text: `"100,000.00"` (line 203, CreateCompetitionScreen.tsx)
- Field label: "Starting Portfolio Balance per Player" (line 194)
- Sent to API as: `startingCapital` in `CreateCompetitionPayload` (line 95, SimCompetition.ts)

**Verdict:** Starting capital is fully configurable by the competition creator. No client-side max validation. The $50k cap mentioned in Notion is an Alpaca-side constraint that would need to be enforced either server-side or via new client-side validation.

---

### Summary of Spec Answers

**(a) Is starting capital configurable per competition? What range?**
- **YES** — fully configurable. Creator types any amount.
- Default: $100k (code) / $100k (Figma placeholder)
- Client validation: only `> 0`
- **Actual cap: $50k per account** (Alpaca sandbox ACH limit, per Notion Apr 11 status update)
- No client-side enforcement of the $50k cap exists today

**(b) Does the design show per-competition isolated portfolios?**
- **YES** — Figma node 6338:96698 shows a full portfolio scoped to "Davidson Alumni League" with competition-specific holdings, cash, returns, trades, and AI insights. This is clearly NOT the global practice portfolio.
- NAN-101 spec explicitly says: "My Portfolio tab: same portfolio chart + metrics as Practice mode **but scoped to competition**"

**(c) What does the competition detail screen show — global portfolio or competition-scoped?**
- **Spec says: competition-scoped**
- **Current implementation: global portfolio** (hooks never pass competitionId/accountId)
- **NAN-101 has an unchecked task:** "Build `CompetitionPortfolio` (scoped portfolio view within competition)" — this was never completed
- **Gap:** The spec and Figma both expect scoped data, but the mobile code shows global data
