Loading trade history
Pass the query itself — a query builder, an Eloquent builder or a relation — and the package streams it with lazy(), holding one page of rows in memory at a time:
use Illuminate\Support\Facades\DB;
use RoundlyConsulting\TradingAnalytics\Facades\TradingAnalytics;
TradingAnalytics::calculate(
DB::table('trades')->where('user_id', $user->id)->orderBy('close_time')->orderBy('id'),
);
// TradeRecord: your own Eloquent model
TradingAnalytics::for(TradeRecord::query()->where('account_id', $accountId)->oldest('close_time')->orderBy('id'));
TradingAnalytics::calculate($account->trades()->orderBy('close_time')->orderBy('id'), chunk: 500);- Order the query by close time. The maximum drawdown, the streaks and the running cumulative return follow the order trades close in (see Trade order), so the package never guesses one: a query without an orderBy throws UnorderedTradeSourceException before anything runs — including an Eloquent builder, which Laravel would otherwise quietly order by its primary key. Use a unique tie-breaker: ->orderBy('close_time')->orderBy('id').
- Chunk size. chunk (default 1000) is the number of rows per page; a value below 1 throws InvalidChunkSizeException. Memory scales with the chunk, never with the table.
- Paging. lazy() pages with LIMIT / OFFSET, so every page re-runs the ordered query and skips the rows before it. On very large tables, order by an indexed column (e.g. an index on (close_time, id)) so each page stays cheap.
- Your builder is left untouched: the package pages a clone.
use RoundlyConsulting\TradingAnalytics\Exceptions\UnorderedTradeSourceException;
TradingAnalytics::calculate(TradeRecord::query());
// UnorderedTradeSourceException — thrown before anything runs:
// the query has no ORDER BY. Order it by close time with a unique tie-breaker:
TradingAnalytics::calculate(TradeRecord::query()->orderBy('close_time')->orderBy('id'));Rows and models
Rows come back as stdClass (query builder) or models (Eloquent) and are read with Trade::fromRow(), so the columns — or model accessors — must provide the Trade::fromArray() fields. A model is read attribute by attribute, so its casts apply: a direction cast to your own string-backed enum (values buy / sell) and dates cast to datetime or immutable_datetime work as they are. Columns named differently? Expose the field through an accessor:
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Model;
final class TradeRecord extends Model
{
protected function casts(): array
{
return [
'direction' => Side::class, // your own enum with the values 'buy' / 'sell'
'open_price' => 'decimal:8',
'close_price' => 'decimal:8',
'size' => 'decimal:8',
'commission' => 'decimal:8',
'close_time' => 'datetime',
];
}
// The column is called opened_at — expose it as the open_time field
protected function openTime(): Attribute
{
return Attribute::get(fn (): mixed => $this->opened_at);
}
}Any iterable
Without a query, hand the facade any iterable of rows — arrays, Trade instances or both mixed. Trade::collect() (and TradingAnalytics::trades()) map it into a LazyCollection of trades, so nothing is read until the engine runs:
use RoundlyConsulting\TradingAnalytics\DataTransferObjects\Trade;
$trades = Trade::collect([
[
'base_currency' => 'BTC',
'quote_currency' => 'USD',
'open_price' => '40000',
'close_price' => '42000',
'size' => '0.5',
'direction' => 'buy',
'open_time' => '2024-03-01 09:00',
'commission' => '10',
'close_time' => '2024-03-01 15:00',
],
$trade, // an already-built Trade passes through unchanged
]);
// LazyCollection<int, Trade>- Rows are validated as they are consumed, so an invalid row throws InvalidTradeException during calculate(), not when you call collect(). An unordered query, by contrast, throws as soon as you pass it.
- Filter in the query (a date range, one portfolio) and run the engine per slice.
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.