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:
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:
$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:
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. |
close_price | closePrice | string|int|float|NumericValueAsString | ✓ | Exit price; for an open position, the price it is valued at. |
size | size | string|int|float|NumericValueAsString | ✓ | Quantity traded; 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.
// 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, a required field is missing or null, a field has the wrong type, or the direction is not buy or sell:
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::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:
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 keep the scale they were built with — 10 decimal places through Trade::make(), fromArray() and fromRow(); even a NumericValueAsString you pass to them is re-wrapped at that scale. When a price × size needs more, construct the Trade directly with NumericValueAsString::of($value, scale: 18) and raise the run’s scale to match:
use RoundlyConsulting\TradingAnalytics\Facades\TradingAnalytics;
$trade = new Trade(
baseCurrency: 'TKN',
quoteCurrency: 'USD',
openPrice: NumericValueAsString::of('0.000000000001234', scale: 18),
closePrice: NumericValueAsString::of('0.000000000002000', scale: 18),
size: NumericValueAsString::of('1000000', scale: 18),
direction: Direction::BUY,
openTime: Carbon::parse('2024-01-01 00:00'),
);
$trade->profitAndLoss()->toRawString(); // '0.000000766000000000'
// 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 cryptoBy donating, you agree to our donation terms.
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.