# Competition Email Aliasing — Backend Research Report

**Date:** 2026-04-12
**Author:** Backend research agent
**Status:** Complete

---

## 1. Current Competition Model

### Tables
- **`sim_competitions`** — competition metadata (name, dates, status, starting_capital, settings like short_selling_allowed, commission_per_trade, allowed_symbols, etc.)
- **`sim_competition_entries`** — join table linking `user_id` to `competition_id` with `start_equity_snapshot`, `current_return_pct`, `rank`. Unique constraint on `(competition_id, user_id)`.

### How joining works today (`simtrade_competition_service.py:join_competition`)
1. Validates competition exists and is not `ended`
2. Validates invite code for private competitions
3. Checks user hasn't already joined
4. Calls `_fund_account_for_competition()` which tops up the **user's single shared Alpaca account** to meet `starting_capital`
5. Inserts a `sim_competition_entries` row with `start_equity_snapshot = starting_capital`

### Critical finding: NO per-competition portfolio isolation
**Every user has exactly ONE Alpaca sub-account** (enforced by `sim_accounts.user_id` unique constraint). When a user joins a competition, the system tops up their existing account — it does NOT create a new one. If a user is in 2 competitions simultaneously, both see the same Alpaca equity/positions. The `DECISION_PENDING` doc at `.agent-status/DECISION_PENDING_competition_portfolio_scoping.md` confirms this is a known gap.

---

## 2. Alpaca Sub-Account Creation Flow

### Current provisioning (`simtrade_service.py:provision_account` → `alpaca_client.py:create_paper_account`)

Fields sent to Alpaca `POST /v1/accounts`:
| Field | Current value |
|-------|--------------|
| `contact.email_address` | User's real email (passed from JWT) |
| `contact.phone_number` | `"0000000000"` (placeholder) |
| `contact.street_address` | `["1 Paper St"]` (placeholder) |
| `identity.given_name` | `user_id[:8]` |
| `identity.family_name` | `"Nano"` |
| `identity.tax_id` | **`"429-15-8362"` (HARDCODED — same for ALL accounts)** |
| `identity.date_of_birth` | `"2000-01-01"` |

### Uniqueness constraint
Alpaca deduplicates on **email_address**, not tax_id. Evidence:
- The 409 handler in `alpaca_client.py:110-118` catches `"already exists"` and recovers by searching by email
- All 61+ existing sub-accounts share the same hardcoded `tax_id` — Alpaca sandbox accepts this
- Alpaca docs confirm: 409 conflict message says "already an existing account registered with the same email address"

### Plus-addressing compatibility
Alpaca's email field is `format: email`. Plus-addressing (`user+tag@domain.com`) is valid per RFC 5321 and standard email format validators accept it. **The API should accept plus-aliased emails as distinct accounts** since `user+comp1@nanostreet.ai` !== `user@nanostreet.ai` as a string.

**Risk:** Not empirically verified against Alpaca's specific validation. Could potentially strip the `+tag` portion. Recommend a sandbox probe before committing to this approach.

---

## 3. Proposed Email Aliasing Scheme

For each user joining competition `comp_id`:
```
{user_email_local}+comp{comp_id_short}@nanostreet.ai
```

Example: `alice+compA1B2C3@nanostreet.ai`

This would allow multiple Alpaca sub-accounts per Nanostreet user — one per competition.

### Required changes

1. **`sim_accounts` table** — Remove `user_id` unique constraint. Add `competition_id` column. New unique constraint on `(user_id, competition_id)` where `competition_id` is nullable (null = main account).

2. **`alpaca_client.py:create_paper_account`** — Accept a `competition_id` parameter. Generate the aliased email. The hardcoded `tax_id` is fine — Alpaca doesn't deduplicate on it.

3. **`simtrade_competition_service.py:join_competition`** — Provision a NEW Alpaca sub-account with the aliased email instead of topping up the existing one.

4. **Trading service** — Route orders to the correct Alpaca account based on whether the user is trading in competition context or main context.

---

## 4. Funding Scalability

Per the empirical funding probe (`.agent-status/alpaca-sandbox-funding-probe.md`):

