---
title: "Building trades — Trading Analytics for Laravel | Roundly"
description: "Build Trade value objects from named scalars, array rows or NumericValueAsString fields — open or closed, validated on construction."
url: https://roundly-consulting.com/open-source/docs/trading-analytics-for-laravel/building-trades
language: en
---

[All packages](https://roundly-consulting.com/open-source.md)

[Trading Analytics for Laravel](https://roundly-consulting.com/open-source/docs/trading-analytics-for-laravel.md)

# Building trades

A Trade describes one position: its pair (base and quote currency), direction, open and close price, size, open and close time and an optional commission. The quickest way to build one is Trade::make(), which takes plain scalars and wraps the numeric fields for you:

```php
use Illuminate\Support\Carbon;
use RoundlyConsulting\TradingAnalytics\DataTransferObjects\Trade;
use RoundlyConsulting\TradingAnalytics\Enums\Direction;

$trade = Trade::make(
    baseCurrency: 'BTC',
    quoteCurrency: 'USD',
    openPrice: '45000.00',
    closePrice: '45500.00',
    size: '0.1',
    direction: Direction::BUY,                      // or the string 'buy' / 'sell'
    openTime: Carbon::create(2024, 1, 15, 12, 30),  // or a parseable date string
    commission: '15.00',                            // optional
    closeTime: Carbon::create(2024, 1, 15, 14, 30), // omit for an open position
);
```

## From an array

Building from an array, e.g. an API payload:

```php
$trade = Trade::fromArray([
    'base_currency' => 'BTC',
    'quote_currency' => 'USD',
    'open_price' => '45000.00',
    'close_price' => '45500.00',
    'size' => '0.1',
    'direction' => 'buy',
    'open_time' => '2024-01-15 12:30:00',
    'commission' => '15.00',               // optional
    'close_time' => '2024-01-15 14:30:00', // optional
]);
```

## From any row

Trade::fromRow() reads a row of any shape your app produces — an array, a query-builder stdClass row, an Eloquent model, any Arrayable, or a plain object’s public properties — and returns a Trade as is. Trade::collect() maps a whole iterable of them lazily; Loading trade history covers models, casts and queries:

```php
use Illuminate\Support\Facades\DB;
use RoundlyConsulting\TradingAnalytics\DataTransferObjects\Trade;

$trade = Trade::fromRow(DB::table('trades')->find($id));   // stdClass row
$trade = Trade::fromRow(TradeRecord::findOrFail($id));      // your Eloquent model

// Any iterable of rows (or Trades, or a mix), mapped lazily
$trades = Trade::collect(DB::table('trades')->orderBy('close_time')->orderBy('id')->lazy());
```

## Fields

| Array key | Named argument | Type | Required | Notes |
| --- | --- | --- | --- | --- |
| `base_currency` | `baseCurrency` | string | ✓ | Non-empty, e.g. BTC. |
| `quote_currency` | `quoteCurrency` | string | ✓ | Non-empty, e.g. USD. |
| `open_price` | `openPrice` | string\|int\|float\|NumericValueAsString | ✓ | Entry price; above 0. |
| `close_price` | `closePrice` | string\|int\|float\|NumericValueAsString | ✓ | Exit price; for an open position, the price it is valued at. Not negative — 0 is a total loss. |
| `size` | `size` | string\|int\|float\|NumericValueAsString | ✓ | Quantity traded, above 0; value = size × open price. |
| `direction` | `direction` | Direction\|BackedEnum\|string | ✓ | buy or sell — or any string-backed enum with those values, e.g. your own model cast. |
| `open_time` | `openTime` | DateTimeInterface\|string | ✓ | Any DateTimeInterface (Carbon, a datetime cast) or a parseable date string. |
| `commission` | `commission` | string\|int\|float\|NumericValueAsString\|null | — | Fees; net figures subtract it. A trade without one counts as 0. |
| `close_time` | `closeTime` | DateTimeInterface\|string\|null | — | Omit (null) for an open position; must not be before the open time. |

## Open positions

Leave out the close time for a position that is still open. Its closePrice is the price the position is valued at: open trades feed the unrealized P&L, while streaks, expectancy, risk-reward, win rate by period, drawdown and the Sharpe and Sortino ratios count closed trades only.

```php
// No close time: the position is still open.
// closePrice is the price the position is valued at (unrealized P&L).
$open = Trade::make(
    baseCurrency: 'ETH',
    quoteCurrency: 'USD',
    openPrice: '3000',
    closePrice: '3120',
    size: '2',
    direction: 'buy',
    openTime: '2024-03-04 08:00',
);

$open->isOpen();                  // true
$open->isRealized();              // false
(string) $open->profitAndLoss();  // '240.0000000000'
```

## Validation

Construction throws InvalidTradeException when a currency is empty, the close time is before the open time, the size or open price is 0 or below, the close price is negative, a required field is missing or null, a field has the wrong type, or the direction is not buy or sell. A close price of 0 — a total loss — is valid:

```php
use RoundlyConsulting\TradingAnalytics\Exceptions\InvalidTradeException;

try {
    Trade::fromArray(['base_currency' => 'BTC', 'quote_currency' => 'USD']);
} catch (InvalidTradeException $e) {
    $e->getMessage(); // "A trade is missing the required 'open_price' field."
}

Trade::make('BTC', 'USD', '1', '1', '1', 'hold', '2024-01-01');
// InvalidTradeException: 'hold' is not a valid trade direction; expected one of: buy, sell.

Trade::make('BTC', 'USD', '45000', '45500', '0', 'buy', '2024-01-01');
// InvalidTradeException: A trade's size must be greater than 0; got '0.0000000000'.

Trade::fromArray([...$row, 'open_time' => 1709283600]);
// InvalidTradeException: A trade's 'open_time' field must be a date string or DateTimeInterface; got int.
```

## Full control

For full control, construct a Trade directly with NumericValueAsString fields:

```php
use RoundlyConsulting\TradingAnalytics\DataTransferObjects\NumericValueAsString;

$trade = new Trade(
    baseCurrency: 'BTC',
    quoteCurrency: 'USD',
    openPrice: new NumericValueAsString('45000.00'),
    closePrice: new NumericValueAsString('45500.00'),
    size: new NumericValueAsString('0.1'),
    direction: Direction::BUY,
    openTime: Carbon::create(2024, 1, 15, 12, 30),
);
```

A trade’s own numbers are kept exactly. Trade::make(), fromArray() and fromRow() keep every amount at its own decimal places — at least 10, exponent notation included (1.5e-14 keeps 15) — and a NumericValueAsString passed to them keeps its own scale (at least 10); a Trade constructed directly keeps the scale of its fields. A 14-decimal size or a tiny position is never cut to 10 decimals, so only the run’s scale limits what the aggregates show — raise it to match:

```php
use RoundlyConsulting\TradingAnalytics\Facades\TradingAnalytics;

$trade = Trade::make(
    baseCurrency: 'TKN',
    quoteCurrency: 'USD',
    openPrice: '0.000000000001234',
    closePrice: '0.000000000002000',
    size: '1000000',
    direction: 'buy',
    openTime: '2024-01-01 00:00',
);

$trade->openPrice->toRawString();       // '0.000000000001234' — all 15 decimals kept
$trade->profitAndLoss()->toRawString(); // '0.000000766000000'

// Raise the run's scale to match: aggregates use the run's scale (default 10)
TradingAnalytics::for([$trade])->scale(18)->calculate();
```

## Show your open-source love

This package is free and MIT-licensed. If it saves you time, a one-off donation or a Patreon membership keeps it maintained, tested and documented.

[More ways to support, including crypto](https://roundly-consulting.com/support-us.md)

By donating, you agree to our [donation terms](https://roundly-consulting.com/donation-terms.md).

[Support our open source work (opens in a new tab)](https://donate.stripe.com/dRmeVe8FX5PF1Qd9pXcEw00) [Join us on Patreon (opens in a new tab)](https://www.patreon.com/cw/roundly)

## Want this built into your product?

We integrate our packages into custom Laravel and AI builds. Tell us what you're working on and we'll reply within 48 hours.

[Get a quote in 48 hours](https://roundly-consulting.com/contact.md) [Browse all packages](https://roundly-consulting.com/open-source/docs/trading-analytics-for-laravel.md)
