@adaptic/utils 0.0.1002 → 0.0.1003
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.cjs +642 -160
- package/dist/index.cjs.map +1 -1
- package/dist/index.mjs +642 -160
- package/dist/index.mjs.map +1 -1
- package/dist/test.js +70 -0
- package/dist/test.js.map +1 -1
- package/dist/types/alpaca-trading-api.d.ts +68 -4
- package/dist/types/alpaca-trading-api.d.ts.map +1 -1
- package/dist/types/crypto.d.ts.map +1 -1
- package/dist/types/performance-metrics.d.ts.map +1 -1
- package/dist/types/price-utils.d.ts.map +1 -1
- package/dist/types/rate-limiter.d.ts +21 -0
- package/dist/types/rate-limiter.d.ts.map +1 -1
- package/dist/types/types/alpaca-types.d.ts +1 -0
- package/dist/types/types/alpaca-types.d.ts.map +1 -1
- package/package.json +1 -1
package/dist/index.cjs
CHANGED
|
@@ -5,6 +5,7 @@ var dateFns = require('date-fns');
|
|
|
5
5
|
var dateFnsTz = require('date-fns-tz');
|
|
6
6
|
var require$$0$1 = require('events');
|
|
7
7
|
var WebSocket = require('ws');
|
|
8
|
+
var node_crypto = require('node:crypto');
|
|
8
9
|
var ms = require('ms');
|
|
9
10
|
var require$$0$2 = require('fs');
|
|
10
11
|
var require$$1 = require('path');
|
|
@@ -2405,6 +2406,19 @@ class DataFormatError extends AdapticUtilsError {
|
|
|
2405
2406
|
* const result = await makeAlpacaApiCall();
|
|
2406
2407
|
* ```
|
|
2407
2408
|
*/
|
|
2409
|
+
/** Number of milliseconds in one second, used for token-refill timing math. */
|
|
2410
|
+
const MS_PER_SECOND$1 = 1000;
|
|
2411
|
+
/**
|
|
2412
|
+
* Minimum delay (ms) for a scheduled queue wake-up. Guards against a `0`/`NaN`
|
|
2413
|
+
* delay when the token deficit rounds down, ensuring the timer always makes
|
|
2414
|
+
* forward progress rather than busy-looping on the event loop.
|
|
2415
|
+
*/
|
|
2416
|
+
const MIN_WAKE_DELAY_MS = 1;
|
|
2417
|
+
/**
|
|
2418
|
+
* Number of whole tokens required to release a single queued request. The token
|
|
2419
|
+
* bucket consumes exactly one token per admitted request.
|
|
2420
|
+
*/
|
|
2421
|
+
const TOKENS_PER_REQUEST = 1;
|
|
2408
2422
|
/**
|
|
2409
2423
|
* Token bucket rate limiter implementation
|
|
2410
2424
|
*
|
|
@@ -2420,6 +2434,13 @@ class TokenBucketRateLimiter {
|
|
|
2420
2434
|
queue = [];
|
|
2421
2435
|
timeoutMs;
|
|
2422
2436
|
processingQueue = false;
|
|
2437
|
+
/**
|
|
2438
|
+
* Single pending timer that wakes the limiter to refill tokens and drain the
|
|
2439
|
+
* queue. Without this, a queued request would only be released by a
|
|
2440
|
+
* subsequent {@link acquire} call and would otherwise stall until its own
|
|
2441
|
+
* timeout fired. `null` means no wake-up is currently scheduled.
|
|
2442
|
+
*/
|
|
2443
|
+
wakeTimer = null;
|
|
2423
2444
|
/**
|
|
2424
2445
|
* Creates a new rate limiter instance
|
|
2425
2446
|
*
|
|
@@ -2493,8 +2514,48 @@ class TokenBucketRateLimiter {
|
|
|
2493
2514
|
reject(error);
|
|
2494
2515
|
}, this.timeoutMs);
|
|
2495
2516
|
this.queue.push({ resolve, reject, timeoutHandle });
|
|
2517
|
+
// Ensure the queue is actively drained even if no further acquire() calls
|
|
2518
|
+
// arrive: schedule a wake-up to refill tokens and release this request.
|
|
2519
|
+
this.scheduleQueueWake();
|
|
2496
2520
|
});
|
|
2497
2521
|
}
|
|
2522
|
+
/**
|
|
2523
|
+
* Schedules a single wake-up timer that refills tokens and drains the queue.
|
|
2524
|
+
*
|
|
2525
|
+
* The delay is the time required to accrue the tokens still needed to release
|
|
2526
|
+
* the next queued request at the configured refill rate. Only one timer is
|
|
2527
|
+
* ever outstanding (guarded by {@link wakeTimer}); the timer is `unref`'d so
|
|
2528
|
+
* it never keeps the Node.js process alive on its own. When it fires it
|
|
2529
|
+
* refills, drains what it can, and re-arms itself if work remains.
|
|
2530
|
+
*/
|
|
2531
|
+
scheduleQueueWake() {
|
|
2532
|
+
// A wake-up is already pending, or there is nothing to wake for.
|
|
2533
|
+
if (this.wakeTimer !== null || this.queue.length === 0) {
|
|
2534
|
+
return;
|
|
2535
|
+
}
|
|
2536
|
+
const tokensNeeded = Math.max(0, TOKENS_PER_REQUEST - this.tokens);
|
|
2537
|
+
const deficitMs = Math.max(MIN_WAKE_DELAY_MS, Math.ceil((tokensNeeded / this.config.refillRate) * MS_PER_SECOND$1));
|
|
2538
|
+
const timer = setTimeout(() => {
|
|
2539
|
+
this.wakeTimer = null;
|
|
2540
|
+
// refill() drains the queue via processQueue(); if requests remain
|
|
2541
|
+
// afterwards, processQueue() re-arms the wake-up.
|
|
2542
|
+
this.refill();
|
|
2543
|
+
}, deficitMs);
|
|
2544
|
+
// Do not let a pending rate-limiter wake-up keep the process alive.
|
|
2545
|
+
if (typeof timer.unref === "function") {
|
|
2546
|
+
timer.unref();
|
|
2547
|
+
}
|
|
2548
|
+
this.wakeTimer = timer;
|
|
2549
|
+
}
|
|
2550
|
+
/**
|
|
2551
|
+
* Clears any pending wake-up timer.
|
|
2552
|
+
*/
|
|
2553
|
+
clearWakeTimer() {
|
|
2554
|
+
if (this.wakeTimer !== null) {
|
|
2555
|
+
clearTimeout(this.wakeTimer);
|
|
2556
|
+
this.wakeTimer = null;
|
|
2557
|
+
}
|
|
2558
|
+
}
|
|
2498
2559
|
/**
|
|
2499
2560
|
* Refills tokens based on elapsed time and processes queued requests
|
|
2500
2561
|
*
|
|
@@ -2539,6 +2600,15 @@ class TokenBucketRateLimiter {
|
|
|
2539
2600
|
finally {
|
|
2540
2601
|
this.processingQueue = false;
|
|
2541
2602
|
}
|
|
2603
|
+
// Keep the wake-up state consistent with the queue: if requests are still
|
|
2604
|
+
// waiting (tokens ran out mid-drain), ensure a wake-up is armed; otherwise
|
|
2605
|
+
// release any pending timer so it cannot fire needlessly.
|
|
2606
|
+
if (this.queue.length > 0) {
|
|
2607
|
+
this.scheduleQueueWake();
|
|
2608
|
+
}
|
|
2609
|
+
else {
|
|
2610
|
+
this.clearWakeTimer();
|
|
2611
|
+
}
|
|
2542
2612
|
}
|
|
2543
2613
|
/**
|
|
2544
2614
|
* Gets the current number of available tokens
|
|
@@ -2570,6 +2640,7 @@ class TokenBucketRateLimiter {
|
|
|
2570
2640
|
clearTimeout(request.timeoutHandle);
|
|
2571
2641
|
request.reject(new RateLimitError(`Rate limiter reset for ${this.config.label}`, this.config.label, undefined));
|
|
2572
2642
|
}
|
|
2643
|
+
this.clearWakeTimer();
|
|
2573
2644
|
this.queue = [];
|
|
2574
2645
|
this.tokens = this.config.maxTokens;
|
|
2575
2646
|
this.lastRefill = Date.now();
|
|
@@ -4319,6 +4390,46 @@ const limitPriceSlippagePercent100 = 0.1; // 0.1%
|
|
|
4319
4390
|
const ORDER_PAGE_LIMIT = 500;
|
|
4320
4391
|
/** Delay between order pagination pages to stay clear of rate limits. */
|
|
4321
4392
|
const ORDER_PAGINATION_DELAY_MS = 300;
|
|
4393
|
+
/**
|
|
4394
|
+
* HTTP status at or above which a Multi-Status (207) sub-result is a failure.
|
|
4395
|
+
* Alpaca's bulk `DELETE /orders` and `DELETE /positions` endpoints return a 207
|
|
4396
|
+
* envelope whose top-level status is 2xx even when individual orders/positions
|
|
4397
|
+
* failed to cancel or close; each element carries its own per-item HTTP status.
|
|
4398
|
+
* Treating >= 300 as a failure lets the engine failsafe see partial failures
|
|
4399
|
+
* instead of recording a false success.
|
|
4400
|
+
*/
|
|
4401
|
+
const HTTP_STATUS_MIN_ERROR = 300;
|
|
4402
|
+
/**
|
|
4403
|
+
* Prefix applied to engine-derived `client_order_id` idempotency keys so they
|
|
4404
|
+
* are visibly attributable in Alpaca's dashboard and can never collide with a
|
|
4405
|
+
* caller-supplied identifier.
|
|
4406
|
+
*/
|
|
4407
|
+
const CLIENT_ORDER_ID_PREFIX = "adaptic-";
|
|
4408
|
+
/**
|
|
4409
|
+
* Number of leading hex characters of the SHA-256 digest retained in a derived
|
|
4410
|
+
* `client_order_id`. 32 hex chars = 128 bits of entropy (collision-negligible),
|
|
4411
|
+
* and keeps the full id (prefix + digest = 40 chars) within Alpaca's identifier
|
|
4412
|
+
* length limit.
|
|
4413
|
+
*/
|
|
4414
|
+
const CLIENT_ORDER_ID_HASH_LENGTH = 32;
|
|
4415
|
+
/**
|
|
4416
|
+
* Idempotency window (ms) used when deriving a default `client_order_id`.
|
|
4417
|
+
*
|
|
4418
|
+
* A client request timeout followed by an automatic retry re-submits the SAME
|
|
4419
|
+
* logical order. Deriving the id from the order's semantic parameters plus the
|
|
4420
|
+
* current time-window bucket makes Alpaca reject the retried duplicate
|
|
4421
|
+
* (`client_order_id must be unique`) instead of double-filling. The window is
|
|
4422
|
+
* deliberately much larger than the 30s Alpaca request timeout so a full
|
|
4423
|
+
* timeout+retry sequence lands in the same bucket, while a genuinely new but
|
|
4424
|
+
* otherwise-identical order placed in a later window still receives a distinct
|
|
4425
|
+
* id.
|
|
4426
|
+
*
|
|
4427
|
+
* This derived default is a best-effort safety net; the guaranteed-idempotent
|
|
4428
|
+
* path is for the caller to pass an explicit `clientOrderId` tied to the
|
|
4429
|
+
* originating signal/decision id (which also permits legitimately-repeated
|
|
4430
|
+
* identical orders inside a single window).
|
|
4431
|
+
*/
|
|
4432
|
+
const CLIENT_ORDER_ID_WINDOW_MS = 300_000;
|
|
4322
4433
|
/**
|
|
4323
4434
|
Websocket example
|
|
4324
4435
|
const alpacaAPI = createAlpacaTradingAPI(credentials); // type AlpacaCredentials
|
|
@@ -4399,6 +4510,114 @@ class AlpacaTradingAPI {
|
|
|
4399
4510
|
? Math.round(price * 100) / 100
|
|
4400
4511
|
: Math.round(price * 10000) / 10000;
|
|
4401
4512
|
};
|
|
4513
|
+
/**
|
|
4514
|
+
* Derive a deterministic `client_order_id` from an order's semantic
|
|
4515
|
+
* parameters so that a client-timeout-triggered retry re-submits the SAME id
|
|
4516
|
+
* and Alpaca rejects the duplicate broker-side instead of double-filling.
|
|
4517
|
+
*
|
|
4518
|
+
* The id is stable for identical parameters within a single
|
|
4519
|
+
* {@link CLIENT_ORDER_ID_WINDOW_MS} bucket and scoped per account. Callers
|
|
4520
|
+
* that must place genuinely distinct yet otherwise-identical orders should
|
|
4521
|
+
* pass an explicit `clientOrderId` rather than relying on this default.
|
|
4522
|
+
*
|
|
4523
|
+
* @param parts - Ordered, stringifiable components uniquely describing the
|
|
4524
|
+
* order (e.g. order kind, symbol, side, quantity, price, intent).
|
|
4525
|
+
* @returns An Alpaca-safe `client_order_id` (prefix + truncated SHA-256 hex).
|
|
4526
|
+
*/
|
|
4527
|
+
deriveClientOrderId(parts) {
|
|
4528
|
+
const windowBucket = Math.floor(Date.now() / CLIENT_ORDER_ID_WINDOW_MS);
|
|
4529
|
+
const material = [
|
|
4530
|
+
this.credentials.accountName,
|
|
4531
|
+
windowBucket,
|
|
4532
|
+
...parts.map((part) => (part === undefined ? "" : String(part))),
|
|
4533
|
+
].join("|");
|
|
4534
|
+
const digest = node_crypto.createHash("sha256")
|
|
4535
|
+
.update(material)
|
|
4536
|
+
.digest("hex")
|
|
4537
|
+
.slice(0, CLIENT_ORDER_ID_HASH_LENGTH);
|
|
4538
|
+
return `${CLIENT_ORDER_ID_PREFIX}${digest}`;
|
|
4539
|
+
}
|
|
4540
|
+
/**
|
|
4541
|
+
* Collect the human-readable failure entries from a bulk Multi-Status (207)
|
|
4542
|
+
* response body (`DELETE /orders`, `DELETE /positions`). Each element carries
|
|
4543
|
+
* its own per-item HTTP status; any element with status >=
|
|
4544
|
+
* {@link HTTP_STATUS_MIN_ERROR} is a failure the caller must be able to see.
|
|
4545
|
+
*
|
|
4546
|
+
* @param entries - Parsed 207 response array.
|
|
4547
|
+
* @returns One `"<identifier>:<status>"` string per failed entry.
|
|
4548
|
+
*/
|
|
4549
|
+
collectMultiStatusFailures(entries) {
|
|
4550
|
+
return entries
|
|
4551
|
+
.filter((entry) => typeof entry.status === "number" &&
|
|
4552
|
+
entry.status >= HTTP_STATUS_MIN_ERROR)
|
|
4553
|
+
.map((entry) => `${entry.symbol ?? entry.id ?? "unknown"}:${entry.status}`);
|
|
4554
|
+
}
|
|
4555
|
+
/**
|
|
4556
|
+
* Flatten a single position with a marketable limit order, deriving the
|
|
4557
|
+
* closing side, intent, and slippage-adjusted limit price from the latest
|
|
4558
|
+
* quote. Throws when no usable quote/price is available for the symbol so the
|
|
4559
|
+
* caller can record a per-position failure via {@link Promise.allSettled}
|
|
4560
|
+
* without aborting the flatten of the remaining positions.
|
|
4561
|
+
*
|
|
4562
|
+
* @param position - The position to close.
|
|
4563
|
+
* @param quotesResponse - Latest quotes keyed by symbol.
|
|
4564
|
+
* @param extendedHours - Whether the closing order is an extended-hours order.
|
|
4565
|
+
*/
|
|
4566
|
+
async closePositionWithLimitOrder(position, quotesResponse, extendedHours) {
|
|
4567
|
+
const quote = quotesResponse.quotes[position.symbol];
|
|
4568
|
+
if (!quote) {
|
|
4569
|
+
throw new Error(`No quote available for ${position.symbol}`);
|
|
4570
|
+
}
|
|
4571
|
+
const qty = Math.abs(parseFloat(position.qty));
|
|
4572
|
+
const side = position.side === "long" ? "sell" : "buy";
|
|
4573
|
+
const positionIntent = side === "sell" ? "sell_to_close" : "buy_to_close";
|
|
4574
|
+
// Use bid for sells, ask for buys.
|
|
4575
|
+
const currentPrice = side === "sell" ? quote.bp : quote.ap;
|
|
4576
|
+
if (!currentPrice) {
|
|
4577
|
+
throw new Error(`No valid price available for ${position.symbol}`);
|
|
4578
|
+
}
|
|
4579
|
+
const limitSlippagePercent1 = limitPriceSlippagePercent100 / 100;
|
|
4580
|
+
const limitPrice = side === "sell"
|
|
4581
|
+
? this.roundPriceForAlpaca(currentPrice * (1 - limitSlippagePercent1)) // Sell slightly lower
|
|
4582
|
+
: this.roundPriceForAlpaca(currentPrice * (1 + limitSlippagePercent1)); // Buy slightly higher
|
|
4583
|
+
this.log(`Creating ${extendedHours ? "extended hours " : ""}limit order to close ${position.symbol} position: ${side} ${qty} shares at $${limitPrice.toFixed(2)}`, {
|
|
4584
|
+
symbol: position.symbol,
|
|
4585
|
+
});
|
|
4586
|
+
await this.createLimitOrder(position.symbol, qty, side, limitPrice, positionIntent, extendedHours);
|
|
4587
|
+
}
|
|
4588
|
+
/**
|
|
4589
|
+
* Flatten every supplied position independently and surface an aggregate
|
|
4590
|
+
* failure if any could not be closed. Positions are attempted concurrently
|
|
4591
|
+
* with {@link Promise.allSettled} so a data gap or broker rejection on one
|
|
4592
|
+
* symbol never silently prevents the others from being flattened.
|
|
4593
|
+
*
|
|
4594
|
+
* @param positions - Positions to flatten.
|
|
4595
|
+
* @param extendedHours - Whether the closing orders are extended-hours orders.
|
|
4596
|
+
* @throws Error listing every symbol that failed to flatten.
|
|
4597
|
+
*/
|
|
4598
|
+
async flattenPositionsWithLimitOrders(positions, extendedHours) {
|
|
4599
|
+
const symbols = positions.map((position) => position.symbol);
|
|
4600
|
+
const quotesResponse = await marketDataAPI.getLatestQuotes(symbols);
|
|
4601
|
+
const results = await Promise.allSettled(positions.map((position) => this.closePositionWithLimitOrder(position, quotesResponse, extendedHours)));
|
|
4602
|
+
const failures = [];
|
|
4603
|
+
results.forEach((result, index) => {
|
|
4604
|
+
if (result.status === "rejected") {
|
|
4605
|
+
const symbol = positions[index]?.symbol ?? "unknown";
|
|
4606
|
+
const reason = result.reason instanceof Error
|
|
4607
|
+
? result.reason.message
|
|
4608
|
+
: String(result.reason);
|
|
4609
|
+
failures.push(`${symbol}: ${reason}`);
|
|
4610
|
+
this.log(`Failed to close position ${symbol}: ${reason}`, {
|
|
4611
|
+
symbol,
|
|
4612
|
+
type: "error",
|
|
4613
|
+
});
|
|
4614
|
+
}
|
|
4615
|
+
});
|
|
4616
|
+
if (failures.length > 0) {
|
|
4617
|
+
throw new Error(`Failed to close ${failures.length} of ${positions.length} positions: ${failures.join("; ")}`);
|
|
4618
|
+
}
|
|
4619
|
+
this.log(`All positions closed: ${symbols.join(", ")}`);
|
|
4620
|
+
}
|
|
4402
4621
|
handleAuthMessage(data) {
|
|
4403
4622
|
if (data.status === "authorized") {
|
|
4404
4623
|
this.authenticated = true;
|
|
@@ -4770,21 +4989,31 @@ class AlpacaTradingAPI {
|
|
|
4770
4989
|
* @param position_intent (string) - the position intent of the order
|
|
4771
4990
|
* @returns The created AlpacaOrder with order ID and details
|
|
4772
4991
|
*/
|
|
4773
|
-
async createTrailingStop(symbol, qty, side, trailPercent100, position_intent) {
|
|
4992
|
+
async createTrailingStop(symbol, qty, side, trailPercent100, position_intent, clientOrderId) {
|
|
4774
4993
|
this.log(`Creating trailing stop ${side.toUpperCase()} ${qty} shares for ${symbol} with trail percent ${trailPercent100}%`, {
|
|
4775
4994
|
symbol,
|
|
4776
4995
|
});
|
|
4996
|
+
const body = {
|
|
4997
|
+
symbol,
|
|
4998
|
+
qty: Math.abs(qty).toString(),
|
|
4999
|
+
side,
|
|
5000
|
+
position_intent,
|
|
5001
|
+
order_class: "simple",
|
|
5002
|
+
type: "trailing_stop",
|
|
5003
|
+
trail_percent: trailPercent100.toString(), // Already in decimal form (e.g., 4 for 4%)
|
|
5004
|
+
time_in_force: "gtc",
|
|
5005
|
+
client_order_id: clientOrderId ??
|
|
5006
|
+
this.deriveClientOrderId([
|
|
5007
|
+
"trailing_stop",
|
|
5008
|
+
symbol,
|
|
5009
|
+
side,
|
|
5010
|
+
position_intent,
|
|
5011
|
+
Math.abs(qty),
|
|
5012
|
+
trailPercent100,
|
|
5013
|
+
]),
|
|
5014
|
+
};
|
|
4777
5015
|
try {
|
|
4778
|
-
const order = await this.makeRequest(`/orders`, "POST",
|
|
4779
|
-
symbol,
|
|
4780
|
-
qty: Math.abs(qty),
|
|
4781
|
-
side,
|
|
4782
|
-
position_intent,
|
|
4783
|
-
order_class: "simple",
|
|
4784
|
-
type: "trailing_stop",
|
|
4785
|
-
trail_percent: trailPercent100, // Already in decimal form (e.g., 4 for 4%)
|
|
4786
|
-
time_in_force: "gtc",
|
|
4787
|
-
});
|
|
5016
|
+
const order = await this.makeRequest(`/orders`, "POST", body);
|
|
4788
5017
|
this.log(`Trailing stop order created for ${symbol}: orderId=${order.id}, trailPercent=${trailPercent100}%`, { symbol });
|
|
4789
5018
|
return order;
|
|
4790
5019
|
}
|
|
@@ -4816,9 +5045,15 @@ class AlpacaTradingAPI {
|
|
|
4816
5045
|
time_in_force: "day",
|
|
4817
5046
|
order_class: "simple",
|
|
4818
5047
|
};
|
|
4819
|
-
|
|
4820
|
-
|
|
4821
|
-
|
|
5048
|
+
body.client_order_id =
|
|
5049
|
+
client_order_id ??
|
|
5050
|
+
this.deriveClientOrderId([
|
|
5051
|
+
"market",
|
|
5052
|
+
symbol,
|
|
5053
|
+
side,
|
|
5054
|
+
position_intent,
|
|
5055
|
+
Math.abs(qty),
|
|
5056
|
+
]);
|
|
4822
5057
|
try {
|
|
4823
5058
|
return await this.makeRequest("/orders", "POST", body);
|
|
4824
5059
|
}
|
|
@@ -4919,16 +5154,31 @@ class AlpacaTradingAPI {
|
|
|
4919
5154
|
}
|
|
4920
5155
|
}
|
|
4921
5156
|
/**
|
|
4922
|
-
* Cancel all open orders
|
|
5157
|
+
* Cancel all open orders.
|
|
5158
|
+
*
|
|
5159
|
+
* Alpaca's bulk cancel returns a 207 Multi-Status body whose top-level status
|
|
5160
|
+
* is 2xx even when individual orders failed to cancel; this method inspects
|
|
5161
|
+
* the per-order statuses and throws if any order could not be canceled, so a
|
|
5162
|
+
* caller acting as a live-stop failsafe cannot record success while orders
|
|
5163
|
+
* remain live. Transport/HTTP errors propagate unchanged (matching the
|
|
5164
|
+
* throw-on-failure contract of {@link cancelOrder}).
|
|
5165
|
+
*
|
|
5166
|
+
* @throws Error if the bulk cancel request fails or any individual order
|
|
5167
|
+
* could not be canceled.
|
|
4923
5168
|
*/
|
|
4924
5169
|
async cancelAllOrders() {
|
|
4925
5170
|
this.log(`Canceling all open orders`);
|
|
4926
|
-
|
|
4927
|
-
|
|
5171
|
+
const results = await this.makeRequest("/orders", "DELETE");
|
|
5172
|
+
if (!Array.isArray(results)) {
|
|
5173
|
+
return;
|
|
4928
5174
|
}
|
|
4929
|
-
|
|
4930
|
-
|
|
5175
|
+
const failures = this.collectMultiStatusFailures(results);
|
|
5176
|
+
if (failures.length > 0) {
|
|
5177
|
+
const detail = failures.join(", ");
|
|
5178
|
+
this.log(`Error canceling all orders: ${failures.length}/${results.length} orders failed to cancel (${detail})`, { type: "error" });
|
|
5179
|
+
throw new Error(`Failed to cancel ${failures.length} of ${results.length} orders: ${detail}`);
|
|
4931
5180
|
}
|
|
5181
|
+
this.log(`Successfully canceled ${results.length} open orders`);
|
|
4932
5182
|
}
|
|
4933
5183
|
/**
|
|
4934
5184
|
* Cancel a specific order by its ID
|
|
@@ -4979,9 +5229,17 @@ class AlpacaTradingAPI {
|
|
|
4979
5229
|
order_class: "simple",
|
|
4980
5230
|
extended_hours,
|
|
4981
5231
|
};
|
|
4982
|
-
|
|
4983
|
-
|
|
4984
|
-
|
|
5232
|
+
body.client_order_id =
|
|
5233
|
+
client_order_id ??
|
|
5234
|
+
this.deriveClientOrderId([
|
|
5235
|
+
"limit",
|
|
5236
|
+
symbol,
|
|
5237
|
+
side,
|
|
5238
|
+
position_intent,
|
|
5239
|
+
Math.abs(qty),
|
|
5240
|
+
this.roundPriceForAlpaca(limitPrice),
|
|
5241
|
+
extended_hours,
|
|
5242
|
+
]);
|
|
4985
5243
|
try {
|
|
4986
5244
|
return await this.makeRequest("/orders", "POST", body);
|
|
4987
5245
|
}
|
|
@@ -5009,55 +5267,21 @@ class AlpacaTradingAPI {
|
|
|
5009
5267
|
return;
|
|
5010
5268
|
}
|
|
5011
5269
|
this.log(`Found ${positions.length} positions to close`);
|
|
5012
|
-
//
|
|
5013
|
-
|
|
5014
|
-
|
|
5015
|
-
|
|
5016
|
-
if (lengthOfQuotes === 0) {
|
|
5017
|
-
this.log("No quotes available for positions, received 0 quotes", {
|
|
5018
|
-
type: "error",
|
|
5019
|
-
});
|
|
5020
|
-
return;
|
|
5021
|
-
}
|
|
5022
|
-
if (lengthOfQuotes !== positions.length) {
|
|
5023
|
-
this.log(`Received ${lengthOfQuotes} quotes for ${positions.length} positions, expected ${positions.length} quotes`, { type: "warn" });
|
|
5024
|
-
return;
|
|
5025
|
-
}
|
|
5026
|
-
// Create limit orders to close each position
|
|
5027
|
-
for (const position of positions) {
|
|
5028
|
-
const quote = quotesResponse.quotes[position.symbol];
|
|
5029
|
-
if (!quote) {
|
|
5030
|
-
this.log(`No quote available for ${position.symbol}, skipping limit order`, {
|
|
5031
|
-
symbol: position.symbol,
|
|
5032
|
-
type: "warn",
|
|
5033
|
-
});
|
|
5034
|
-
continue;
|
|
5035
|
-
}
|
|
5036
|
-
const qty = Math.abs(parseFloat(position.qty));
|
|
5037
|
-
const side = position.side === "long" ? "sell" : "buy";
|
|
5038
|
-
const positionIntent = side === "sell" ? "sell_to_close" : "buy_to_close";
|
|
5039
|
-
// Get the current price from the quote
|
|
5040
|
-
const currentPrice = side === "sell" ? quote.bp : quote.ap; // Use bid for sells, ask for buys
|
|
5041
|
-
if (!currentPrice) {
|
|
5042
|
-
this.log(`No valid price available for ${position.symbol}, skipping limit order`, {
|
|
5043
|
-
symbol: position.symbol,
|
|
5044
|
-
type: "warn",
|
|
5045
|
-
});
|
|
5046
|
-
continue;
|
|
5047
|
-
}
|
|
5048
|
-
// Apply slippage from config
|
|
5049
|
-
const limitSlippagePercent1 = limitPriceSlippagePercent100 / 100;
|
|
5050
|
-
const limitPrice = side === "sell"
|
|
5051
|
-
? this.roundPriceForAlpaca(currentPrice * (1 - limitSlippagePercent1)) // Sell slightly lower
|
|
5052
|
-
: this.roundPriceForAlpaca(currentPrice * (1 + limitSlippagePercent1)); // Buy slightly higher
|
|
5053
|
-
this.log(`Creating limit order to close ${position.symbol} position: ${side} ${qty} shares at $${limitPrice.toFixed(2)}`, {
|
|
5054
|
-
symbol: position.symbol,
|
|
5055
|
-
});
|
|
5056
|
-
await this.createLimitOrder(position.symbol, qty, side, limitPrice, positionIntent);
|
|
5057
|
-
}
|
|
5270
|
+
// Flatten each position independently. A missing quote or broker
|
|
5271
|
+
// rejection on one symbol must never abort the flatten of the others; any
|
|
5272
|
+
// per-position failure is surfaced as an aggregate error.
|
|
5273
|
+
await this.flattenPositionsWithLimitOrders(positions, false);
|
|
5058
5274
|
}
|
|
5059
5275
|
else {
|
|
5060
|
-
await this.makeRequest("/positions", "DELETE", undefined, options.cancel_orders ? "?cancel_orders=true" : "");
|
|
5276
|
+
const results = await this.makeRequest("/positions", "DELETE", undefined, options.cancel_orders ? "?cancel_orders=true" : "");
|
|
5277
|
+
if (Array.isArray(results)) {
|
|
5278
|
+
const failures = this.collectMultiStatusFailures(results);
|
|
5279
|
+
if (failures.length > 0) {
|
|
5280
|
+
const detail = failures.join(", ");
|
|
5281
|
+
this.log(`Error closing all positions: ${failures.length}/${results.length} positions failed to close (${detail})`, { type: "error" });
|
|
5282
|
+
throw new Error(`Failed to close ${failures.length} of ${results.length} positions: ${detail}`);
|
|
5283
|
+
}
|
|
5284
|
+
}
|
|
5061
5285
|
}
|
|
5062
5286
|
}
|
|
5063
5287
|
/**
|
|
@@ -5074,44 +5298,22 @@ class AlpacaTradingAPI {
|
|
|
5074
5298
|
this.log("No positions to close");
|
|
5075
5299
|
return;
|
|
5076
5300
|
}
|
|
5077
|
-
|
|
5078
|
-
|
|
5079
|
-
//
|
|
5080
|
-
|
|
5081
|
-
|
|
5082
|
-
|
|
5083
|
-
|
|
5084
|
-
|
|
5085
|
-
|
|
5086
|
-
|
|
5087
|
-
symbol: position.symbol,
|
|
5088
|
-
type: "warn",
|
|
5089
|
-
});
|
|
5090
|
-
continue;
|
|
5091
|
-
}
|
|
5092
|
-
const qty = Math.abs(parseFloat(position.qty));
|
|
5093
|
-
const side = position.side === "long" ? "sell" : "buy";
|
|
5094
|
-
const positionIntent = side === "sell" ? "sell_to_close" : "buy_to_close";
|
|
5095
|
-
// Get the current price from the quote
|
|
5096
|
-
const currentPrice = side === "sell" ? quote.bp : quote.ap; // Use bid for sells, ask for buys
|
|
5097
|
-
if (!currentPrice) {
|
|
5098
|
-
this.log(`No valid price available for ${position.symbol}, skipping limit order`, {
|
|
5099
|
-
symbol: position.symbol,
|
|
5100
|
-
type: "warn",
|
|
5101
|
-
});
|
|
5102
|
-
continue;
|
|
5103
|
-
}
|
|
5104
|
-
// Apply slippage from config
|
|
5105
|
-
const limitSlippagePercent1 = limitPriceSlippagePercent100 / 100;
|
|
5106
|
-
const limitPrice = side === "sell"
|
|
5107
|
-
? this.roundPriceForAlpaca(currentPrice * (1 - limitSlippagePercent1)) // Sell slightly lower
|
|
5108
|
-
: this.roundPriceForAlpaca(currentPrice * (1 + limitSlippagePercent1)); // Buy slightly higher
|
|
5109
|
-
this.log(`Creating extended hours limit order to close ${position.symbol} position: ${side} ${qty} shares at $${limitPrice.toFixed(2)}`, {
|
|
5110
|
-
symbol: position.symbol,
|
|
5111
|
-
});
|
|
5112
|
-
await this.createLimitOrder(position.symbol, qty, side, limitPrice, positionIntent, true);
|
|
5301
|
+
// Cancelling stale open orders is secondary to the primary failsafe goal of
|
|
5302
|
+
// flattening positions. A cancel failure is logged but must not abort the
|
|
5303
|
+
// flatten, otherwise a single un-cancelable order would leave every position
|
|
5304
|
+
// open. The flatten step below surfaces its own aggregate failure.
|
|
5305
|
+
try {
|
|
5306
|
+
await this.cancelAllOrders();
|
|
5307
|
+
this.log(`Cancelled all open orders`);
|
|
5308
|
+
}
|
|
5309
|
+
catch (error) {
|
|
5310
|
+
this.log(`Proceeding to flatten despite cancelAllOrders failure: ${error instanceof Error ? error.message : String(error)}`, { type: "error" });
|
|
5113
5311
|
}
|
|
5114
|
-
|
|
5312
|
+
// Flatten each position independently with extended-hours limit orders. A
|
|
5313
|
+
// missing quote or broker rejection on one symbol must never silently leave
|
|
5314
|
+
// the remaining positions open; per-position failures are surfaced as an
|
|
5315
|
+
// aggregate error.
|
|
5316
|
+
await this.flattenPositionsWithLimitOrders(positions, true);
|
|
5115
5317
|
}
|
|
5116
5318
|
onTradeUpdate(callback) {
|
|
5117
5319
|
this.tradeUpdateCallback = callback;
|
|
@@ -5190,9 +5392,12 @@ class AlpacaTradingAPI {
|
|
|
5190
5392
|
* @param position_intent Position intent (buy_to_open, buy_to_close, sell_to_open, sell_to_close)
|
|
5191
5393
|
* @param type Order type (market or limit)
|
|
5192
5394
|
* @param limitPrice Limit price (required for limit orders)
|
|
5395
|
+
* @param clientOrderId Optional idempotency key; a deterministic one is
|
|
5396
|
+
* derived from the order parameters when omitted so a client-timeout retry
|
|
5397
|
+
* is de-duplicated broker-side.
|
|
5193
5398
|
* @returns The created order
|
|
5194
5399
|
*/
|
|
5195
|
-
async createOptionOrder(symbol, qty, side, position_intent, type, limitPrice) {
|
|
5400
|
+
async createOptionOrder(symbol, qty, side, position_intent, type, limitPrice, clientOrderId) {
|
|
5196
5401
|
if (!Number.isInteger(qty) || qty <= 0) {
|
|
5197
5402
|
this.log("Quantity must be a positive whole number for option orders", {
|
|
5198
5403
|
type: "error",
|
|
@@ -5217,6 +5422,19 @@ class AlpacaTradingAPI {
|
|
|
5217
5422
|
if (type === "limit" && limitPrice !== undefined) {
|
|
5218
5423
|
orderData.limit_price = this.roundPriceForAlpaca(limitPrice).toString();
|
|
5219
5424
|
}
|
|
5425
|
+
orderData.client_order_id =
|
|
5426
|
+
clientOrderId ??
|
|
5427
|
+
this.deriveClientOrderId([
|
|
5428
|
+
"option",
|
|
5429
|
+
type,
|
|
5430
|
+
symbol,
|
|
5431
|
+
side,
|
|
5432
|
+
position_intent,
|
|
5433
|
+
qty,
|
|
5434
|
+
type === "limit" && limitPrice !== undefined
|
|
5435
|
+
? this.roundPriceForAlpaca(limitPrice)
|
|
5436
|
+
: undefined,
|
|
5437
|
+
]);
|
|
5220
5438
|
return this.makeRequest("/orders", "POST", orderData);
|
|
5221
5439
|
}
|
|
5222
5440
|
/**
|
|
@@ -5225,9 +5443,12 @@ class AlpacaTradingAPI {
|
|
|
5225
5443
|
* @param qty Quantity of the multi-leg order (must be a whole number)
|
|
5226
5444
|
* @param type Order type (market or limit)
|
|
5227
5445
|
* @param limitPrice Limit price (required for limit orders)
|
|
5446
|
+
* @param clientOrderId Optional idempotency key; a deterministic one is
|
|
5447
|
+
* derived from the legs and order parameters when omitted so a
|
|
5448
|
+
* client-timeout retry is de-duplicated broker-side.
|
|
5228
5449
|
* @returns The created multi-leg order
|
|
5229
5450
|
*/
|
|
5230
|
-
async createMultiLegOptionOrder(legs, qty, type, limitPrice) {
|
|
5451
|
+
async createMultiLegOptionOrder(legs, qty, type, limitPrice, clientOrderId) {
|
|
5231
5452
|
if (!Number.isInteger(qty) || qty <= 0) {
|
|
5232
5453
|
this.log("Quantity must be a positive whole number for option orders", {
|
|
5233
5454
|
type: "error",
|
|
@@ -5253,6 +5474,17 @@ class AlpacaTradingAPI {
|
|
|
5253
5474
|
if (type === "limit" && limitPrice !== undefined) {
|
|
5254
5475
|
orderData.limit_price = this.roundPriceForAlpaca(limitPrice).toString();
|
|
5255
5476
|
}
|
|
5477
|
+
orderData.client_order_id =
|
|
5478
|
+
clientOrderId ??
|
|
5479
|
+
this.deriveClientOrderId([
|
|
5480
|
+
"mleg",
|
|
5481
|
+
type,
|
|
5482
|
+
qty,
|
|
5483
|
+
type === "limit" && limitPrice !== undefined
|
|
5484
|
+
? this.roundPriceForAlpaca(limitPrice)
|
|
5485
|
+
: undefined,
|
|
5486
|
+
...legs.map((leg) => `${leg.symbol}:${leg.side}:${leg.ratio_qty}:${leg.position_intent}`),
|
|
5487
|
+
]);
|
|
5256
5488
|
return this.makeRequest("/orders", "POST", orderData);
|
|
5257
5489
|
}
|
|
5258
5490
|
/**
|
|
@@ -5730,9 +5962,26 @@ class AlpacaTradingAPI {
|
|
|
5730
5962
|
extended_hours: extendedHours,
|
|
5731
5963
|
position_intent: side === "buy" ? "buy_to_open" : "sell_to_open",
|
|
5732
5964
|
};
|
|
5733
|
-
|
|
5734
|
-
|
|
5735
|
-
|
|
5965
|
+
orderData.client_order_id =
|
|
5966
|
+
clientOrderId ??
|
|
5967
|
+
this.deriveClientOrderId([
|
|
5968
|
+
"equities",
|
|
5969
|
+
orderClass,
|
|
5970
|
+
type,
|
|
5971
|
+
symbol,
|
|
5972
|
+
side,
|
|
5973
|
+
Math.abs(qty),
|
|
5974
|
+
type === "limit" && limitPrice !== undefined
|
|
5975
|
+
? this.roundPriceForAlpaca(limitPrice)
|
|
5976
|
+
: undefined,
|
|
5977
|
+
extendedHours,
|
|
5978
|
+
useStopLoss && calculatedStopPrice !== undefined
|
|
5979
|
+
? this.roundPriceForAlpaca(calculatedStopPrice)
|
|
5980
|
+
: undefined,
|
|
5981
|
+
useTakeProfit && calculatedTakeProfitPrice !== undefined
|
|
5982
|
+
? this.roundPriceForAlpaca(calculatedTakeProfitPrice)
|
|
5983
|
+
: undefined,
|
|
5984
|
+
]);
|
|
5736
5985
|
// Add limit price for limit orders
|
|
5737
5986
|
if (type === "limit" && limitPrice !== undefined) {
|
|
5738
5987
|
orderData.limit_price = this.roundPriceForAlpaca(limitPrice).toString();
|
|
@@ -8408,6 +8657,13 @@ var atrNs = /*#__PURE__*/Object.freeze({
|
|
|
8408
8657
|
});
|
|
8409
8658
|
|
|
8410
8659
|
const ALPACA_API_BASE = MARKET_DATA_API.CRYPTO;
|
|
8660
|
+
/**
|
|
8661
|
+
* Hard upper bound on the number of paginated news pages fetched in a single
|
|
8662
|
+
* {@link fetchNews} call. Acts as a runaway-loop backstop that is independent of
|
|
8663
|
+
* the caller-supplied `limit`, mirroring the max-page guard the equities
|
|
8664
|
+
* paginator already enforces.
|
|
8665
|
+
*/
|
|
8666
|
+
const MAX_NEWS_PAGES = 100;
|
|
8411
8667
|
/**
|
|
8412
8668
|
* Fetches cryptocurrency bars for the specified parameters.
|
|
8413
8669
|
* This function retrieves historical price data for multiple cryptocurrencies.
|
|
@@ -8507,17 +8763,25 @@ async function fetchNews(params, auth) {
|
|
|
8507
8763
|
include_content: includeContent.toString(),
|
|
8508
8764
|
limit: limit.toString(),
|
|
8509
8765
|
});
|
|
8510
|
-
const
|
|
8511
|
-
|
|
8512
|
-
|
|
8766
|
+
const authHeaders = {
|
|
8767
|
+
"APCA-API-KEY-ID": auth.APIKey,
|
|
8768
|
+
"APCA-API-SECRET-KEY": auth.APISecret,
|
|
8769
|
+
};
|
|
8770
|
+
const newsArticles = [];
|
|
8513
8771
|
let pageToken = null;
|
|
8514
|
-
let
|
|
8515
|
-
while (
|
|
8772
|
+
let pageCount = 0;
|
|
8773
|
+
while (pageCount < MAX_NEWS_PAGES) {
|
|
8774
|
+
// Rebuild the request URL on every iteration so the pagination cursor is
|
|
8775
|
+
// actually applied. Using `set` (not `append`) overwrites the previous
|
|
8776
|
+
// cursor instead of accumulating stale `page_token` values across pages.
|
|
8516
8777
|
if (pageToken) {
|
|
8517
|
-
queryParams.
|
|
8778
|
+
queryParams.set("page_token", pageToken);
|
|
8518
8779
|
}
|
|
8780
|
+
const url = `${ALPACA_API_BASE}/news?${queryParams.toString()}`;
|
|
8781
|
+
logIfDebug(`Fetching news from: ${url}`);
|
|
8519
8782
|
await withRetry(async () => {
|
|
8520
8783
|
const response = await fetch(url, {
|
|
8784
|
+
headers: authHeaders,
|
|
8521
8785
|
signal: createTimeoutSignal(DEFAULT_TIMEOUTS.ALPACA_API),
|
|
8522
8786
|
});
|
|
8523
8787
|
if (!response.ok) {
|
|
@@ -8525,7 +8789,7 @@ async function fetchNews(params, auth) {
|
|
|
8525
8789
|
throw new Error(`Alpaca API error (${response.status}): ${errorText}`);
|
|
8526
8790
|
}
|
|
8527
8791
|
const data = await response.json();
|
|
8528
|
-
|
|
8792
|
+
const pageArticles = (data.news ?? []).map((article) => ({
|
|
8529
8793
|
id: article.id,
|
|
8530
8794
|
author: article.author,
|
|
8531
8795
|
content: article.content,
|
|
@@ -8537,11 +8801,17 @@ async function fetchNews(params, auth) {
|
|
|
8537
8801
|
url: article.url,
|
|
8538
8802
|
symbols: article.symbols,
|
|
8539
8803
|
images: article.images,
|
|
8540
|
-
}))
|
|
8804
|
+
}));
|
|
8805
|
+
newsArticles.push(...pageArticles);
|
|
8541
8806
|
pageToken = data.next_page_token ?? null;
|
|
8542
|
-
|
|
8543
|
-
logIfDebug(`Received ${data.news.length} news articles. More pages: ${hasMorePages}`);
|
|
8807
|
+
logIfDebug(`Received ${pageArticles.length} news articles. Next page token: ${pageToken ? "present" : "none"}`);
|
|
8544
8808
|
}, API_RETRY_CONFIGS.CRYPTO, `Crypto.fetchNews(${symbol})`);
|
|
8809
|
+
pageCount++;
|
|
8810
|
+
// Terminate once the API reports no further pages or once we have
|
|
8811
|
+
// accumulated at least the requested number of articles.
|
|
8812
|
+
if (!pageToken || newsArticles.length >= limit) {
|
|
8813
|
+
break;
|
|
8814
|
+
}
|
|
8545
8815
|
}
|
|
8546
8816
|
// If sort is "asc" and limit is 10, return only the 10 most recent articles
|
|
8547
8817
|
if (sort === "asc" && limit === 10) {
|
|
@@ -9758,33 +10028,138 @@ const formatIndicesBarData = (data) => {
|
|
|
9758
10028
|
};
|
|
9759
10029
|
|
|
9760
10030
|
// price-utils.ts
|
|
10031
|
+
// ---------------------------------------------------------------------------
|
|
10032
|
+
// Transaction-cost (fee) model
|
|
10033
|
+
//
|
|
10034
|
+
// Alpaca's REST order object does not expose the realized per-order fee, so the
|
|
10035
|
+
// transaction cost is reconstructed from the published fee schedules, branching
|
|
10036
|
+
// on the order's ACTUAL asset class (never a hardcoded STOCK). Every rate is a
|
|
10037
|
+
// named constant sourced from Alpaca / SEC / FINRA public schedules (2024-2025)
|
|
10038
|
+
// so it can be audited and updated in one place.
|
|
10039
|
+
// ---------------------------------------------------------------------------
|
|
10040
|
+
/** Basis points in one whole unit (1 = 10,000 bps). */
|
|
10041
|
+
const BPS_PER_UNIT = 10_000;
|
|
10042
|
+
/** Shares represented by one US listed option contract. */
|
|
10043
|
+
const OPTIONS_CONTRACT_MULTIPLIER = 100;
|
|
10044
|
+
/**
|
|
10045
|
+
* SEC Section 31 fee, charged on the principal of SELL orders for equities and
|
|
10046
|
+
* options. FY2024+ rate: USD 8.00 per USD 1,000,000 of principal.
|
|
10047
|
+
*/
|
|
10048
|
+
const SEC_SECTION31_FEE_PER_USD = 8.0 / 1_000_000;
|
|
10049
|
+
/** FINRA Trading Activity Fee (TAF) for equity sells: USD per share sold. */
|
|
10050
|
+
const FINRA_TAF_EQUITY_PER_SHARE = 0.000166;
|
|
10051
|
+
/** FINRA TAF for option sells: USD per contract sold. */
|
|
10052
|
+
const FINRA_TAF_OPTIONS_PER_CONTRACT = 0.00279;
|
|
10053
|
+
/** FINRA TAF is capped per trade regardless of size. */
|
|
10054
|
+
const FINRA_TAF_MAX_PER_TRADE = 8.3;
|
|
10055
|
+
/** OCC clearing fee per option contract, capped per trade. */
|
|
10056
|
+
const OCC_CLEARING_FEE_PER_CONTRACT = 0.02;
|
|
10057
|
+
const OCC_CLEARING_FEE_MAX_PER_TRADE = 55.0;
|
|
10058
|
+
/**
|
|
10059
|
+
* Options Regulatory Fee (ORF) pass-through, charged on both sides, USD per
|
|
10060
|
+
* contract. Published, exchange-set pass-through rate.
|
|
10061
|
+
*/
|
|
10062
|
+
const OPTIONS_REGULATORY_FEE_PER_CONTRACT = 0.02685;
|
|
10063
|
+
/**
|
|
10064
|
+
* Alpaca crypto TAKER fee schedule as `[minTrailing30dVolumeUsd, takerBps]`,
|
|
10065
|
+
* ordered ascending by volume threshold. Market orders are takers; absent a
|
|
10066
|
+
* known trailing-30-day volume we conservatively select the tier-1 (highest)
|
|
10067
|
+
* taker rate. Source: Alpaca Crypto fee schedule.
|
|
10068
|
+
*/
|
|
10069
|
+
const ALPACA_CRYPTO_TAKER_FEE_TIERS_BPS = [
|
|
10070
|
+
[0, 25],
|
|
10071
|
+
[100_000, 22],
|
|
10072
|
+
[500_000, 20],
|
|
10073
|
+
[1_000_000, 18],
|
|
10074
|
+
[10_000_000, 15],
|
|
10075
|
+
[25_000_000, 13],
|
|
10076
|
+
[50_000_000, 12],
|
|
10077
|
+
[100_000_000, 10],
|
|
10078
|
+
];
|
|
10079
|
+
/**
|
|
10080
|
+
* Resolve the applicable Alpaca crypto taker fee (in bps) for a trailing
|
|
10081
|
+
* 30-day USD volume. Defaults to the tier-1 rate when the volume is unknown.
|
|
10082
|
+
* @param trailing30dVolumeUsd - Trailing 30-day traded notional in USD.
|
|
10083
|
+
* @returns The taker fee in basis points.
|
|
10084
|
+
*/
|
|
10085
|
+
function resolveCryptoTakerBps(trailing30dVolumeUsd) {
|
|
10086
|
+
let bps = ALPACA_CRYPTO_TAKER_FEE_TIERS_BPS[0][1];
|
|
10087
|
+
for (const [threshold, tierBps] of ALPACA_CRYPTO_TAKER_FEE_TIERS_BPS) {
|
|
10088
|
+
if (trailing30dVolumeUsd >= threshold) {
|
|
10089
|
+
bps = tierBps;
|
|
10090
|
+
}
|
|
10091
|
+
else {
|
|
10092
|
+
break;
|
|
10093
|
+
}
|
|
10094
|
+
}
|
|
10095
|
+
return bps;
|
|
10096
|
+
}
|
|
10097
|
+
/**
|
|
10098
|
+
* Computes the realized transaction cost (fees + regulatory charges) for the
|
|
10099
|
+
* Alpaca order backing a single {@link types.Action}, branching on the order's
|
|
10100
|
+
* actual asset class. Returns 0 only when there is genuinely no order to price
|
|
10101
|
+
* (no linked order id, order not found, or nothing filled) — never as a
|
|
10102
|
+
* fabricated success.
|
|
10103
|
+
* @param action - The action whose linked Alpaca order should be priced.
|
|
10104
|
+
* @param trade - The parent trade (supplies the Alpaca account id).
|
|
10105
|
+
* @param alpacaAccount - The Alpaca account supplying broker credentials.
|
|
10106
|
+
* @returns The total fee in account currency (USD).
|
|
10107
|
+
*/
|
|
9761
10108
|
const calculateFees = async (action, trade, alpacaAccount) => {
|
|
9762
|
-
let fee = 0;
|
|
9763
10109
|
const alpacaOrderId = action.alpacaOrderId;
|
|
9764
10110
|
if (!alpacaOrderId)
|
|
9765
|
-
return
|
|
10111
|
+
return 0;
|
|
9766
10112
|
const order = await getOrder$1({
|
|
9767
10113
|
adapticAccountId: trade.alpacaAccountId,
|
|
9768
10114
|
alpacaApiKey: alpacaAccount.APIKey,
|
|
9769
10115
|
alpacaApiSecret: alpacaAccount.APISecret,
|
|
9770
10116
|
}, alpacaOrderId);
|
|
9771
10117
|
if (!order)
|
|
9772
|
-
return
|
|
9773
|
-
const
|
|
9774
|
-
Number(order.
|
|
9775
|
-
order.notional || 0;
|
|
9776
|
-
Number(order.filled_avg_price || order.limit_price || order.stop_price) ||
|
|
10118
|
+
return 0;
|
|
10119
|
+
const filledQty = Number(order.filled_qty) || 0;
|
|
10120
|
+
const filledPrice = Number(order.filled_avg_price ?? order.limit_price ?? order.stop_price) ||
|
|
9777
10121
|
0;
|
|
9778
|
-
|
|
9779
|
-
|
|
9780
|
-
|
|
9781
|
-
|
|
9782
|
-
|
|
9783
|
-
|
|
9784
|
-
|
|
9785
|
-
|
|
10122
|
+
// Realized notional prefers the actual fill (qty * avg price); it falls back
|
|
10123
|
+
// to the order's notional field for dollar-notional (fractional) orders.
|
|
10124
|
+
const notional = filledQty > 0 && filledPrice > 0
|
|
10125
|
+
? filledQty * filledPrice
|
|
10126
|
+
: Number(order.notional ?? 0) || 0;
|
|
10127
|
+
if (notional <= 0)
|
|
10128
|
+
return 0;
|
|
10129
|
+
const isSell = order.side === "sell";
|
|
10130
|
+
switch (order.asset_class) {
|
|
10131
|
+
case "crypto": {
|
|
10132
|
+
// Crypto fees are bps of notional. Without a known 30-day volume we use
|
|
10133
|
+
// the conservative tier-1 taker rate.
|
|
10134
|
+
const takerBps = resolveCryptoTakerBps(0);
|
|
10135
|
+
return (notional * takerBps) / BPS_PER_UNIT;
|
|
10136
|
+
}
|
|
10137
|
+
case "us_option": {
|
|
10138
|
+
const contracts = filledQty > 0 ? filledQty : Number(order.qty) || 0;
|
|
10139
|
+
const occFee = Math.min(contracts * OCC_CLEARING_FEE_PER_CONTRACT, OCC_CLEARING_FEE_MAX_PER_TRADE);
|
|
10140
|
+
const orfFee = contracts * OPTIONS_REGULATORY_FEE_PER_CONTRACT;
|
|
10141
|
+
let fee = occFee + orfFee;
|
|
10142
|
+
if (isSell) {
|
|
10143
|
+
// Option premium is quoted per share; SEC fee applies to the full
|
|
10144
|
+
// principal (premium * contract multiplier).
|
|
10145
|
+
const optionPrincipal = notional * OPTIONS_CONTRACT_MULTIPLIER;
|
|
10146
|
+
const secFee = optionPrincipal * SEC_SECTION31_FEE_PER_USD;
|
|
10147
|
+
const taf = Math.min(contracts * FINRA_TAF_OPTIONS_PER_CONTRACT, FINRA_TAF_MAX_PER_TRADE);
|
|
10148
|
+
fee += secFee + taf;
|
|
10149
|
+
}
|
|
10150
|
+
return fee;
|
|
10151
|
+
}
|
|
10152
|
+
case "us_equity":
|
|
10153
|
+
default: {
|
|
10154
|
+
// Alpaca charges USD 0 commission on US equities; only sell-side
|
|
10155
|
+
// regulatory charges (SEC Section 31 + FINRA TAF) apply.
|
|
10156
|
+
if (!isSell)
|
|
10157
|
+
return 0;
|
|
10158
|
+
const secFee = notional * SEC_SECTION31_FEE_PER_USD;
|
|
10159
|
+
const taf = Math.min(filledQty * FINRA_TAF_EQUITY_PER_SHARE, FINRA_TAF_MAX_PER_TRADE);
|
|
10160
|
+
return secFee + taf;
|
|
10161
|
+
}
|
|
9786
10162
|
}
|
|
9787
|
-
return fee;
|
|
9788
10163
|
};
|
|
9789
10164
|
const computeTotalFees = async (trade) => {
|
|
9790
10165
|
let totalFees = 0;
|
|
@@ -10653,16 +11028,76 @@ async function calculateExpenseRatio({ accountId, client, alpacaAccount, }) {
|
|
|
10653
11028
|
return "N/A";
|
|
10654
11029
|
}
|
|
10655
11030
|
const equity = parseFloat(accountDetails.equity);
|
|
10656
|
-
// Fetch
|
|
10657
|
-
|
|
10658
|
-
//
|
|
11031
|
+
// Fetch the account's real trailing fee expenses from Alpaca account
|
|
11032
|
+
// activities. A genuine data-source failure yields "N/A" (unknown) rather
|
|
11033
|
+
// than a fabricated 0.00%.
|
|
11034
|
+
const auth = {
|
|
11035
|
+
adapticAccountId: alpacaAccountId,
|
|
11036
|
+
alpacaApiKey: alpacaAccount?.APIKey,
|
|
11037
|
+
alpacaApiSecret: alpacaAccount?.APISecret,
|
|
11038
|
+
};
|
|
11039
|
+
let expenses;
|
|
11040
|
+
try {
|
|
11041
|
+
expenses = await fetchTrailingFeeExpenses(auth);
|
|
11042
|
+
}
|
|
11043
|
+
catch (error) {
|
|
11044
|
+
getLogger().warn("Failed to fetch Alpaca account fee activities for expense ratio.", { error });
|
|
11045
|
+
return "N/A";
|
|
11046
|
+
}
|
|
11047
|
+
// Calculate expense ratio (trailing fees as a percentage of current equity).
|
|
10659
11048
|
const expenseRatio = (expenses / equity) * 100;
|
|
10660
11049
|
return `${expenseRatio.toFixed(2)}%`;
|
|
10661
11050
|
}
|
|
10662
|
-
|
|
10663
|
-
|
|
10664
|
-
|
|
10665
|
-
|
|
11051
|
+
/** Trailing window over which account fees are aggregated for the expense ratio. */
|
|
11052
|
+
const EXPENSE_TRAILING_WINDOW_DAYS = 365;
|
|
11053
|
+
/** Milliseconds in one day. */
|
|
11054
|
+
const MS_PER_DAY = 24 * 60 * 60 * 1000;
|
|
11055
|
+
/** Alpaca account-activity types that represent fees/regulatory charges. */
|
|
11056
|
+
const FEE_ACTIVITY_TYPES = "FEE,REG,CFEE";
|
|
11057
|
+
/** Page size for the paginated Alpaca account-activities endpoint. */
|
|
11058
|
+
const ACTIVITIES_PAGE_SIZE = 100;
|
|
11059
|
+
/** Hard cap on activity pages to bound pagination on unexpected responses. */
|
|
11060
|
+
const ACTIVITIES_MAX_PAGES = 1000;
|
|
11061
|
+
/**
|
|
11062
|
+
* Aggregates the account's fee/regulatory charges over the trailing window from
|
|
11063
|
+
* the Alpaca account-activities endpoint, following id-based pagination.
|
|
11064
|
+
* @param auth - Alpaca authentication (account id and/or direct API keys).
|
|
11065
|
+
* @returns Total fees in account currency (USD) as a positive number.
|
|
11066
|
+
*/
|
|
11067
|
+
async function fetchTrailingFeeExpenses(auth) {
|
|
11068
|
+
const after = new Date(Date.now() - EXPENSE_TRAILING_WINDOW_DAYS * MS_PER_DAY).toISOString();
|
|
11069
|
+
let total = 0;
|
|
11070
|
+
let pageToken;
|
|
11071
|
+
for (let page = 0; page < ACTIVITIES_MAX_PAGES; page++) {
|
|
11072
|
+
const queryParams = new URLSearchParams({
|
|
11073
|
+
activity_types: FEE_ACTIVITY_TYPES,
|
|
11074
|
+
after,
|
|
11075
|
+
page_size: String(ACTIVITIES_PAGE_SIZE),
|
|
11076
|
+
});
|
|
11077
|
+
if (pageToken) {
|
|
11078
|
+
queryParams.append("page_token", pageToken);
|
|
11079
|
+
}
|
|
11080
|
+
const activities = await makeRequest(auth, {
|
|
11081
|
+
endpoint: "/account/activities",
|
|
11082
|
+
method: "GET",
|
|
11083
|
+
queryString: `?${queryParams.toString()}`,
|
|
11084
|
+
});
|
|
11085
|
+
if (!Array.isArray(activities) || activities.length === 0) {
|
|
11086
|
+
break;
|
|
11087
|
+
}
|
|
11088
|
+
for (const activity of activities) {
|
|
11089
|
+
const amount = parseFloat(activity.net_amount ?? "");
|
|
11090
|
+
if (Number.isFinite(amount)) {
|
|
11091
|
+
// Fee entries are debits (negative net_amount); accumulate magnitude.
|
|
11092
|
+
total += Math.abs(amount);
|
|
11093
|
+
}
|
|
11094
|
+
}
|
|
11095
|
+
if (activities.length < ACTIVITIES_PAGE_SIZE) {
|
|
11096
|
+
break;
|
|
11097
|
+
}
|
|
11098
|
+
pageToken = activities[activities.length - 1].id;
|
|
11099
|
+
}
|
|
11100
|
+
return total;
|
|
10666
11101
|
}
|
|
10667
11102
|
/**
|
|
10668
11103
|
* Calculates the liquidity ratio for a given Alpaca account.
|
|
@@ -11322,6 +11757,58 @@ async function calculateInformationRatio(portfolioHistory, benchmarkBars) {
|
|
|
11322
11757
|
}
|
|
11323
11758
|
return informationRatio.toFixed(4);
|
|
11324
11759
|
}
|
|
11760
|
+
/**
|
|
11761
|
+
* Maps a portfolio-history timeframe token to the Alpaca market-data
|
|
11762
|
+
* {@link TimeFrame} accepted by the historical-bars endpoint. Benchmark
|
|
11763
|
+
* comparison is daily by default when no timeframe is supplied.
|
|
11764
|
+
* @param timeframe - The portfolio-history timeframe token.
|
|
11765
|
+
* @returns The equivalent Alpaca market-data timeframe.
|
|
11766
|
+
*/
|
|
11767
|
+
function toAlpacaTimeFrame(timeframe) {
|
|
11768
|
+
switch (timeframe) {
|
|
11769
|
+
case "1Min":
|
|
11770
|
+
return "1Min";
|
|
11771
|
+
case "5Min":
|
|
11772
|
+
return "5Min";
|
|
11773
|
+
case "15Min":
|
|
11774
|
+
return "15Min";
|
|
11775
|
+
case "1H":
|
|
11776
|
+
return "1Hour";
|
|
11777
|
+
case "1D":
|
|
11778
|
+
return "1Day";
|
|
11779
|
+
default:
|
|
11780
|
+
return "1Day";
|
|
11781
|
+
}
|
|
11782
|
+
}
|
|
11783
|
+
/** Milliseconds per second, for RFC-3339 → Unix-second conversion. */
|
|
11784
|
+
const MS_PER_SECOND = 1000;
|
|
11785
|
+
/**
|
|
11786
|
+
* Fetches benchmark OHLCV bars from the wrapped Alpaca market-data vendor and
|
|
11787
|
+
* maps them into {@link BenchmarkBar}s (Unix-second timestamp + close price)
|
|
11788
|
+
* expected by the alpha/beta/information-ratio calculators.
|
|
11789
|
+
* @param request - Benchmark symbol, RFC-3339 start/end, and timeframe token.
|
|
11790
|
+
* @returns The benchmark bars, sorted ascending by time; empty if none.
|
|
11791
|
+
*/
|
|
11792
|
+
async function fetchBenchmarkBars(request) {
|
|
11793
|
+
const { symbol, start, end, timeframe } = request;
|
|
11794
|
+
const response = await marketDataAPI.getHistoricalBars({
|
|
11795
|
+
symbols: [symbol],
|
|
11796
|
+
timeframe: toAlpacaTimeFrame(timeframe),
|
|
11797
|
+
start,
|
|
11798
|
+
end,
|
|
11799
|
+
sort: "asc",
|
|
11800
|
+
});
|
|
11801
|
+
const bars = response.bars[symbol];
|
|
11802
|
+
if (!Array.isArray(bars) || bars.length === 0) {
|
|
11803
|
+
return [];
|
|
11804
|
+
}
|
|
11805
|
+
return bars
|
|
11806
|
+
.map((bar) => ({
|
|
11807
|
+
t: Math.floor(new Date(bar.t).getTime() / MS_PER_SECOND),
|
|
11808
|
+
c: bar.c,
|
|
11809
|
+
}))
|
|
11810
|
+
.filter((bar) => Number.isFinite(bar.t) && Number.isFinite(bar.c));
|
|
11811
|
+
}
|
|
11325
11812
|
/**
|
|
11326
11813
|
* Fetches performance metrics for a given Alpaca account.
|
|
11327
11814
|
* @param params - The parameters for fetching performance metrics.
|
|
@@ -11386,7 +11873,10 @@ async function fetchPerformanceMetrics({ params, client, accountId, alpacaAccoun
|
|
|
11386
11873
|
getLogger().error("[fetchPerformanceMetrics] Error fetching portfolio history:", error);
|
|
11387
11874
|
throw new Error("Failed to retrieve portfolio history data");
|
|
11388
11875
|
}
|
|
11389
|
-
// Fetch benchmark data
|
|
11876
|
+
// Fetch benchmark data directly from the wrapped Alpaca market-data vendor.
|
|
11877
|
+
// (Previously this hit a relative "/api/market-data/historical-prices"
|
|
11878
|
+
// Next.js route that only resolves in a browser; in a Node/engine runtime
|
|
11879
|
+
// the relative fetch always threw, silently zeroing out alpha/beta/IR.)
|
|
11390
11880
|
const benchmarkSymbol = "SPY";
|
|
11391
11881
|
let benchmarkBars = [];
|
|
11392
11882
|
try {
|
|
@@ -11397,24 +11887,16 @@ async function fetchPerformanceMetrics({ params, client, accountId, alpacaAccoun
|
|
|
11397
11887
|
: params?.period
|
|
11398
11888
|
? params?.period
|
|
11399
11889
|
: "1Y",
|
|
11400
|
-
outputFormat: "
|
|
11890
|
+
outputFormat: "iso",
|
|
11401
11891
|
intraday_reporting: params?.intraday_reporting,
|
|
11402
11892
|
});
|
|
11403
|
-
|
|
11404
|
-
|
|
11405
|
-
|
|
11406
|
-
|
|
11407
|
-
|
|
11408
|
-
signal: createTimeoutSignal(DEFAULT_TIMEOUTS.GENERAL),
|
|
11893
|
+
benchmarkBars = await fetchBenchmarkBars({
|
|
11894
|
+
symbol: benchmarkSymbol,
|
|
11895
|
+
start: String(start),
|
|
11896
|
+
end: String(end),
|
|
11897
|
+
timeframe: params.timeframe,
|
|
11409
11898
|
});
|
|
11410
|
-
if (
|
|
11411
|
-
const errorText = await response.text();
|
|
11412
|
-
throw new Error(`Failed to fetch benchmark data: ${response.statusText} - ${errorText}`);
|
|
11413
|
-
}
|
|
11414
|
-
benchmarkBars = await response.json();
|
|
11415
|
-
if (!benchmarkBars ||
|
|
11416
|
-
!Array.isArray(benchmarkBars) ||
|
|
11417
|
-
benchmarkBars.length === 0) {
|
|
11899
|
+
if (benchmarkBars.length === 0) {
|
|
11418
11900
|
throw new Error("Received empty or invalid benchmark data");
|
|
11419
11901
|
}
|
|
11420
11902
|
}
|