use super::traits::{MarketState, Strategy, StrategyAction, StrategyDecision};
use crate::domain::Order;
use async_trait::async_trait;
use rust_decimal::Decimal;
use serde::{Deserialize, Serialize};
use tracing::{debug, trace};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct HoldingConfig {
pub symbol: String,
pub target_percent: Option<Decimal>,
pub absolute_value: Option<Decimal>,
pub tolerance_percent: Decimal,
pub allow_sell: bool,
pub min_trade_lots: u32,
pub enabled: bool,
}
impl Default for HoldingConfig {
fn default() -> Self {
Self {
symbol: String::new(),
target_percent: None,
absolute_value: None,
tolerance_percent: Decimal::new(5, 1), allow_sell: false,
min_trade_lots: 1,
enabled: true,
}
}
}
impl HoldingConfig {
#[must_use]
pub fn with_percent(symbol: impl Into<String>, target_percent: Decimal) -> Self {
Self {
symbol: symbol.into(),
target_percent: Some(target_percent),
..Default::default()
}
}
#[must_use]
pub fn with_absolute(symbol: impl Into<String>, absolute_value: Decimal) -> Self {
Self {
symbol: symbol.into(),
absolute_value: Some(absolute_value),
..Default::default()
}
}
#[must_use]
pub fn with_tolerance(mut self, tolerance: Decimal) -> Self {
self.tolerance_percent = tolerance;
self
}
#[must_use]
pub fn with_allow_sell(mut self, allow: bool) -> Self {
self.allow_sell = allow;
self
}
#[must_use]
pub fn with_min_trade_lots(mut self, lots: u32) -> Self {
self.min_trade_lots = lots;
self
}
#[must_use]
pub fn with_enabled(mut self, enabled: bool) -> Self {
self.enabled = enabled;
self
}
}
#[derive(Debug)]
pub struct HoldingStrategy {
config: HoldingConfig,
}
impl HoldingStrategy {
#[must_use]
pub fn new(config: HoldingConfig) -> Self {
Self { config }
}
#[must_use]
pub fn config(&self) -> &HoldingConfig {
&self.config
}
fn calculate_target_value(&self, total_portfolio_value: Decimal) -> Decimal {
self.config.target_percent.map_or_else(
|| self.config.absolute_value.unwrap_or(Decimal::ZERO),
|target_percent| total_portfolio_value * target_percent / Decimal::from(100),
)
}
fn calculate_action(
&self,
state: &MarketState,
total_portfolio_value: Decimal,
) -> StrategyDecision {
if !self.config.enabled {
return StrategyDecision::hold_with_reason("Strategy disabled");
}
let target_value = self.calculate_target_value(total_portfolio_value);
if target_value.is_zero() {
return StrategyDecision::hold_with_reason("No target value configured");
}
let current_value = state
.position
.as_ref()
.map_or(Decimal::ZERO, |p| p.market_value().amount());
let diff_value = target_value - current_value;
let diff_percent = if total_portfolio_value.is_zero() {
Decimal::ZERO
} else {
(diff_value.abs() / total_portfolio_value) * Decimal::from(100)
};
trace!(
"{}: current={}, target={}, diff={} ({}%)",
self.config.symbol,
current_value,
target_value,
diff_value,
diff_percent
);
if diff_percent <= self.config.tolerance_percent {
return StrategyDecision::hold_with_reason("Within tolerance");
}
let price = match state.last_price {
Some(p) if !p.is_zero() => p,
_ => return StrategyDecision::hold_with_reason("No price available"),
};
let lots_needed = (diff_value.abs() / price).floor();
let lots_u32 = lots_needed.to_string().parse::<u32>().unwrap_or(0);
if lots_u32 < self.config.min_trade_lots {
return StrategyDecision::hold_with_reason("Trade size below minimum");
}
let mut decision = StrategyDecision::new();
if diff_value > Decimal::ZERO {
if state.available_cash >= diff_value {
debug!(
"Holding strategy: buying {} lots of {} at ~{}",
lots_u32, self.config.symbol, price
);
decision.add_action(StrategyAction::market_buy(&self.config.symbol, lots_u32));
decision = decision.with_reason("Under-allocated, buying to target");
} else {
return StrategyDecision::hold_with_reason("Insufficient cash");
}
} else if self.config.allow_sell {
let position_lots = state.position_lots();
let lots_to_sell = lots_u32.min(position_lots);
if lots_to_sell > 0 {
debug!(
"Holding strategy: selling {} lots of {}",
lots_to_sell, self.config.symbol
);
decision.add_action(StrategyAction::market_sell(
&self.config.symbol,
lots_to_sell,
));
decision = decision.with_reason("Over-allocated, selling to target");
} else {
return StrategyDecision::hold_with_reason("No position to sell");
}
} else {
return StrategyDecision::hold_with_reason("Over-allocated but sell not allowed");
}
decision
}
}
#[async_trait]
impl Strategy for HoldingStrategy {
fn name(&self) -> &str {
"Holding Strategy"
}
fn id(&self) -> &str {
&self.config.symbol
}
async fn decide(&self, state: &MarketState) -> StrategyDecision {
let total_value = state.available_cash
+ state
.position
.as_ref()
.map_or(Decimal::ZERO, |p| p.market_value().amount());
self.calculate_action(state, total_value)
}
async fn on_order_filled(&mut self, _order: &Order) {
}
async fn on_order_cancelled(&mut self, _order: &Order) {
}
async fn reset(&mut self) {
}
fn symbols(&self) -> Vec<String> {
vec![self.config.symbol.clone()]
}
fn is_enabled(&self) -> bool {
self.config.enabled
}
}
#[cfg(test)]
mod tests {
use super::*;
use rust_decimal_macros::dec;
#[test]
fn test_holding_config_with_percent() {
let config = HoldingConfig::with_percent("AAPL", dec!(20))
.with_tolerance(dec!(1))
.with_allow_sell(true);
assert_eq!(config.symbol, "AAPL");
assert_eq!(config.target_percent, Some(dec!(20)));
assert_eq!(config.tolerance_percent, dec!(1));
assert!(config.allow_sell);
}
#[test]
fn test_holding_config_with_absolute() {
let config = HoldingConfig::with_absolute("GOOGL", dec!(10000));
assert_eq!(config.symbol, "GOOGL");
assert_eq!(config.absolute_value, Some(dec!(10000)));
assert!(config.target_percent.is_none());
}
#[test]
fn test_calculate_target_value_percent() {
let config = HoldingConfig::with_percent("AAPL", dec!(25));
let strategy = HoldingStrategy::new(config);
let target = strategy.calculate_target_value(dec!(100000));
assert_eq!(target, dec!(25000));
}
#[test]
fn test_calculate_target_value_absolute() {
let config = HoldingConfig::with_absolute("AAPL", dec!(15000));
let strategy = HoldingStrategy::new(config);
let target = strategy.calculate_target_value(dec!(100000));
assert_eq!(target, dec!(15000));
}
#[tokio::test]
async fn test_strategy_symbols() {
let config = HoldingConfig::with_percent("MSFT", dec!(10));
let strategy = HoldingStrategy::new(config);
assert_eq!(strategy.symbols(), vec!["MSFT".to_string()]);
}
#[tokio::test]
async fn test_strategy_disabled() {
let config = HoldingConfig::with_percent("AAPL", dec!(10)).with_enabled(false);
let strategy = HoldingStrategy::new(config);
let state = MarketState::new("AAPL", "USD").with_cash(dec!(10000));
let decision = strategy.decide(&state).await;
assert!(decision.is_hold());
assert_eq!(decision.reason(), Some("Strategy disabled"));
}
}