# Import your history

> Accepted Schwab CSV, Schwab JSON, generic CSV, positions CSV and Order Status CSV formats, how duplicates are skipped, and how to delete an import batch.

Source: https://investi.live/docs/getting-started/import-history

Imports are the most complete way to bring in your record. Investi's Schwab sync asks for the last 365 days of trades, while a Schwab History export can go back as far as Schwab keeps it. Every row gets a fingerprint, so importing the same or an overlapping file twice never duplicates anything.

## Where to import

Open **Settings** and find the **Import Schwab history** panel (`/settings#data`). Choose **Transactions**, **Positions** or **Orders**, pick an existing account or type a name for a new one, then drop the file or click to choose it.

- Accepted: `.csv` and `.json` files up to 15 MB.
- Rejected: `.xlsx` and `.pdf` statements. In Schwab, use History > Export (CSV or JSON) instead.

The same upload is available as `POST /api/v1/imports` (multipart form with `file`, `kind` of `transactions`, `positions` or `orders`, and either `accountId` or `accountName`). It needs the `workspace:write` scope. See the [REST API](/docs/integrations/rest-api).

## How the format is detected

For a transactions import, Investi decides the format from the file itself:

1. **Schwab JSON** when the filename ends in `.json` or the content starts with `{`.
2. **Schwab CSV** when any row contains cells named exactly `Date` and `Action` (case does not matter). Title rows above the header are skipped, and rows whose date cell contains "total" are ignored.
3. **Generic CSV** otherwise, with the header in the first row.

## Schwab transactions CSV

Export from schwab.com: Accounts > History > Export. Both the current and the legacy layouts work. Columns are matched by name:

| Column | Matched by |
| --- | --- |
| `Date` | exact name |
| `Action` | exact name |
| `Symbol` | exact name |
| `Description` | exact name |
| `Quantity` | exact name, or any name starting with `Qty` |
| `Price` | exact name |
| Fees | any name containing `Fees`, such as `Fees & Comm` |
| `Amount` | exact name |

```csv
"Date","Action","Symbol","Description","Quantity","Price","Fees & Comm","Amount"
"10/30/2026 as of 10/29/2026","Buy","SPXC","SPX TECHNOLOGIES INC","10","$148.21","","-$1,482.10"
"10/31/2026","Sell","SPXC","SPX TECHNOLOGIES INC","10","$160.31","$0.05","$1,603.05"
```

## Schwab transactions JSON

The JSON export is an object with a `BrokerageTransactions` array. Each entry is read from the keys `Date`, `Action`, `Symbol`, `Description`, `Quantity`, `Price`, `Fees & Comm` and `Amount`.

```json
{
  "BrokerageTransactions": [
    { "Date": "10/30/2026", "Action": "Buy", "Symbol": "SPXC", "Description": "SPX TECHNOLOGIES INC",
      "Quantity": "10", "Price": "$148.21", "Fees & Comm": "", "Amount": "-$1,482.10" }
  ]
}
```

## Generic CSV

Any CSV with a date column and a side column works. Header names are matched case-insensitively, and the first matching column wins:

| Field | Header rule | Required |
| --- | --- | --- |
| Date | contains `date` | Yes |
| Side | starts with `side`, `action`, `type` or `transaction` | Yes |
| Symbol | starts with `symbol` or `ticker` | For fills |
| Quantity | starts with `quantity`, `qty` or `shares` | For fills |
| Price | starts with `price` | For fills, unless an amount is present |
| Fees | contains `fee` or `commission` | No |
| Amount | starts with `amount`, `net amount` or `total` | No |
| Description | starts with `description`, `memo` or `note` | No |

Sides `B` and `S` are read as Buy and Sell. Without a date and a side column, the import stops with "Could not find Date and Action/Side columns."

```csv
trade date,side,ticker,shares,price,commission
2026-01-02,B,AAPL,10,190.50,0
2026-01-20,S,AAPL,10,201.10,0
```

> **Note:** A file whose headers are exactly `Date` and `Action` is read with the Schwab CSV rules above, which expect exact names like `Symbol`, `Quantity` and `Price`, and which do not translate `B` and `S`.

## How rows are read

**Dates.** `MM/DD/YYYY` (two-digit years get 2000 added) or ISO `YYYY-MM-DD`. For Schwab's "10/30/2026 as of 10/29/2026", the as-of date wins. Fills are stored at 4:00 PM Eastern on the trade date.

**Numbers.** `$`, commas, spaces and `%` are stripped; `(12.00)` and `-$12.00` are negative; empty, `--` and `N/A` mean no value. Fees are always taken as positive.

**Price.** When the price is blank, it is derived as the absolute amount divided by the absolute quantity. A fill with neither is reported as an error and skipped.

**Actions.**

| Action text | Becomes |
| --- | --- |
| Buy, Reinvest Shares, Buy to Open | Buy that opens |
| Buy to Cover, Buy to Close | Buy that closes a short |
| Sell, Sell to Close | Sell that closes |
| Sell Short, Sell to Open | Sell that opens a short |
| Stock Split, Forward Split | Split (see below) |
| Security Transfer, Journaled Shares, Transfer of Security | Shares in: a buy with unknown basis. Shares out: ignored with a warning, never treated as a sale. |
| Anything else with an amount | A cash event |