| Constraint | Limit |
|-----------|-------|
| Per-sub-account ACH daily cap | **$50,000/day** (hard, enforced at submission) |
| Firm account ACH daily cap | **$1,000,000/day** |
| Journal (JNLC) from firm | No documented daily cap, but requires firm cash balance |
| ACH settlement (sandbox) | Minutes with `allow_instant_ach=true` |

### Scaling scenario: 20 participants join a $100k competition

| Method | Math | Time |
|--------|------|------|
| ACH only ($50k cap/sub) | 20 × $50k = $1M ACH + 20 × $50k journal from firm | ~minutes for ACH, EOD for journals |
| Current hybrid (ACH $50k + journal remainder) | Works if firm has $1M+ cash | Minutes + EOD batch |
| Pre-fund firm, then journal all | 20 × $100k = $2M from firm | Firm needs $2M balance, EOD settlement |

**Bottleneck:** The $50k/sub ACH cap means competitions with >$50k starting capital ALWAYS need the journal fallback. For 20 participants × $100k, the firm needs $1M+ cash and journals settle at EOD (4pm ET in sandbox). **Users won't see full equity until next trading day.**

### Throughput at scale
- 50 competitions × 20 users = 1,000 Alpaca sub-accounts
- No documented rate limit on account creation, but 1,000 sequential API calls could take 5-10 minutes
- Batch creation endpoint does NOT exist — must be sequential

---

## 5. Lifecycle: What Happens When a Competition Ends

Current `run_status_transitions()` (daily cron):
- Moves `active → ended` when `end_date < today`
- Calls `_finalize_competition_ranks()` to persist final rankings
- Evicts leaderboard cache

**For alias accounts, new lifecycle needed:**
1. **End competition** → Liquidate all positions in the competition's Alpaca accounts
2. **Reclaim capital** → Journal remaining cash from each competition sub-account back to firm
3. **Close/freeze accounts** → Either close the Alpaca account (`DELETE /v1/accounts/{id}`) or mark as inactive in our DB
4. **Cleanup** — Remove `sim_accounts` rows for competition accounts (or soft-delete)

