// SPDX-License-Identifier: MIT
// Copyright (c) 2026 Slick Quant
// https://github.com/SlickQuant/alpaca-cpp
//
// Offline tests for order request serialisation. Alpaca requires every numeric field as a
// decimal string and rejects scientific notation, so these assert the exact wire payload.
#include
#include
#include
#include
#include
namespace alpaca::tests {
TEST(OrderRequest, MarketOrder) {
const auto j = order_request::market("AAPL", 10, order_side::buy).to_json();
EXPECT_EQ(j["symbol"], "AAPL");
EXPECT_EQ(j["side"], "buy");
EXPECT_EQ(j["type"], "market");
EXPECT_EQ(j["time_in_force"], "day");
EXPECT_EQ(j["qty"], "10");
EXPECT_FALSE(j.contains("limit_price"));
EXPECT_FALSE(j.contains("notional"));
}
TEST(OrderRequest, NotionalMarketOrder) {
const auto j = order_request::market_notional("AAPL", 500.25, order_side::buy).to_json();
EXPECT_EQ(j["notional"], "500.25");
EXPECT_FALSE(j.contains("qty"));
EXPECT_EQ(j["time_in_force"], "day");
}
TEST(OrderRequest, LimitOrderWithGtc) {
const auto j = order_request::limit("MSFT", 5, order_side::sell, 412.5,
time_in_force::gtc).to_json();
EXPECT_EQ(j["type"], "limit");
EXPECT_EQ(j["side"], "sell");
EXPECT_EQ(j["limit_price"], "412.5");
EXPECT_EQ(j["time_in_force"], "gtc");
}
TEST(OrderRequest, StopAndStopLimit) {
const auto stop = order_request::stop("AAPL", 1, order_side::sell, 90).to_json();
EXPECT_EQ(stop["type"], "stop");
EXPECT_EQ(stop["stop_price"], "90");
EXPECT_FALSE(stop.contains("limit_price"));
const auto stop_limit = order_request::stop_limit("AAPL", 1, order_side::sell, 90, 89.5).to_json();
EXPECT_EQ(stop_limit["type"], "stop_limit");
EXPECT_EQ(stop_limit["stop_price"], "90");
EXPECT_EQ(stop_limit["limit_price"], "89.5");
}
TEST(OrderRequest, TrailingStopByPriceAndPercent) {
const auto by_price = order_request::trailing_stop_price("AAPL", 1, order_side::sell, 2.5).to_json();
EXPECT_EQ(by_price["type"], "trailing_stop");
EXPECT_EQ(by_price["trail_price"], "2.5");
EXPECT_FALSE(by_price.contains("trail_percent"));
const auto by_pct = order_request::trailing_stop_percent("AAPL", 1, order_side::sell, 1.5).to_json();
EXPECT_EQ(by_pct["trail_percent"], "1.5");
EXPECT_FALSE(by_pct.contains("trail_price"));
}
TEST(OrderRequest, FractionalQuantitiesKeepTheirPrecision) {
// Fractional share quantities must survive as decimals, not become 0 or 1e-09.
const auto j = order_request::market("AAPL", 0.001, order_side::buy).to_json();
EXPECT_EQ(j["qty"], "0.001");
const auto tiny = order_request::market("BTC/USD", 0.000000001, order_side::buy).to_json();
EXPECT_EQ(tiny["qty"], "0.000000001");
}
TEST(OrderRequest, BracketOrderCarriesBothChildLegs) {
const auto j = order_request::bracket(
order_request::limit("AAPL", 10, order_side::buy, 100, time_in_force::gtc),
110.0, // take profit
90.0 // stop loss
).to_json();
EXPECT_EQ(j["order_class"], "bracket");
EXPECT_EQ(j["limit_price"], "100");
ASSERT_TRUE(j.contains("take_profit"));
EXPECT_EQ(j["take_profit"]["limit_price"], "110");
ASSERT_TRUE(j.contains("stop_loss"));
EXPECT_EQ(j["stop_loss"]["stop_price"], "90");
EXPECT_FALSE(j["stop_loss"].contains("limit_price"));
}
TEST(OrderRequest, BracketWithStopLimitChild) {
const auto j = order_request::bracket(
order_request::limit("AAPL", 10, order_side::buy, 100), 110.0, 90.0, 89.0).to_json();
EXPECT_EQ(j["stop_loss"]["stop_price"], "90");
EXPECT_EQ(j["stop_loss"]["limit_price"], "89");
}
TEST(OrderRequest, MultiLegOmitsTopLevelSymbolAndSide) {
// mleg orders carry symbol/side per leg; sending a top-level symbol makes Alpaca reject them.
const auto j = order_request::multi_leg({
{"AAPL240628C00200000", 1, order_side::buy, position_intent::buy_to_open},
{"AAPL240628C00210000", 1, order_side::sell, position_intent::sell_to_open},
}, 1).to_json();
EXPECT_EQ(j["order_class"], "mleg");
EXPECT_FALSE(j.contains("symbol"));
EXPECT_FALSE(j.contains("side"));
EXPECT_EQ(j["qty"], "1");
ASSERT_TRUE(j.contains("legs"));
ASSERT_EQ(j["legs"].size(), 2u);
EXPECT_EQ(j["legs"][0]["symbol"], "AAPL240628C00200000");
EXPECT_EQ(j["legs"][0]["ratio_qty"], "1");
EXPECT_EQ(j["legs"][0]["side"], "buy");
EXPECT_EQ(j["legs"][0]["position_intent"], "buy_to_open");
EXPECT_EQ(j["legs"][1]["side"], "sell");
}
TEST(OrderRequest, OptionalFlagsOnlyAppearWhenSet) {
order_request r = order_request::limit("AAPL", 1, order_side::buy, 100);
EXPECT_FALSE(r.to_json().contains("extended_hours"));
EXPECT_FALSE(r.to_json().contains("client_order_id"));
EXPECT_FALSE(r.to_json().contains("position_intent"));
r.extended_hours = true;
r.client_order_id = "my-id-123";
r.position_intent = position_intent::buy_to_open;
const auto j = r.to_json();
EXPECT_EQ(j["extended_hours"], true);
EXPECT_EQ(j["client_order_id"], "my-id-123");
EXPECT_EQ(j["position_intent"], "buy_to_open");
}
TEST(ReplaceOrderRequest, SerialisesOnlySetFields) {
replace_order_request r;
EXPECT_TRUE(r.to_json().empty());
r.qty = 5;
r.limit_price = 101.25;
const auto j = r.to_json();
EXPECT_EQ(j.size(), 2u);
EXPECT_EQ(j["qty"], "5");
EXPECT_EQ(j["limit_price"], "101.25");
EXPECT_FALSE(j.contains("stop_price"));
}
TEST(OrderQuery, BuildsFilters) {
order_query q;
q.status = order_status_filter::open;
q.limit = 100;
q.direction = sort_direction::desc;
q.nested = true;
q.symbols = {"AAPL", "MSFT"};
EXPECT_EQ(q.build().str(),
"?status=open&limit=100&direction=desc&nested=true&symbols=AAPL%2CMSFT");
}
TEST(OrderQuery, DefaultQueryIsEmpty) {
EXPECT_TRUE(order_query{}.build().empty());
}
TEST(ClosePositionRequest, BuildsQuantitySelector) {
close_position_request r;
EXPECT_TRUE(r.build().empty());
r.qty = 5;
EXPECT_EQ(r.build().str(), "?qty=5");
close_position_request pct;
pct.percentage = 50;
EXPECT_EQ(pct.build().str(), "?percentage=50");
}
TEST(WatchlistRequest, Serialises) {
const watchlist_request r{"My List", {"AAPL", "MSFT"}};
const auto j = r.to_json();
EXPECT_EQ(j["name"], "My List");
ASSERT_EQ(j["symbols"].size(), 2u);
EXPECT_EQ(j["symbols"][0], "AAPL");
}
TEST(LocateRequest, Serialises) {
locate_request r;
r.symbol = "GME";
r.requested_qty = 100;
r.limit_price = 0.25;
const auto j = r.to_json();
EXPECT_EQ(j["symbol"], "GME");
EXPECT_EQ(j["requested_qty"], 100);
EXPECT_EQ(j["limit_price"], "0.25");
EXPECT_FALSE(j.contains("all_or_none"));
}
} // namespace alpaca::tests