# SimTrade XP — Investigation

**Author:** backend-integration agent
**Date:** 2026-04-07
**Status:** XP is implemented as MILESTONE-based, not per-trade. Critical bug found: `first_trade` milestone NEVER fires.

## TL;DR

1. **No, the backend does NOT increment XP on every order fill.** XP is awarded only when a milestone is achieved.
2. **The XP formula is milestone-based**, with 8 fixed milestones worth 50–500 XP each.
3. **After a market buy fills, `GET /simtrade/performance` returns `xp: 0`** — even though there is a `first_trade` milestone worth 100 XP, it can NEVER fire because the table it queries (`sim_orders`) is **never written to anywhere in the backend**.
4. **Empty state is what mobile gets today.** The `xp_message` will be `"Keep trading to earn XP from milestones."` for everyone.

## How XP Works Today

### Storage
- `sim_milestones` table: per-user achieved milestones, with `metadata.xp_awarded` for the actual amount.
- `xp_service.award_xp_internal()` writes to a separate XP ledger (used by the learn module too).
- `GET /api/simtrade/performance` reads `sim_milestones`, sums `xp_awarded`, returns `{ xp, xp_message, ... }`.

### Milestone definitions
File: `services/main/app/services/simtrade_performance_service.py:24-34`

| Key | XP | Trigger condition |
|-----|-----|-------------------|
| `first_trade` | **100** | Any row exists in `sim_orders` for this user |
| `first_profit` | 50 | Any `sim_orders` row with `side=sell, realized_pl > 0` |
| `beat_sp500_monthly` | 500 | Monthly return > SPY monthly return |
| `beat_sp500_all_time` | 500 | All-time return > SPY all-time return |
| `positive_week` | 75 | Weekly return > 0 |
| `positive_month` | 100 | Monthly return > 0 |
| `ten_pct_return` | 200 | All-time return ≥ 10% |
| `fifty_pct_return` | 500 | All-time return ≥ 50% |

### When milestones are evaluated
- `_evaluate_milestones()` runs **only** from the daily cron at 21:00 UTC (`_cron_loop`).
- It is NOT called on order placement.
- So milestone awards lag by up to ~24 hours even when they would fire.

## Critical Bugs

### Bug 1 — `first_trade` can never fire
The check at `simtrade_performance_service.py:333-339`:
```python
if "first_trade" not in existing:
    result = await client.table("sim_orders").select("id").eq("user_id", user_id).limit(1).execute()
    if result.data:
        newly_achieved.append("first_trade")
```

**The `sim_orders` table is never written to anywhere in the codebase.** Searched all of `app/services` and `app/routers` for inserts/upserts on `sim_orders` — zero matches. `place_order()` in `simtrade_trading_service.py` forwards the order to Alpaca and returns the response, but does not persist a local copy.

So:
- `sim_orders` is always empty
- `first_trade` check always returns no rows
- 100 XP for first trade is never awarded
- `first_profit` (which also reads `sim_orders` with `realized_pl > 0`) is also broken for the same reason — the column doesn't exist on a table that doesn't get populated

### Bug 2 — Milestone evaluation only runs daily
Even after fixing Bug 1, a user who places their first trade today won't see the XP until the next 21:00 UTC cron run. For an onboarding celebration UX, this delay is not acceptable.

## What Mobile Sees Today

For a brand-new user, after a successful market buy:
```
GET /api/simtrade/performance
{
  ...
  "xp": 0,
  "xp_message": "Keep trading to earn XP from milestones."
}
```

This matches the user-reported bug: "buying stocks doesn't grant any XP".

## Options

### Option A — Ship empty state (lowest effort)
- Don't change the backend.
- Mobile shows "Keep trading to earn XP from milestones." for everyone until daily cron starts firing other milestones (positive_week, ten_pct_return, etc) for users who actually have returns.
- `first_trade` and `first_profit` remain dead code.
- **Pro:** zero risk, no new code.
- **Con:** the celebration sheet feels broken — buying a stock should give *some* feedback.

### Option B — Fix `first_trade` and award XP synchronously on order fill
1. In `simtrade_trading_service.place_order()`, after the Alpaca call succeeds:
   - `INSERT INTO sim_orders (user_id, alpaca_order_id, symbol, side, qty, ...) ...`
   - Call a new helper `simtrade_performance_service.maybe_award_first_trade(user_id)` that checks if the user has the `first_trade` milestone and awards it inline (without waiting for cron).
2. Add a new migration for the `sim_orders` table if it doesn't physically exist (need to verify in `supabase/migrations/`).
3. Mobile gets `xp: 100` and `xp_message: "You earned 100 XP for your first trade!"` immediately after the first buy.
- **Pro:** matches the celebration UX.
- **Con:** new DB migration + new code path. Needs RLS policy on `sim_orders`.

### Option C — Per-trade XP (NEW formula, requires product decision)
If the product wants XP on every trade (not just milestones), we'd need a brand-new formula, e.g.:
- `+10 XP` per buy
- `+10 XP` per sell
- `+1 XP` per $1000 traded (bonus)
- Possibly capped per day to prevent farming
- This would require a new table like `sim_xp_events` and inline awarding.
- **Not recommended** without explicit product input — milestones are the existing model.

## Recommendation

**Option B is the right fix** if the product wants the celebration sheet to show real XP. It's the smallest change that makes the existing milestone model actually work, and gives users immediate feedback on their first trade.

If product is OK with empty state for now, **Option A** is fine and we can backlog Option B as `feat(simtrade): persist sim_orders + inline first-trade XP`.

Regardless of choice, the **`sim_orders` table never being written is a bug** — the milestone system was clearly designed assuming order persistence, and that step was missed in the trading service implementation.

## Verification Steps Performed

1. Searched `services/main/app` for `xp` references — found 9 files.
2. Read `simtrade_performance_service.py` end-to-end — confirmed milestone definitions, evaluation logic, cron-only invocation.
3. Searched all of `app/services` and `app/routers` for `sim_orders` writes — only the read in `_evaluate_milestones` exists.
4. Read `simtrade_trading_service.place_order()` — confirmed no local persistence.
5. Confirmed `_check_milestones` / `_evaluate_milestones` is called only from `_run_daily_snapshot` (the cron).

## Next Steps

Awaiting product/coordinator decision between Option A (ship empty) and Option B (fix first_trade inline). If Option B, I will:
1. Check `supabase/migrations/` for a `sim_orders` table — create if missing.
2. Add insert in `place_order()`.
3. Add `maybe_award_first_trade()` synchronous helper.
4. Write unit tests with TDD.
5. Open a separate PR (not in PR #274).