**Can we reclaim capital?** Yes — journal cash from sub → firm is the reverse of the funding flow. Positions must be liquidated first (Alpaca won't let you close an account with open positions).

---

## 6. Limitations and Risks

### HIGH RISK
| Risk | Severity | Mitigation |
|------|----------|------------|
| **Plus-addressing not tested on Alpaca** | HIGH | Must probe sandbox before committing. If Alpaca strips `+tag`, entire approach fails. |
| **$50k/sub ACH cap** | HIGH | Competitions with >$50k starting capital need journal fallback; settlement is EOD, not instant. |
| **Orphaned accounts** | HIGH | If cleanup fails, orphaned sub-accounts accumulate. Need reconciliation cron. |
| **Account creation rate** | MEDIUM | No batch API. 100 participants = 100 sequential account creations (~5-10 min). |

### MEDIUM RISK
| Risk | Severity | Mitigation |
|------|----------|------------|
| **Email deliverability** | MEDIUM | Alpaca may send verification/confirmation emails to alias addresses. In sandbox, email verification is skipped. In prod, `user+comp@nanostreet.ai` must be routable — requires a catch-all mailbox or email forwarding on `nanostreet.ai`. |
| **Identity verification** | MEDIUM | Sandbox auto-approves KYC (~28 seconds). Prod may require real identity verification per account — this could BLOCK the entire approach for live trading. |
| **Firm cash depletion** | MEDIUM | 20 competitions × 20 users × $100k = $40M in journals. Firm account must be pre-funded. |

### LOW RISK
| Risk | Severity | Notes |
|------|----------|-------|
| **tax_id sharing** | LOW | Already works — all 61+ existing accounts share the same hardcoded SSN. Alpaca sandbox doesn't enforce uniqueness on it. |
| **Account status delays** | LOW | New accounts go SUBMITTED → APPROVED → ACTIVE in ~28s in sandbox. |

---

## 7. Alternative Approaches (for comparison)

| Approach | Pros | Cons |
|----------|------|------|
| **A: Email aliasing (this report)** | True Alpaca-level portfolio isolation; real order execution per competition | Complex lifecycle; funding bottlenecks; prod KYC risk |
| **B: Software isolation (sim_orders tagging)** | No new Alpaca accounts; no funding bottleneck; works in prod | Must build position computation; dual-write complexity; not "real" isolation |
| **C: Alpaca sub-sub-accounts** | Cleanest if API supported it | Alpaca does NOT support hierarchical sub-accounts |
| **D: Separate Alpaca firm per competition** | Full isolation | Absurdly complex; requires separate Alpaca correspondent agreements |

**DECISION_PENDING doc recommendation:** Phase 1 (snapshots, no isolation) is quick win. Phase 2 (sim_orders tagging) gives true isolation without Alpaca complexity. Email aliasing is effectively a "Phase 3" that only makes sense if we need real Alpaca-executed trades per competition.

---

## 8. Recommended Next Steps

1. **Probe plus-addressing on Alpaca sandbox** — Create an account with `test+probe1@nanostreet.ai` and verify it's accepted as distinct from `test@nanostreet.ai`. This is a 5-minute test that gates the entire approach.

2. **Decide: real Alpaca execution per competition vs. software isolation** — If competitions only need leaderboard rankings (not real fills), software isolation (Phase 2) is vastly simpler and avoids all funding/lifecycle/KYC issues.

3. **If proceeding with aliasing:** Design the `sim_accounts` schema change, competition account provisioning flow, and cleanup cron before writing code.

---

## 9. Spec Verification — Answers from Linear/Notion/Figma/Code

### Source Documents Consulted
1. **Notion: "SimTrade — Status Update (Apr 11)"** — product-level status page with competition multi-account section
2. **Mobile spec: `docs/superpowers/specs/2026-03-25-simtrade-mobile-design.md`** — NAN-96 competition endpoints and NAN-101 screens
3. **Mobile plan: `docs/superpowers/plans/2026-03-27-simtrade-parallel-work.md`** — UI-9 Create Competition design spec with Figma node refs
4. **Backend model: `app/models/simtrade.py:212-219`** — `CreateCompetitionRequest` Pydantic model
5. **Mobile mock: `mockCompetitionData.ts`** — default starting capital in mock data
6. **Mobile model: `SimCompetition.ts`** — `CreateCompetitionPayload` type
7. **Figma nodes:** `6340:64332` (create competition), `6340:72079` (additional settings), `6340:72837` (success)
8. **DECISION_PENDING doc** — `.agent-status/DECISION_PENDING_competition_portfolio_scoping.md`
9. **Mobile research report** — `.agent-status/competition-email-aliasing-mobile.md`

---

### (a) Can competition starting capital differ from the base $50k user provisioning?

**YES — per spec.** The `CreateCompetitionRequest` model allows any `starting_capital > 0` (no upper bound):

> `starting_capital: float = Field(100000, gt=0, description="Starting capital for participants")`
> — `app/models/simtrade.py:219`

The Figma create-competition screen (`6340:64332`) shows a text input for "Starting Portfolio Balance" with a default of **$100,000.00** — it's a free-form numeric field, not a dropdown. The user can type any value.

The base practice account uses `settings.simtrade_starting_balance` (configured separately, currently $100k target, capped at $50k by sandbox ACH). Competition starting capital is independent of practice account balance.

**Notion status page confirms the $50k cap applies to competition accounts too:**
> "Each competition account would also be capped at $50k initial capital (same ACH limit)."
> — Notion: "SimTrade — Status Update (Apr 11)"

### (b) What is the expected range of starting capital for competitions?

**Per spec: any amount > $0, default $100k.** No upper bound in the Pydantic model or the mobile form.

Evidence from code:
- Backend default: `$100,000` (`simtrade.py:219`)
- Mobile default: `useState(100000)` (`useCreateCompetitionViewModel.ts:72`)
- Mock data: `starting_capital: 100000` (`mockCompetitionData.ts:67`)
- Figma design: `$100,000.00` shown in the create form (`6340:64332`)
- `max_stock_price` setting: `$200,000` — this is max stock price per trade, NOT starting capital (the $200k value the user may have seen in Figma)

**The $200k value in designs is `max_stock_price` (a competition trading rule), not starting capital.** From `CompetitionSettings`:
> `max_stock_price: float = Field(200000, description="Maximum allowed stock price for trades")`
> — `app/models/simtrade.py:205`

And in the Figma additional settings screen (`6340:72079`): "Min/Max Stock Price" is listed as a competition rule setting.

### (c) Does each competition participant need an isolated portfolio, or is snapshot-based ranking sufficient per spec?

**The spec implies isolated portfolios, but the initial implementation shipped with snapshots.**

The Notion status page explicitly frames the multi-account limitation as a problem to solve:
> "Alpaca enforces one sub-account per email (tax_id uniqueness). We can't create multiple isolated portfolios per user for separate competitions."
> — Notion: "SimTrade — Status Update (Apr 11)"

The mobile spec (`2026-03-27-simtrade-parallel-work.md`) describes **"Competition-scoped portfolio, leaderboard, insights"** for NAN-101:
> "Competition Screens (NAN-101): Competition list with sub-tabs (All + named competitions), Competition detail with 7-tab navigation, Join competition confirmation bottom sheet, **Competition-scoped portfolio**, leaderboard, insights"
> — `2026-03-25-simtrade-mobile-design.md:417-422`

The mobile research report confirms portfolio queries are NOT scoped today:
> "Portfolio data is ALWAYS the global practice portfolio, even inside a competition."
> — `.agent-status/competition-email-aliasing-mobile.md:40`

The DECISION_PENDING doc frames the key product question:
> "Is it acceptable that a user in 2 overlapping competitions sees the same portfolio performance in both? Or do we need true trade isolation (Phase 2)?"
> — `DECISION_PENDING_competition_portfolio_scoping.md:30`

**Conclusion:** The spec says "competition-scoped portfolio." The implementation shipped Phase 1 (snapshots) as a stepping stone. The product intent is isolation — the question is mechanism (email aliasing vs. software tagging).

### (d) Can a user be in multiple competitions simultaneously per spec?

**YES — explicitly supported.** No limit in code or spec.

Evidence:
- `sim_competition_entries` unique constraint is `(competition_id, user_id)` — prevents double-joining one competition, allows joining many
- Mock data shows a user in 2 active competitions simultaneously: "March Madness Trading" and "Davidson Alumni League" (`mockCompetitionData.ts:16-34`)
- Mobile UI has a horizontally scrollable competition pill bar to switch between active competitions
- No `MAX_COMPETITIONS_PER_USER` constant or validation exists anywhere in backend or mobile
- The DECISION_PENDING doc frames the "user in 2 overlapping competitions" scenario as expected, not edge-case

### (e) Are competition trades real Alpaca-executed trades, or simulated?

**Real Alpaca-executed trades.** All SimTrade orders (practice AND competition) go through Alpaca's Broker API.

Evidence:
- `simtrade_trading_service.py` places orders via `alpaca_trading_client.py` which calls `POST /v1/trading/accounts/{id}/orders`
- The competition service calls `_fund_account_for_competition()` which tops up the user's real Alpaca account
- The Notion status page discusses competition accounts as Alpaca sub-accounts, not simulated accounts
- The entire SimTrade feature is built on Alpaca's paper-trading infrastructure — there is no simulation layer in our code; Alpaca IS the simulation engine
- Competition settings like `short_selling_allowed`, `commission_per_trade`, `min_stock_price` are enforced at our API layer but orders still execute on Alpaca

**There is no separate "simulated" order path — email aliasing gives each competition its own Alpaca execution environment, which is the only way to get true portfolio isolation with real Alpaca order flow.**

---

### Summary Table

| Question | Answer | Source |
|----------|--------|--------|
| Can starting capital differ from $50k base? | **Yes**, any amount > $0, default $100k | `simtrade.py:219`, Figma `6340:64332` |
| Expected capital range? | **$0+ with no cap**, default $100k. $200k in designs is `max_stock_price`, not capital | `simtrade.py:205,219` |
| Isolated portfolio per competition? | **Spec says yes** ("competition-scoped portfolio"), current impl is snapshot-based | `2026-03-25-simtrade-mobile-design.md:422`, DECISION_PENDING |
| Multiple simultaneous competitions? | **Yes**, no limit | `sim_competition_entries` schema, mock data |
| Real Alpaca trades in competitions? | **Yes**, all orders go through Alpaca Broker API | `simtrade_trading_service.py`, Notion status |

---

*Research complete. Report updated 2026-04-12 with spec verification from Linear/Notion/Figma/code.*
