1use brokapi_bcs_protocol::http::models::{
23 CreateOrderRequest, OrderExtras, OrderType as BcsOrderType,
24};
25use brokapi_bcs_protocol::models::enums::OrderSide as BcsOrderSide;
26use brokapi_core::{Error, PlaceStopOrderRequest, Price, Side, StopExpiry, StopOrderKind};
27
28use crate::util::{class_code, ticker};
29
30fn refused(code: &str, message: impl Into<String>) -> Error {
31 Error::Rejected {
32 code: code.into(),
33 message: message.into(),
34 }
35}
36
37pub fn build(req: &PlaceStopOrderRequest) -> Result<CreateOrderRequest, Error> {
40 match req.expiry {
41 StopExpiry::GoodTillCancel => {}
42 StopExpiry::EndOfDay | StopExpiry::GoodTillDate(_) => {
43 return Err(refused(
44 "unsupported_expiry",
45 "BCS documents no lifetime for conditional orders: only GoodTillCancel (no time-in-force is sent)",
46 ));
47 }
48 }
49 let qty = match req.qty.to_whole() {
50 Some(n) if n >= 1 => n,
51 _ => {
52 return Err(refused(
53 "invalid_qty",
54 format!(
55 "stop order quantity must be a whole number >= 1, got {}",
56 req.qty
57 ),
58 ));
59 }
60 };
61 let stop = req.stop_price;
62 if !stop.is_positive() {
63 return Err(refused(
64 "invalid_price",
65 format!("stop price must be > 0, got {stop}"),
66 ));
67 }
68 let limit = match req.limit_price {
69 Some(p) if !p.is_positive() => {
70 return Err(refused(
71 "invalid_price",
72 format!("limit price must be > 0, got {p}"),
73 ));
74 }
75 other => other,
76 };
77 if req.kind == StopOrderKind::StopLimit && limit.is_none() {
78 return Err(refused(
79 "missing_limit_price",
80 "a stop-limit order needs a limit price",
81 ));
82 }
83
84 let execution = if limit.is_some() { 2 } else { 1 };
86 let mut extras = OrderExtras::default();
87 match req.kind {
88 StopOrderKind::StopLoss | StopOrderKind::StopLimit => {
89 extras.stop_price = Some(stop.decimal());
90 extras.execution_stop_loss_type = Some(execution);
91 }
92 StopOrderKind::TakeProfit => {
93 extras.take_price = Some(stop.decimal());
94 extras.execution_take_profit_type = Some(execution);
95 }
96 }
97 let side = match req.side {
98 Side::Buy => BcsOrderSide::Buy,
99 Side::Sell => BcsOrderSide::Sell,
100 };
101 Ok(CreateOrderRequest {
102 client_order_id: req.cid,
103 side,
104 order_type: BcsOrderType::Conditional,
105 order_quantity: qty,
106 ticker: ticker(&req.instrument)?,
107 class_code: class_code(&req.instrument)?,
108 price: limit.map(Price::decimal),
109 extras,
110 })
111}
112
113#[cfg(test)]
114mod tests {
115 use super::*;
116 use brokapi_core::{
117 ClientOrderId, InstrumentId, InstrumentKey, NativeId, Price, Qty, UnixNanos,
118 };
119 use brokapi_core::{Decimal, dec};
120
121 fn sber() -> InstrumentId {
122 InstrumentId::new(
123 InstrumentKey::new("SBER", "TQBR"),
124 NativeId::TickerClass {
125 ticker: "SBER".into(),
126 class_code: "TQBR".into(),
127 },
128 )
129 }
130
131 fn req(
132 kind: StopOrderKind,
133 side: Side,
134 limit: Option<Decimal>,
135 expiry: StopExpiry,
136 ) -> PlaceStopOrderRequest {
137 PlaceStopOrderRequest {
138 cid: ClientOrderId::from_bytes([5; 16]),
139 instrument: sber(),
140 side,
141 kind,
142 qty: Qty::from(3),
143 stop_price: Price::from(250),
144 limit_price: limit.map(Price::new),
145 expiry,
146 }
147 }
148
149 fn json(r: &CreateOrderRequest) -> serde_json::Value {
150 serde_json::to_value(r).unwrap()
151 }
152
153 #[test]
154 fn market_stop_loss_is_a_conditional_order_with_stop_price_only() {
155 let w = json(
156 &build(&req(
157 StopOrderKind::StopLoss,
158 Side::Sell,
159 None,
160 StopExpiry::GoodTillCancel,
161 ))
162 .unwrap(),
163 );
164 assert_eq!(w["orderType"], "5");
165 assert_eq!(w["side"], "2");
166 assert_eq!(w["stopPrice"], 250.0);
167 assert_eq!(w["executionStopLossType"], 1);
168 assert!(
169 w.get("price").is_none(),
170 "market execution sends no price: {w}"
171 );
172 assert!(w.get("takePrice").is_none());
173 assert!(
174 w.get("timeInForce").is_none() && w.get("expiryDate").is_none(),
175 "no lifetime is sent: {w}"
176 );
177 assert_eq!(w["orderQuantity"], 3);
178 }
179
180 #[test]
181 fn stop_limit_and_limit_stop_loss_send_the_limit_price() {
182 for kind in [StopOrderKind::StopLimit, StopOrderKind::StopLoss] {
183 let w = json(
184 &build(&req(
185 kind,
186 Side::Sell,
187 Some(dec!(249.0)),
188 StopExpiry::GoodTillCancel,
189 ))
190 .unwrap(),
191 );
192 assert_eq!(w["executionStopLossType"], 2);
193 assert_eq!(w["price"], 249.0);
194 assert_eq!(w["stopPrice"], 250.0);
195 }
196 }
197
198 #[test]
199 fn take_profit_uses_take_price_not_stop_price() {
200 let w = json(
201 &build(&req(
202 StopOrderKind::TakeProfit,
203 Side::Sell,
204 None,
205 StopExpiry::GoodTillCancel,
206 ))
207 .unwrap(),
208 );
209 assert_eq!(w["takePrice"], 250.0);
210 assert_eq!(w["executionTakeProfitType"], 1);
211 assert!(
212 w.get("stopPrice").is_none() && w.get("executionStopLossType").is_none(),
213 "one leg only: {w}"
214 );
215 let w = json(
216 &build(&req(
217 StopOrderKind::TakeProfit,
218 Side::Buy,
219 Some(dec!(240.0)),
220 StopExpiry::GoodTillCancel,
221 ))
222 .unwrap(),
223 );
224 assert_eq!(w["executionTakeProfitType"], 2);
225 assert_eq!(w["price"], 240.0);
226 }
227
228 #[test]
229 fn refused_before_anything_is_sent() {
230 let t = UnixNanos::from_nanos(1);
231 for (r, code) in [
232 (
233 req(
234 StopOrderKind::StopLimit,
235 Side::Sell,
236 None,
237 StopExpiry::GoodTillCancel,
238 ),
239 "missing_limit_price",
240 ),
241 (
242 req(
243 StopOrderKind::StopLoss,
244 Side::Sell,
245 None,
246 StopExpiry::EndOfDay,
247 ),
248 "unsupported_expiry",
249 ),
250 (
251 req(
252 StopOrderKind::StopLoss,
253 Side::Sell,
254 None,
255 StopExpiry::GoodTillDate(t),
256 ),
257 "unsupported_expiry",
258 ),
259 (
260 req(
261 StopOrderKind::StopLoss,
262 Side::Sell,
263 Some(dec!(-1.0)),
264 StopExpiry::GoodTillCancel,
265 ),
266 "invalid_price",
267 ),
268 ] {
269 match build(&r) {
270 Err(Error::Rejected { code: got, .. }) => assert_eq!(got, code),
271 other => panic!("{code}: {other:?}"),
272 }
273 }
274 let mut frac = req(
275 StopOrderKind::StopLoss,
276 Side::Sell,
277 None,
278 StopExpiry::GoodTillCancel,
279 );
280 frac.qty = Qty::new(dec!(1.5));
281 assert!(matches!(build(&frac), Err(Error::Rejected { code, .. }) if code == "invalid_qty"));
282 let mut bad_stop = req(
283 StopOrderKind::StopLoss,
284 Side::Sell,
285 None,
286 StopExpiry::GoodTillCancel,
287 );
288 bad_stop.stop_price = Price::from(0);
289 assert!(
290 matches!(build(&bad_stop), Err(Error::Rejected { code, .. }) if code == "invalid_price")
291 );
292 }
293}