Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 7 additions & 2 deletions packages/exchange/src/broker/alpaca/AlpacaBroker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -188,7 +188,11 @@ export class AlpacaBroker extends Broker implements MarketDataSource {
const cb = (message: TradeUpdateMessage) => {
if (message.event === TradeUpdateEvent.FILL) {
const pair = AlpacaBrokerMapper.symbolToPair(message.order.symbol, message.order.asset_class);
const fill = AlpacaBrokerMapper.toFilledOrder(message.order, pair);
/*
* The stream is account-wide (no single pair to query rates for), so the default fee
* schedule is used — the same values `getFeeRates` returns today.
*/
const fill = AlpacaBrokerMapper.toFilledOrder(message.order, pair, AlpacaBroker.DEFAULT_FEE_RATES);
this.emit(topicId, fill);
}
};
Expand Down Expand Up @@ -318,7 +322,8 @@ export class AlpacaBroker extends Broker implements MarketDataSource {
const symbol = await this.#createReliableSymbol(pair);
const orders = await this.#alpacaAPI.getOrders({status: 'closed', symbols: symbol});
const filledOrders = orders.filter(order => order.status === AlpacaOrderStatus.FILLED);
return filledOrders.map(order => AlpacaBrokerMapper.toFilledOrder(order, pair));
const feeRates = await this.getFeeRates(pair);
return filledOrders.map(order => AlpacaBrokerMapper.toFilledOrder(order, pair, feeRates));
}

async getFillByOrderId(pair: TradingPair, orderId: string): Promise<Fill | undefined> {
Expand Down
93 changes: 90 additions & 3 deletions packages/exchange/src/broker/alpaca/AlpacaBrokerMapper.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import type {BatchedCandle} from '../../candle/BatchedCandle.js';
import {CandleBatcher} from '../../candle/CandleBatcher.js';
import {TradingPair} from '../TradingPair.js';
import {OrderPosition, OrderSide} from '../Broker.js';
import {AlpacaBroker} from './AlpacaBroker.js';

describe('AlpacaBrokerMapper', () => {
describe('mapInterval', () => {
Expand Down Expand Up @@ -62,7 +63,7 @@ describe('AlpacaBrokerMapper', () => {
});

describe('toFilledOrder', () => {
it('maps a filled BUY order', () => {
it('maps a filled stock BUY order without a fee because stock trades are commission-free', () => {
const order = {
asset_class: AlpacaAssetClass.US_EQUITY,
asset_id: '78856c3d-67c8-43bc-8cd4-e95b686cf741',
Expand Down Expand Up @@ -95,7 +96,7 @@ describe('AlpacaBrokerMapper', () => {

const pair = new TradingPair('SHOP', 'USD');

const filledOrder = AlpacaBrokerMapper.toFilledOrder(order, pair);
const filledOrder = AlpacaBrokerMapper.toFilledOrder(order, pair, AlpacaBroker.DEFAULT_FEE_RATES);

expect(filledOrder.created_at).toBe('2023-08-21T15:57:26.195019Z');
expect(filledOrder.fee).toBe('0');
Expand All @@ -108,6 +109,92 @@ describe('AlpacaBrokerMapper', () => {
expect(filledOrder.size).toBe('3');
});

it('charges the taker fee of a crypto MARKET BUY in the credited base asset', () => {
const order = {
asset_class: AlpacaAssetClass.CRYPTO,
asset_id: '276e2673-764b-4ab6-a611-caf665ca6340',
canceled_at: null,
client_order_id: '0571ce61-bf65-4f0c-b3f6-7e13ac2c1e46',
created_at: '2024-05-06T09:00:00.000001Z',
expired_at: null,
extended_hours: false,
failed_at: null,
filled_at: '2024-05-06T09:00:00.500000Z',
filled_avg_price: '61234.5',
filled_qty: '2.5',
id: '9f6f7c9e-3d9c-4a37-8f0f-97e0a54a5b26',
legs: null,
limit_price: null,
notional: null,
qty: '2.5',
replaced_at: null,
replaced_by: null,
replaces: null,
side: AlpacaOrderSide.BUY,
status: AlpacaOrderStatus.FILLED,
stop_price: null,
submitted_at: '2024-05-06T09:00:00.000002Z',
symbol: 'BTC/USD',
time_in_force: TimeInForce.GTC,
type: AlpacaOrderType.MARKET,
updated_at: '2024-05-06T09:00:00.600000Z',
} as const;

const pair = new TradingPair('BTC', 'USD');

const filledOrder = AlpacaBrokerMapper.toFilledOrder(order, pair, AlpacaBroker.DEFAULT_FEE_RATES);

// Credited asset on a BUY is the base: 2.5 BTC * 0.0025 (taker) = 0.00625 BTC
expect(filledOrder.fee).toBe('0.00625');
expect(filledOrder.feeAsset).toBe('BTC');
expect(filledOrder.price).toBe('61234.5');
expect(filledOrder.side).toBe(OrderSide.BUY);
expect(filledOrder.size).toBe('2.5');
});

it('charges the maker fee of a crypto LIMIT SELL in the credited counter asset', () => {
const order = {
asset_class: AlpacaAssetClass.CRYPTO,
asset_id: '276e2673-764b-4ab6-a611-caf665ca6340',
canceled_at: null,
client_order_id: 'a26b64d1-3c75-4c3d-9a51-8f7f7f6a1d55',
created_at: '2024-05-07T10:00:00.000001Z',
expired_at: null,
extended_hours: false,
failed_at: null,
filled_at: '2024-05-07T10:05:00.000000Z',
filled_avg_price: '61234.5',
filled_qty: '0.4',
id: 'e0cf78be-13a1-4d17-9e0a-59f0f3b3f0a8',
legs: null,
limit_price: '61234.5',
notional: null,
qty: '0.4',
replaced_at: null,
replaced_by: null,
replaces: null,
side: AlpacaOrderSide.SELL,
status: AlpacaOrderStatus.FILLED,
stop_price: null,
submitted_at: '2024-05-07T10:00:00.000002Z',
symbol: 'BTC/USD',
time_in_force: TimeInForce.GTC,
type: AlpacaOrderType.LIMIT,
updated_at: '2024-05-07T10:05:00.100000Z',
} as const;

const pair = new TradingPair('BTC', 'USD');

const filledOrder = AlpacaBrokerMapper.toFilledOrder(order, pair, AlpacaBroker.DEFAULT_FEE_RATES);

// Credited asset on a SELL is the counter: 0.4 BTC * 61234.5 USD * 0.0015 (maker) = 36.7407 USD
expect(filledOrder.fee).toBe('36.7407');
expect(filledOrder.feeAsset).toBe('USD');
expect(filledOrder.price).toBe('61234.5');
expect(filledOrder.side).toBe(OrderSide.SELL);
expect(filledOrder.size).toBe('0.4');
});

it('does not map a canceled SELL order', () => {
const order = {
asset_class: AlpacaAssetClass.US_EQUITY,
Expand Down Expand Up @@ -141,7 +228,7 @@ describe('AlpacaBrokerMapper', () => {

const pair = new TradingPair('SHOP', 'USD');

expect(() => AlpacaBrokerMapper.toFilledOrder(order, pair)).toThrowError();
expect(() => AlpacaBrokerMapper.toFilledOrder(order, pair, AlpacaBroker.DEFAULT_FEE_RATES)).toThrowError();
});
});
});
59 changes: 51 additions & 8 deletions packages/exchange/src/broker/alpaca/AlpacaBrokerMapper.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,17 @@
import Big from 'big.js';
import type {Bar} from './api/schema/BarSchema.js';
import {type Order, AlpacaOrderStatus, type AlpacaAssetClass} from './api/schema/OrderSchema.js';
import {ms} from 'ms';
import {TradingPair} from '../TradingPair.js';
import type {Candle, Fill, OrderOptions, PendingLimitOrder, PendingMarketOrder, PendingOrder} from '../Broker.js';
import type {
Candle,
FeeRate,
Fill,
OrderOptions,
PendingLimitOrder,
PendingMarketOrder,
PendingOrder,
} from '../Broker.js';
import {OrderPosition, OrderSide, OrderType} from '../Broker.js';

export class AlpacaBrokerMapper {
Expand Down Expand Up @@ -93,22 +102,56 @@ export class AlpacaBrokerMapper {
return pendingOrder;
}

static toFilledOrder(order: Order, pair: TradingPair): Fill {
static toFilledOrder(order: Order, pair: TradingPair, feeRates: FeeRate): Fill {
if (order.status !== AlpacaOrderStatus.FILLED) {
throw new Error(`Order ID "${order.id}" is not filled.`);
}

if (order.filled_avg_price === null) {
throw new Error(`Order ID "${order.id}" is filled but has no average fill price.`);
}

const side = order.side === 'buy' ? OrderSide.BUY : OrderSide.SELL;

/*
* Alpaca's order payload carries no fee field, so the realized fee has to be derived here.
* Stocks/ETFs are commission-free, but crypto is not: Alpaca deducts the fee from the
* credited asset (what you receive) per trade — a BUY pays in the base asset, a SELL pays
* in the counter/fiat. Limit orders are billed at the maker rate; every other order type
* removes liquidity and is billed at the taker (market) rate.
*
* @see https://docs.alpaca.markets/docs/crypto-fees
* @see https://files.alpaca.markets/disclosures/library/BrokFeeSched.pdf
*/
let fee = '0';
let feeAsset = pair.counter;

if (order.asset_class === 'crypto') {
const feeRate = order.type === 'limit' ? feeRates.LIMIT : feeRates.MARKET;
const filledQty = new Big(order.filled_qty);
Comment on lines +133 to +135
if (side === OrderSide.BUY) {
fee = filledQty.times(feeRate).toFixed();
feeAsset = pair.base;
} else {
fee = filledQty.times(order.filled_avg_price).times(feeRate).toFixed();
feeAsset = pair.counter;
}
}

return {
created_at: `${order.created_at}`,
/** Alpaca does not charge a commission (except for crypto) for trades: https://files.alpaca.markets/disclosures/library/BrokFeeSched.pdf */
fee: '0',
feeAsset: pair.counter,
fee,
feeAsset,
order_id: `${order.id}`,
pair,
/** @see https://forum.alpaca.markets/t/13480 */
/**
* Alpaca's order payload has no position side — from the order alone, a SELL that closes
* a long is indistinguishable from one that opens a short (https://forum.alpaca.markets/t/13480).
* This integration only trades long, so every fill is reported as LONG.
*/
position: OrderPosition.LONG,
price: `${order.filled_avg_price}`,
side: order.side === 'buy' ? OrderSide.BUY : OrderSide.SELL,
price: order.filled_avg_price,
side,
size: `${order.filled_qty}`,
};
}
Expand Down