Skip to main content

trader_bot/domain/
position.rs

1//! Position type representing a holding in a portfolio.
2
3use rust_decimal::Decimal;
4use serde::{Deserialize, Serialize};
5
6use super::Money;
7
8/// Represents a position (holding) in the portfolio.
9#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
10pub struct Position {
11    /// The trading symbol/ticker (e.g., "AAPL", "BTC-USD").
12    symbol: String,
13    /// The exchange/market identifier (e.g., "MOEX", "NASDAQ", "BINANCE").
14    exchange: String,
15    /// Unique instrument identifier used by the exchange (e.g., FIGI for T-Bank).
16    instrument_id: Option<String>,
17    /// Quantity of the asset held.
18    quantity: Decimal,
19    /// The lot size for this instrument (how many units per lot).
20    lot_size: u32,
21    /// Current price per unit.
22    current_price: Money,
23    /// Average cost basis per unit (for P&L calculations).
24    average_cost: Option<Money>,
25    /// Whether the position is blocked/frozen.
26    blocked: bool,
27    /// Number of lots that are blocked.
28    blocked_lots: u32,
29}
30
31impl Position {
32    /// Creates a new Position.
33    #[must_use]
34    pub fn new(
35        symbol: impl Into<String>,
36        exchange: impl Into<String>,
37        quantity: Decimal,
38        current_price: Money,
39    ) -> Self {
40        Self {
41            symbol: symbol.into(),
42            exchange: exchange.into(),
43            instrument_id: None,
44            quantity,
45            lot_size: 1,
46            current_price,
47            average_cost: None,
48            blocked: false,
49            blocked_lots: 0,
50        }
51    }
52
53    /// Creates a Position builder for more complex construction.
54    #[must_use]
55    pub fn builder(symbol: impl Into<String>, exchange: impl Into<String>) -> PositionBuilder {
56        PositionBuilder::new(symbol, exchange)
57    }
58
59    /// Returns the symbol.
60    #[must_use]
61    pub fn symbol(&self) -> &str {
62        &self.symbol
63    }
64
65    /// Returns the exchange.
66    #[must_use]
67    pub fn exchange(&self) -> &str {
68        &self.exchange
69    }
70
71    /// Returns the instrument ID if set.
72    #[must_use]
73    pub fn instrument_id(&self) -> Option<&str> {
74        self.instrument_id.as_deref()
75    }
76
77    /// Returns the quantity held.
78    #[must_use]
79    pub const fn quantity(&self) -> Decimal {
80        self.quantity
81    }
82
83    /// Returns the lot size.
84    #[must_use]
85    pub const fn lot_size(&self) -> u32 {
86        self.lot_size
87    }
88
89    /// Returns the current price.
90    #[must_use]
91    pub const fn current_price(&self) -> &Money {
92        &self.current_price
93    }
94
95    /// Returns the average cost if set.
96    #[must_use]
97    pub const fn average_cost(&self) -> Option<&Money> {
98        self.average_cost.as_ref()
99    }
100
101    /// Returns whether the position is blocked.
102    #[must_use]
103    pub const fn is_blocked(&self) -> bool {
104        self.blocked
105    }
106
107    /// Returns the number of blocked lots.
108    #[must_use]
109    pub const fn blocked_lots(&self) -> u32 {
110        self.blocked_lots
111    }
112
113    /// Calculates the total market value of the position.
114    #[must_use]
115    pub fn market_value(&self) -> Money {
116        self.current_price.multiply(self.quantity)
117    }
118
119    /// Calculates the number of complete lots held.
120    #[must_use]
121    pub fn lots_held(&self) -> u32 {
122        let lot_size = Decimal::from(self.lot_size);
123        (self.quantity / lot_size)
124            .floor()
125            .to_string()
126            .parse::<u32>()
127            .unwrap_or(0)
128    }
129
130    /// Calculates the number of tradeable lots (total - blocked).
131    #[must_use]
132    pub fn tradeable_lots(&self) -> u32 {
133        self.lots_held().saturating_sub(self.blocked_lots)
134    }
135
136    /// Calculates the unrealized P&L if average cost is known.
137    #[must_use]
138    pub fn unrealized_pnl(&self) -> Option<Money> {
139        let avg_cost = self.average_cost.as_ref()?;
140        let cost_basis = avg_cost.multiply(self.quantity);
141        let market_value = self.market_value();
142        market_value.checked_sub(&cost_basis)
143    }
144
145    /// Calculates the unrealized P&L percentage if average cost is known.
146    #[must_use]
147    pub fn unrealized_pnl_percent(&self) -> Option<Decimal> {
148        let avg_cost = self.average_cost.as_ref()?;
149        if avg_cost.is_zero() {
150            return None;
151        }
152        let pnl = self.unrealized_pnl()?;
153        let cost_basis = avg_cost.multiply(self.quantity);
154        Some((pnl.amount() / cost_basis.amount()) * Decimal::from(100))
155    }
156
157    /// Updates the current price.
158    pub fn update_price(&mut self, price: Money) {
159        self.current_price = price;
160    }
161
162    /// Updates the quantity.
163    pub fn update_quantity(&mut self, quantity: Decimal) {
164        self.quantity = quantity;
165    }
166}
167
168/// Builder for constructing Position instances.
169pub struct PositionBuilder {
170    symbol: String,
171    exchange: String,
172    instrument_id: Option<String>,
173    quantity: Decimal,
174    lot_size: u32,
175    current_price: Option<Money>,
176    average_cost: Option<Money>,
177    blocked: bool,
178    blocked_lots: u32,
179}
180
181impl PositionBuilder {
182    fn new(symbol: impl Into<String>, exchange: impl Into<String>) -> Self {
183        Self {
184            symbol: symbol.into(),
185            exchange: exchange.into(),
186            instrument_id: None,
187            quantity: Decimal::ZERO,
188            lot_size: 1,
189            current_price: None,
190            average_cost: None,
191            blocked: false,
192            blocked_lots: 0,
193        }
194    }
195
196    /// Sets the instrument ID.
197    #[must_use]
198    pub fn instrument_id(mut self, id: impl Into<String>) -> Self {
199        self.instrument_id = Some(id.into());
200        self
201    }
202
203    /// Sets the quantity.
204    #[must_use]
205    pub const fn quantity(mut self, qty: Decimal) -> Self {
206        self.quantity = qty;
207        self
208    }
209
210    /// Sets the lot size.
211    #[must_use]
212    pub const fn lot_size(mut self, size: u32) -> Self {
213        self.lot_size = size;
214        self
215    }
216
217    /// Sets the current price.
218    #[must_use]
219    pub fn current_price(mut self, price: Money) -> Self {
220        self.current_price = Some(price);
221        self
222    }
223
224    /// Sets the average cost.
225    #[must_use]
226    pub fn average_cost(mut self, cost: Money) -> Self {
227        self.average_cost = Some(cost);
228        self
229    }
230
231    /// Marks the position as blocked.
232    #[must_use]
233    pub const fn blocked(mut self, is_blocked: bool) -> Self {
234        self.blocked = is_blocked;
235        self
236    }
237
238    /// Sets the number of blocked lots.
239    #[must_use]
240    pub const fn blocked_lots(mut self, lots: u32) -> Self {
241        self.blocked_lots = lots;
242        self
243    }
244
245    /// Builds the Position instance.
246    ///
247    /// # Panics
248    ///
249    /// Panics if current_price is not set.
250    #[must_use]
251    pub fn build(self) -> Position {
252        Position {
253            symbol: self.symbol,
254            exchange: self.exchange,
255            instrument_id: self.instrument_id,
256            quantity: self.quantity,
257            lot_size: self.lot_size,
258            current_price: self.current_price.expect("current_price is required"),
259            average_cost: self.average_cost,
260            blocked: self.blocked,
261            blocked_lots: self.blocked_lots,
262        }
263    }
264}
265
266#[cfg(test)]
267mod tests {
268    use super::*;
269    use rust_decimal_macros::dec;
270
271    fn make_usd(amount: Decimal) -> Money {
272        Money::new(amount, "USD")
273    }
274
275    #[test]
276    fn test_position_creation() {
277        let pos = Position::new("AAPL", "NASDAQ", dec!(10), make_usd(dec!(150)));
278        assert_eq!(pos.symbol(), "AAPL");
279        assert_eq!(pos.exchange(), "NASDAQ");
280        assert_eq!(pos.quantity(), dec!(10));
281    }
282
283    #[test]
284    fn test_position_builder() {
285        let pos = Position::builder("SBER", "MOEX")
286            .instrument_id("BBG004730N88")
287            .quantity(dec!(100))
288            .lot_size(10)
289            .current_price(make_usd(dec!(250)))
290            .average_cost(make_usd(dec!(200)))
291            .build();
292
293        assert_eq!(pos.symbol(), "SBER");
294        assert_eq!(pos.instrument_id(), Some("BBG004730N88"));
295        assert_eq!(pos.lot_size(), 10);
296        assert_eq!(pos.lots_held(), 10); // 100 / 10
297    }
298
299    #[test]
300    fn test_market_value() {
301        let pos = Position::new("BTC", "BINANCE", dec!(2.5), make_usd(dec!(40000)));
302        let value = pos.market_value();
303        assert_eq!(value.amount(), dec!(100000));
304    }
305
306    #[test]
307    fn test_unrealized_pnl() {
308        let pos = Position::builder("AAPL", "NASDAQ")
309            .quantity(dec!(10))
310            .current_price(make_usd(dec!(150)))
311            .average_cost(make_usd(dec!(100)))
312            .build();
313
314        let pnl = pos.unrealized_pnl().unwrap();
315        assert_eq!(pnl.amount(), dec!(500)); // (150 - 100) * 10
316
317        let pnl_pct = pos.unrealized_pnl_percent().unwrap();
318        assert_eq!(pnl_pct, dec!(50)); // 50% gain
319    }
320
321    #[test]
322    fn test_lots_calculation() {
323        let pos = Position::builder("SBER", "MOEX")
324            .quantity(dec!(105))
325            .lot_size(10)
326            .current_price(make_usd(dec!(250)))
327            .blocked_lots(3)
328            .build();
329
330        assert_eq!(pos.lots_held(), 10); // 105 / 10 = 10 complete lots
331        assert_eq!(pos.tradeable_lots(), 7); // 10 - 3
332    }
333}