Cash events are classified in this order: margin interest, then dividends and capital gain distributions, then other interest, tax, fees (including ADR management fees), and deposits or withdrawals (MoneyLink, wire, ACH, journal and similar, by the sign of the amount). An amount that matches none of these is kept as "other" cash with a warning. A row with an unknown action and no amount is skipped with a warning.

**Transfers in.** Shares transferred in carry no cost basis. They enter the ledger at price 0 marked unknown basis, and any later sale of those shares is excluded from verified realized P/L. See [The ledger](/docs/concepts/ledger#unknown-basis).

**Splits.** A Schwab split row lists the shares added. Investi derives the ratio from the position it reconstructs from the same file: (shares before + shares added) / shares before. If the file holds no position before the split date, the ratio cannot be derived, a warning is recorded and the split is not applied.

**Symbols.** Every ticker is resolved against the SEC's list of registered tickers. Symbols that cannot be resolved are listed after the import, and their rows are not imported. Investi does not guess.

## Duplicates

Each row's fingerprint is a hash of its raw date, action, symbol, quantity, price, fees and amount text, plus an occurrence number so that two genuinely identical rows in one file stay distinct. Fingerprints are unique per account: re-importing the same or an overlapping file into the same account inserts only the rows that are new and counts the rest as duplicates.

> **Warning:** Duplicate detection works within one account and compares raw text. Importing the same history into two different accounts, or importing a CSV into the account that Schwab sync created, counts those trades twice. Pick one format and one account per brokerage account.

When Schwab sync first sees a brokerage account, it reuses an account you created by import if exactly one such account (no Schwab link yet, not the sample) has a masked number matching the account number's last digits. Give imported accounts their last digits and the sync and your imports share one account. With no match, or more than one, the sync creates its own account.

## Positions CSV

A Schwab Positions export (Accounts > Positions > Export) gives exact share counts, cost basis, cash and margin. Choose **Positions** before uploading.

- The header row is the first row whose first cell is `Symbol`. Other columns are matched by name: description, `Qty` or quantity, `Price`, `Mkt Val` or market value, `Cost Basis`, and security type.
- The `Cash` row sets cash (a negative value is recorded as a margin debit) and the `Account Total` row sets equity.
- Rows whose security type is not an equity, ETF, stock or fund are skipped with "is not an equity; other asset classes are not yet supported."

A positions import is a snapshot at the moment you upload it. Each upload adds a new snapshot rather than replacing or deduplicating the last one.

## Order Status CSV

Schwab's API returns orders only for accounts enabled for thinkorswim. For other accounts, export your orders from schwab.com: Trade > Order Status > Export, then choose **Orders** (or drop the file under any type; it is recognized from its header).

The header row is the first row with an order number column, a status column and a symbol column; title lines above it are read for the account's last digits. Columns are matched by name, never by position:

| Field | Matched by | Required |
| --- | --- | --- |
| Order number | `Order #`, `Order Number`, `Order No`, `Order ID` | Yes |
| Symbol | `Symbol` or `Ticker` | Yes |
| Status | `Status` or `Order Status` | Yes |
| Action | `Action`, `Side` or `Instruction` | Yes |
| Quantity | `Qty`, `Quantity` or `Shares` | Yes |
| Filled quantity | `Filled Qty`, `Qty Filled`, `Filled` | No |
| Price | `Price` or `Limit` | No |
| Stop | `Stop` or `Stop Price` | No |
| Order type | `Order Type` or `Type` | No |
| Timing | `Timing`, `Duration` or `Time in Force` | No |
| Entered | a date and time column (`Time and Date`, `Entered`, `Submitted`) | No |
| Expires | a name starting with `Expir` | No |

A file without an order number column is refused: the order number is what keeps imported orders and later Schwab syncs on the same record, and Investi does not make one up.

**Values.** Status `Open`, `Working`, `Received`, `Pending` or `Queued` becomes working, or partially filled when some shares are filled; `Filled` becomes filled; `Canceled` or `Replaced` canceled; `Expired` expired; `Rejected` rejected; a pending cancel stays working until Schwab confirms it. A status Investi doesn't recognize is reported and the row skipped. Actions starting with Buy or Sell set the side. Price cells such as `Limit $12.50`, `Stop $10.00`, `Stop Limit $10.00 / $9.90` or `Market` set the order type and prices. Quantity may be `100` or `50 of 100` (filled of total). Timing `GTC`, `Day` and an extended-hours flag set the duration and session. Times are read as New York time. Option and other non-stock symbols are skipped with a note.

**Which account.** The filename (for example `...XXXXX782_Order_Status_20261101-090901.csv`) or the title line names the account's last digits. If you chose an account whose masked number disagrees, the import stops and asks you to pick the matching one. With **+ New account**, an existing account ending in those digits is used instead of creating a duplicate.

**Re-importing and Schwab sync.** Orders are keyed by account and Schwab order number, the same key Schwab sync uses. Whichever arrives first, the other updates the same order rather than adding one. Each order's status is as of the time in the filename (`20261101-090901`, New York time), or the upload time when the file has none. A re-import updates status, filled quantity and prices only when the file is at least as new as what is stored, so an older export never rolls back a fill or cancellation that a later sync or export recorded. Orders in a file that match what is stored are counted as unchanged.

## Verdict history

If you already keep verdicts in a spreadsheet, bring them in so Investi knows what you have reviewed. Open **Settings**, **Import verdict history** (`/settings#verdicts`), and drop the file, or send it to `POST /api/v1/imports/verdicts` as the multipart field `file`.

**Formats.** An `.xlsx` workbook (the first sheet is read) or a `.csv` file, up to 10 MB. Older `.xls`, Numbers and ODS files are refused with a request to save as `.xlsx` or CSV. Workbooks are read with `fflate` 0.8.3, a small zip library with no dependencies, plus Investi's own reader for the sheet XML; the `xlsx` package on npm was not used because its last npm release (0.18.5) has unfixed security advisories, and `exceljs` pulls in about 20 MB of dependencies for features an import never needs.

**Columns.** The header row can sit anywhere in the first 15 rows. Names are matched loosely:

| Column | Header names accepted | Required |
| --- | --- | --- |
| Ticker | Ticker, Symbol, Stock | Yes |
| Verdict | Verdict, Status, Rating, Signal, Color, Call, Grade | Yes |
| Date | Date, As of, Reviewed, Review date, Last reviewed, Updated | No |
| Notes | Notes, Comment, Comments, Rationale, Reason, Remarks, Thesis | No |

**Verdicts** are read case-insensitively: GREEN (also G, Go, OK, Pass, Approved), YELLOW (Y, Amber, Watch, Caution, Wait), RED (R, No, Avoid, Reject, Fail), DORMANT (D, Inactive, Parked, Asleep) and FLAGGED (F, Flag, Red flag). The colored circle emoji work too.

**Dates** can be `YYYY-MM-DD`, `MM/DD/YYYY`, `MM/DD/YY`, `Oct 14, 2026`, `14-Oct-2026` or an Excel date cell. A row without a date is dated at the import time. A future date is replaced by the import time, with a note.

**Validation.** Every row is either imported or listed with its reason: no ticker, a cell that is not a ticker, a ticker that is not a listed US security, an unknown verdict word or an unreadable date. Two rows for the same ticker and date keep the later row and list the earlier one as replaced. Nothing is dropped silently. The result shows how many were imported, how many were already imported, the count of each verdict and the columns used.

**What it stores.** Each row becomes an evaluation of that name marked `source: imported`, with the verdict as the override and the note as its rationale, dated from the row (noon New York time). Re-importing the same file writes nothing twice. Then:

- Discover treats the name as reviewed: **Unchanged** while today's verdict matches yours, **Changed** once it differs, and never **New**. See [Candidate states](/docs/concepts/candidate-states).
- Your verdict stays current. A fresh analysis (for example opening the company page) records the engine's verdict beside it and never replaces it; this holds even when the name was analyzed after the review's date. The research page shows both, for example "Your verdict RED, imported Oct 12 · engine says YELLOW", and Discover marks the name **Changed** only when the engine's verdict moves while yours stays.
- **Clear imported verdict** on the research page (or `DELETE /api/v1/securities/{ticker}/override`) writes a new revision without your verdict, so it stops carrying forward and the engine's verdict becomes current. Re-importing the same file later does not bring a cleared verdict back. Manual overrides carry forward and clear the same way.
- The action list, the REST report (`userVerdict`) and the MCP tools use your verdict as the current one.

A sample file is at [/samples/verdict-tracker-sample.csv](/samples/verdict-tracker-sample.csv).

## Import batches

Every upload creates an import batch that records the filename, detected format, row count, rows inserted, duplicates skipped, up to 500 issues, and the coverage window (earliest and latest dated row). The **Imports** panel on the History page lists your 20 most recent batches, and `GET /api/v1/imports` returns all of them.

After an upload the panel shows the result: format, new rows, duplicates skipped, unresolved symbols, coverage, and any notes from the import.

## Deleting a batch

There is no delete button in the app yet. Send `DELETE /api/v1/imports/{id}` with a token that has `workspace:write`:

```bash
curl -X DELETE https://investistocks.vercel.app/api/v1/imports/imp_abc123 \
  -H "Authorization: Bearer $INVESTI_TOKEN"
```

Deleting a batch removes, in one transaction, the fills and cash events it inserted, the position snapshots from a positions batch, the orders an orders batch holds (an order belongs to the newest import that listed it), and the batch record. It does not remove:

- rows that were skipped as duplicates, because those belong to the earlier batch that inserted them,
- splits, which are stored once per security and shared,
- the balance snapshot written by a positions import,
- the account itself.
