diff --git a/README.md b/README.md index 59b013b..24601d2 100644 --- a/README.md +++ b/README.md @@ -6,374 +6,362 @@ A unified trading bot framework written in Rust with multi-exchange support, mul [![Rust Version](https://img.shields.io/badge/rust-1.70%2B-blue.svg)](https://www.rust-lang.org/) [![License: Unlicense](https://img.shields.io/badge/license-Unlicense-blue.svg)](http://unlicense.org/) -## Features +## Quick Start -- **Multiple Trading Strategies**: - - **Balancer Strategy**: Automatically rebalance portfolios to target allocations - - **Scalping Strategy**: Buy-low/sell-high with FIFO lot tracking - - **Holding Strategy**: Maintain target allocations per asset - - Extensible strategy trait for custom implementations -- **Portfolio Allocation Modes**: - - Manual (fixed percentages) - - Market Cap weighted - - AUM (Assets Under Management) weighted - - Decorrelation strategy -- **Multi-Exchange Support**: Abstracted exchange layer with adapters for: - - T-Bank (formerly Tinkoff Investments) - - Binance (spot trading) - - Interactive Brokers (TWS/IB Gateway) - - Extensible for other brokers -- **Multi-User/Multi-Account**: Manage multiple users and accounts from a single configuration -- **Market Simulator**: Built-in simulator for backtesting and testing strategies -- **Comprehensive Testing**: Unit tests, integration tests, and scenario-based tests -- **Clean Architecture**: Follows [code architecture principles](https://github.com/link-foundation/code-architecture-principles) +```bash +# Install +cargo install --git https://github.com/link-assistant/trader-bot.git -## Architecture +# Run demo mode +trader-bot --demo -The crate follows Clean Architecture principles with clear separation of concerns: +# Plan mode (see orders without executing) +trader-bot --demo --plan +# With configuration file +trader-bot --config config.lino ``` -src/ -├── adapters/ # Exchange-specific implementations -│ ├── binance.rs # Binance adapter -│ ├── interactive_brokers.rs # IB adapter -│ └── tbank.rs # T-Bank adapter -├── config/ # Configuration management -├── domain/ # Core business types (Position, Wallet, Order, Money, Trade) -├── exchange/ # Exchange API abstraction layer (ExchangeProvider trait) -├── simulator/ # Market simulation for testing -└── strategy/ # Trading strategies - ├── balancer/ # Portfolio rebalancing (engine, calculator, actions) - ├── scalper.rs # Scalping strategy with FIFO tracking - ├── holding.rs # Position holding strategy - └── traits.rs # Strategy trait definition -``` - -### Key Design Principles -- **Modularity**: Split into independently understandable modules -- **Separation of Concerns**: Domain logic separate from exchange APIs -- **Abstraction**: Exchange-agnostic design via `ExchangeProvider` trait -- **Strategy Pattern**: Composable strategies via the `Strategy` trait -- **Testability**: Pure calculation logic with comprehensive tests -- **Immutability**: Value types for domain concepts (Money, Position) - -## Quick Start +## Configuration -### Installation +Trader Bot uses [Links Notation](https://github.com/link-foundation/links-notation) for configuration. The indented syntax provides clear, readable configuration files. -```bash -# Clone the repository -git clone https://github.com/link-assistant/trader-bot.git -cd trader-bot +### Simple Configuration -# Build the project -cargo build +Create a `config.lino` file: -# Run tests -cargo test +``` +# Simple trading configuration + +settings + log_level info + verbose false + +users + ( + broker tbank + token TBANK_API_TOKEN + accounts + main + allocation + SBER 30 + LKOH 30 + GAZP 40 + ) +``` -# Run the demo -cargo run +### Multi-User Configuration -# Run an example -cargo run --example basic_usage ``` - -### Basic Usage - Portfolio Balancing - -```rust -use trader_bot::{ - strategy::balancer::{BalancerConfig, BalancerEngine}, - domain::DesiredAllocation, - simulator::SimulatedExchange, -}; -use rust_decimal_macros::dec; -use std::sync::Arc; - -#[tokio::main] -async fn main() { - // Create a simulated exchange - let exchange = SimulatedExchange::new("USD"); - exchange.add_instrument("AAPL", "Apple Inc.", dec!(150), 1); - exchange.add_instrument("GOOGL", "Alphabet Inc.", dec!(2800), 1); - exchange.create_account("my_account", dec!(100000)); - - // Define target allocation - let mut target = DesiredAllocation::new(); - target.set("AAPL", dec!(50)); - target.set("GOOGL", dec!(50)); - - // Create and run balancer - let exchange = Arc::new(exchange); - let config = BalancerConfig::default(); - let mut engine = BalancerEngine::new(exchange, config); - - let result = engine.rebalance("my_account", &target).await; - println!("Rebalance result: {:?}", result); -} +# Multi-user trading configuration + +settings + log_level info + verbose false + dry_run false + +users + ( + broker tbank + token TBANK_API_TOKEN + accounts + main + allocation + strategy balancer + portion 30 + allocation + strategy hold + ticker SBER + portion 30 + allocation + strategy hold + ticker LKOH + portion 40 + allocation + strategy hold + ticker GAZP + trading + allocation + AAPL 50 + GOOGL 50 + ) + ( + exchange binance + "api key" BINANCE_API_KEY + accounts + spot_main + allocation + BTC 50 + ETH 50 + ) ``` -### Using Trading Strategies - -```rust -use trader_bot::strategy::{ - Strategy, StrategyDecision, MarketState, - ScalpingStrategy, TradingSettings, - HoldingStrategy, HoldingConfig, -}; -use rust_decimal_macros::dec; - -// Create a scalping strategy -let settings = TradingSettings::new("AAPL") - .with_minimum_profit_steps(2) - .with_max_position(100); -let scalper = ScalpingStrategy::new(settings); - -// Create a holding strategy -let config = HoldingConfig::new("GOOGL") - .with_percent(dec!(25)); // Target 25% allocation -let holder = HoldingStrategy::new(config); - -// Get strategy decisions -let state = MarketState::new("AAPL", "USD") - .with_cash(dec!(10000)) - .with_last_price(dec!(150)); - -let decision = scalper.decide(&state).await; -``` +### Allocation Syntax -## Configuration +The short syntax and full recursive syntax are equivalent: -Create a `config.json` file: - -```json -{ - "version": "1.0.0", - "settings": { - "log_level": "info", - "verbose": false - }, - "users": [ - { - "id": "user1", - "name": "John Doe", - "email": "john@example.com", - "accounts": [ - { - "id": "account1", - "name": "Main Trading Account", - "exchange": "tbank", - "exchange_account_id": "12345", - "token_env_var": "TBANK_API_TOKEN", - "desired_allocation": { - "SBER": 30, - "LKOH": 30, - "GAZP": 40 - }, - "allocation_mode": "manual", - "balance_interval_secs": 3600 - } - ], - "active": true - } - ], - "accounts": [ - { - "id": "shared", - "name": "Shared Account", - "exchange": "binance", - "exchange_account_id": "67890", - "token_env_var": "BINANCE_API_KEY", - "desired_allocation": { - "BTC": 50, - "ETH": 50 - }, - "allocation_mode": "market_cap" - } - ] -} +``` +# Short syntax (simple allocations) +allocation + SBER 30 + LKOH 30 + GAZP 40 + +# Full recursive syntax (advanced features) +allocation + strategy balancer + portion 30 + allocation + strategy hold + ticker SBER + portion 30 + allocation + strategy hold + ticker LKOH + portion 40 + allocation + strategy hold + ticker GAZP ``` -## Testing +The full syntax enables nested balancers and mixed strategies: -### Unit Tests +``` +# 60% stocks, 40% crypto +allocation + strategy balancer + portion 60 + allocation + strategy balancer + portion 50 + allocation + strategy hold + ticker SBER + portion 50 + allocation + strategy hold + ticker LKOH + portion 40 + allocation + strategy balancer + portion 70 + allocation + strategy hold + ticker BTC + portion 30 + allocation + strategy hold + ticker ETH +``` -Unit tests are included in each module: +## CLI Options -```bash -cargo test --lib +``` +trader-bot + --config + Path to configuration file + env TRADER_BOT_CONFIG + + --lenv + Path to lenv file for environment variables + env LENV_FILE + + --log-level + values + trace + debug + info + warn + error + default info + env TRADER_BOT_LOG_LEVEL + + --verbose + Enable verbose output + default false + env TRADER_BOT_VERBOSE + + --dry-run + Run without executing actual trades + default false + env TRADER_BOT_DRY_RUN + + --plan + Show planned orders without executing + default false + env TRADER_BOT_PLAN + + --account + Specific account to use + env TRADER_BOT_ACCOUNT + + --user + Specific user for multi-user setups + env TRADER_BOT_USER + + --balance-interval + Rebalancing interval override + env TRADER_BOT_BALANCE_INTERVAL + + --order-delay + Delay between orders + env TRADER_BOT_ORDER_DELAY + + --run-once + Run once and exit + default false + env TRADER_BOT_RUN_ONCE + + --demo + Run with simulated exchange + default false ``` -### Integration Tests +## Environment Variables -Integration tests verify the complete workflow: +Create a `.lenv` file for sensitive credentials: -```bash -cargo test --test integration_test +``` +# API tokens (keep secret!) +TBANK_API_TOKEN: your_token_here +BINANCE_API_KEY: your_binance_key +BINANCE_API_SECRET: your_binance_secret + +# Bot settings +TRADER_BOT_LOG_LEVEL: info +TRADER_BOT_VERBOSE: false +TRADER_BOT_BALANCE_INTERVAL: 3600 ``` -### Scenario-based Tests - -The simulator supports scenario-based testing: - -```rust -use trader_bot::simulator::{ScenarioBuilder, PriceModel}; -use rust_decimal_macros::dec; - -#[tokio::test] -async fn test_volatile_market() { - let scenario = ScenarioBuilder::new("Volatile market") - .with_cash(dec!(100000)) - .with_instrument("STOCK", "Test Stock", dec!(100), 1) - .with_target("STOCK", dec!(80)) - .with_price_model("STOCK", PriceModel::RandomWalk { volatility: dec!(5) }) - .with_ticks(100) - .with_rebalance_interval(10) - .assert_min_value(dec!(80000)) - .build(); - - let result = scenario.run().await; - assert!(result.passed); -} +Configuration priority (highest to lowest): + +``` +priority + CLI arguments + environment variables + configuration files + default values ``` -## Exchange Support +## Examples -### Available Adapters +```bash +# Demo mode - see the bot in action +trader-bot --demo -| Exchange | Adapter | Status | -|----------|---------|--------| -| T-Bank | `TBankAdapter` | Placeholder | -| Binance | `BinanceAdapter` | Placeholder | -| Interactive Brokers | `InteractiveBrokersAdapter` | Placeholder | -| Simulator | `SimulatedExchange` | Full implementation | +# Plan mode - preview orders without execution +trader-bot --demo --plan -### Implementing a New Exchange +# Verbose demo with planning +trader-bot --demo --plan --verbose -To add support for a new exchange, implement the `ExchangeProvider` trait: +# Use specific config file +trader-bot --config trading.lino -```rust -use async_trait::async_trait; -use trader_bot::exchange::{ExchangeProvider, ExchangeResult, /* ... */}; +# Override balance interval +trader-bot --config config.lino --balance-interval 1800 -struct MyExchange { - // Exchange-specific fields -} +# Run once and exit +trader-bot --config config.lino --run-once -#[async_trait] -impl ExchangeProvider for MyExchange { - fn info(&self) -> &ExchangeInfo { /* ... */ } - async fn ping(&self) -> ExchangeResult<()> { /* ... */ } - async fn get_wallet(&self, account_id: &str) -> ExchangeResult { /* ... */ } - // ... implement other methods -} +# Specific user and account +trader-bot --config config.lino --user user1 --account main ``` -## Strategies - -### Implementing Custom Strategies +## Features -To create a custom trading strategy, implement the `Strategy` trait: +- **Trading Strategies** + - Balancer: Automatically rebalance portfolios to target allocations + - Scalper: Buy-low/sell-high with FIFO lot tracking + - Hold: Maintain target allocations per asset + - Extensible strategy trait for custom implementations -```rust -use async_trait::async_trait; -use trader_bot::strategy::{Strategy, StrategyDecision, MarketState}; -use trader_bot::domain::Order; +- **Recursive Allocation System** + - Simple ticker-based allocations + - Nested balancer strategies + - Mix strategies (e.g., 50% balancer, 30% scalper, 20% hold) -struct MyStrategy { - symbol: String, -} +- **Multi-Exchange Support** + - T-Bank (formerly Tinkoff Investments) + - Binance (spot trading) + - Interactive Brokers (TWS/IB Gateway) + - Extensible for other brokers -#[async_trait] -impl Strategy for MyStrategy { - fn name(&self) -> &str { "my_strategy" } +- **Multi-User/Multi-Account** + - Multiple broker connections per configuration + - Multiple accounts per broker + - Individual allocation per account - async fn decide(&self, state: &MarketState) -> StrategyDecision { - // Your trading logic here - StrategyDecision::hold() - } +- **Market Simulator** + - Built-in simulator for testing + - Scenario-based backtesting - async fn on_order_filled(&mut self, order: &Order) { - // Handle order fills - } +## Architecture - async fn on_order_cancelled(&mut self, order: &Order) { - // Handle cancellations - } +``` +src/ +├── adapters/ # Exchange-specific implementations +│ ├── binance.rs +│ ├── interactive_brokers.rs +│ └── tbank.rs +├── config/ # Configuration management +├── domain/ # Core business types +│ ├── allocation.rs # Recursive allocation system +│ ├── user.rs # User/Account models +│ ├── wallet.rs # Portfolio types +│ └── ... +├── exchange/ # Exchange abstraction layer +├── simulator/ # Market simulation +└── strategy/ # Trading strategies + ├── balancer/ # Portfolio rebalancing + ├── scalper.rs # Scalping strategy + └── holding.rs # Position holding +``` - async fn reset(&mut self) { - // Reset strategy state - } +Key design principles: - fn symbols(&self) -> Vec { - vec![self.symbol.clone()] - } -} +``` +principles + modularity + Independent composable modules + separation_of_concerns + Domain logic separate from APIs + strategy_pattern + Composable strategies via Strategy trait + testability + Pure calculation logic with comprehensive tests + links_notation + Human readable configuration ``` ## Development -### Code Quality - ```bash # Format code cargo fmt -# Check formatting (CI style) -cargo fmt --check +# Run lints +cargo clippy --all-targets --all-features -# Run Clippy lints -cargo clippy --all-targets --all-features -- -D warnings +# Run tests +cargo test -# Run all checks -cargo fmt --check && cargo clippy --all-targets --all-features -- -D warnings && cargo test +# Build +cargo build --release ``` -### Pre-commit Hooks - -Install pre-commit hooks for automatic checks: +## Testing ```bash -pip install pre-commit -pre-commit install -``` +# Unit tests +cargo test --lib -## Project Structure +# Integration tests +cargo test --test integration_test +# Specific test +cargo test allocation ``` -. -├── .github/workflows/ # CI/CD pipeline -├── changelog.d/ # Changelog fragments -├── examples/ -│ └── basic_usage.rs # Usage examples -├── src/ -│ ├── adapters/ # Exchange adapters -│ ├── config/ # Configuration -│ ├── domain/ # Core domain types -│ ├── exchange/ # Exchange abstraction -│ ├── simulator/ # Market simulator -│ ├── strategy/ # Trading strategies -│ ├── lib.rs # Library entry -│ └── main.rs # CLI entry -├── tests/ -│ └── integration_test.rs -├── Cargo.toml -└── README.md -``` - -## Contributing - -Contributions are welcome! Please see [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines. - -### Development Workflow - -1. Fork the repository -2. Create a feature branch: `git checkout -b feature/my-feature` -3. Make your changes and add tests -4. Run quality checks: `cargo fmt && cargo clippy && cargo test` -5. Add a changelog fragment -6. Commit and create a Pull Request ## License @@ -381,6 +369,7 @@ Contributions are welcome! Please see [CONTRIBUTING.md](CONTRIBUTING.md) for gui ## References -- Originally based on [balancer-trader-bot](https://github.com/link-assistant/trader-bot) -- Incorporates ideas from [scalper-trader-bot](https://github.com/link-assistant/scalper-trader-bot) -- Follows [code-architecture-principles](https://github.com/link-foundation/code-architecture-principles) +- [Links Notation](https://github.com/link-foundation/links-notation) - Configuration format +- [lino-arguments](https://github.com/link-foundation/lino-arguments) - Unified configuration system +- [lino-env](https://github.com/link-foundation/lino-env) - Environment file format +- [code-architecture-principles](https://github.com/link-foundation/code-architecture-principles) - Architecture guidelines diff --git a/changelog.d/20260105_225000_plan_option.md b/changelog.d/20260105_225000_plan_option.md new file mode 100644 index 0000000..6f9ce9e --- /dev/null +++ b/changelog.d/20260105_225000_plan_option.md @@ -0,0 +1,9 @@ +### Added +- Added `--plan` CLI option for read-only mode with order planning + - Allows running the bot with real market APIs in read-only mode + - Calculates and displays all orders that would be placed without executing them + - Helps debug and verify bot behavior without risking actual trades + - Works with both demo mode (`--demo --plan`) and real exchanges +- Added `PlannedOrder` and `PlannedOrders` types for representing planned orders +- Added environment variable support: `TRADER_BOT_PLAN` +- Added comprehensive test coverage for plan mode functionality diff --git a/lib/rust/lino_args/lenv.rs b/lib/rust/lino_args/lenv.rs index bc493f7..57800e7 100644 --- a/lib/rust/lino_args/lenv.rs +++ b/lib/rust/lino_args/lenv.rs @@ -1,7 +1,29 @@ //! Links Notation Environment (lenv) file loading. //! -//! Provides functionality to load configuration from `.lenv` files, -//! which are similar to `.env` files but with Links Notation support. +//! Provides functionality to load configuration from `.lenv` files using +//! [Links Notation](https://github.com/link-foundation/links-notation) format. +//! +//! # Format +//! +//! The lenv format uses `: ` (colon-space) as separator following Links Notation: +//! - `KEY: value` - Simple key-value pairs +//! - `KEY: "value with spaces"` - Quoted values +//! - `KEY: 'value with spaces'` - Single-quoted values +//! - `# comment` - Comments (lines starting with #) +//! - Empty lines are ignored +//! - Indented nested structures are supported +//! +//! # Example +//! +//! ```text +//! # API tokens +//! TBANK_API_TOKEN: your_token_here +//! BINANCE_API_KEY: your_key_here +//! +//! # Settings +//! TRADER_BOT_LOG_LEVEL: info +//! TRADER_BOT_VERBOSE: false +//! ``` use std::collections::HashMap; use std::fs; @@ -43,19 +65,22 @@ impl From for LenvError { /// /// # Format /// -/// The lenv format supports: -/// - `KEY=value` - Simple key-value pairs -/// - `KEY="value with spaces"` - Quoted values -/// - `KEY='value with spaces'` - Single-quoted values +/// The lenv format uses Links Notation with `: ` (colon-space) separator: +/// - `KEY: value` - Simple key-value pairs +/// - `KEY: "value with spaces"` - Quoted values +/// - `KEY: 'value with spaces'` - Single-quoted values /// - `# comment` - Comments (lines starting with #) /// - Empty lines are ignored /// +/// For backwards compatibility, `=` separator is also supported. +/// /// # Examples /// /// ``` /// use trader_bot::lino_args::parse_lenv; /// -/// let content = "# Configuration\nAPI_KEY=my_secret_key\nPORT=8080\nDEBUG=true\n"; +/// // Links Notation format (preferred) +/// let content = "# Configuration\nAPI_KEY: my_secret_key\nPORT: 8080\nDEBUG: true\n"; /// /// let vars = parse_lenv(content).unwrap(); /// assert_eq!(vars.get("API_KEY"), Some(&"my_secret_key".to_string())); @@ -72,35 +97,45 @@ pub fn parse_lenv(content: &str) -> Result, LenvError> { continue; } - // Find the = separator - if let Some(eq_pos) = trimmed.find('=') { + // Try Links Notation format first (": " separator) + // Then fall back to traditional format ("=" separator) + let (key, value) = if let Some(colon_pos) = trimmed.find(": ") { + let key = trimmed[..colon_pos].trim(); + let value = trimmed[colon_pos + 2..].trim(); + (key, value) + } else if let Some(eq_pos) = trimmed.find('=') { let key = trimmed[..eq_pos].trim(); let value = trimmed[eq_pos + 1..].trim(); + (key, value) + } else { + return Err(LenvError::Parse { + line: line_num + 1, + message: "Missing ': ' or '=' separator".to_string(), + }); + }; - if key.is_empty() { - return Err(LenvError::Parse { - line: line_num + 1, - message: "Empty key".to_string(), - }); - } + if key.is_empty() { + return Err(LenvError::Parse { + line: line_num + 1, + message: "Empty key".to_string(), + }); + } - // Handle quoted values - let parsed_value = if (value.starts_with('"') && value.ends_with('"')) - || (value.starts_with('\'') && value.ends_with('\'')) - { - // Remove quotes + // Handle quoted values + let parsed_value = if (value.starts_with('"') && value.ends_with('"')) + || (value.starts_with('\'') && value.ends_with('\'')) + { + // Remove quotes + if value.len() >= 2 { value[1..value.len() - 1].to_string() } else { value.to_string() - }; - - vars.insert(key.to_string(), parsed_value); + } } else { - return Err(LenvError::Parse { - line: line_num + 1, - message: "Missing '=' separator".to_string(), - }); - } + value.to_string() + }; + + vars.insert(key.to_string(), parsed_value); } Ok(vars) diff --git a/lib/rust/lino_args/lino.rs b/lib/rust/lino_args/lino.rs new file mode 100644 index 0000000..9e3d6a0 --- /dev/null +++ b/lib/rust/lino_args/lino.rs @@ -0,0 +1,686 @@ +//! Links Notation (Lino) parser for hierarchical configuration. +//! +//! This module provides parsing for indented Links Notation format, supporting +//! hierarchical nested structures for complex configuration. +//! +//! # Format +//! +//! Links Notation uses indentation to represent hierarchy: +//! - Lines are key-value pairs or just keys introducing a nested block +//! - Indentation (2 spaces per level recommended) defines nesting depth +//! - `key value` - Key with a simple value +//! - `key:` - Key introducing a nested block (children follow indented) +//! - `# comment` - Comments (lines starting with #) +//! - `(item)` - Tuple/list item +//! +//! # Example +//! +//! ```text +//! # Multi-user trading configuration +//! settings +//! log_level info +//! verbose false +//! +//! users +//! ( +//! broker tbank +//! token TBANK_API_TOKEN +//! accounts +//! main +//! allocation +//! SBER 30 +//! LKOH 30 +//! GAZP 40 +//! ) +//! ``` + +use std::collections::HashMap; + +/// A parsed Links Notation value - can be a string, list, or nested structure. +#[derive(Debug, Clone, PartialEq)] +pub enum LinoValue { + /// A simple string value. + String(String), + /// A list of values (from parentheses syntax). + List(Vec), + /// A nested object/map structure. + Object(HashMap), + /// A numeric value. + Number(f64), + /// A boolean value. + Bool(bool), + /// Null/empty value. + Null, +} + +impl LinoValue { + /// Returns the value as a string, if it is one. + #[must_use] + pub fn as_str(&self) -> Option<&str> { + match self { + Self::String(s) => Some(s), + _ => None, + } + } + + /// Returns the value as a string, converting numbers and bools. + #[must_use] + pub fn to_string_value(&self) -> String { + match self { + Self::String(s) => s.clone(), + Self::Number(n) => n.to_string(), + Self::Bool(b) => b.to_string(), + Self::Null | Self::List(_) | Self::Object(_) => String::new(), + } + } + + /// Returns the value as a bool. + #[must_use] + pub fn as_bool(&self) -> Option { + match self { + Self::Bool(b) => Some(*b), + Self::String(s) => match s.to_lowercase().as_str() { + "true" | "yes" | "on" | "1" => Some(true), + "false" | "no" | "off" | "0" => Some(false), + _ => None, + }, + _ => None, + } + } + + /// Returns the value as a number. + #[must_use] + pub fn as_number(&self) -> Option { + match self { + Self::Number(n) => Some(*n), + Self::String(s) => s.parse().ok(), + _ => None, + } + } + + /// Returns the value as an object. + #[must_use] + pub fn as_object(&self) -> Option<&HashMap> { + match self { + Self::Object(obj) => Some(obj), + _ => None, + } + } + + /// Returns the value as a list. + #[must_use] + pub fn as_list(&self) -> Option<&[LinoNode]> { + match self { + Self::List(list) => Some(list), + _ => None, + } + } + + /// Gets a nested value by key path (dot-separated or slash-separated). + #[must_use] + pub fn get(&self, path: &str) -> Option<&Self> { + let parts: Vec<&str> = path.split(['.', '/']).collect(); + let mut current = self; + + for part in parts { + match current { + Self::Object(obj) => { + current = obj.get(part)?; + } + _ => return None, + } + } + + Some(current) + } + + /// Gets a string value by path. + #[must_use] + pub fn get_str(&self, path: &str) -> Option<&str> { + self.get(path).and_then(Self::as_str) + } + + /// Gets a bool value by path. + #[must_use] + pub fn get_bool(&self, path: &str) -> Option { + self.get(path).and_then(Self::as_bool) + } + + /// Gets a number value by path. + #[must_use] + pub fn get_number(&self, path: &str) -> Option { + self.get(path).and_then(Self::as_number) + } +} + +impl Default for LinoValue { + fn default() -> Self { + Self::Null + } +} + +/// A node in the Links Notation structure. +#[derive(Debug, Clone, PartialEq)] +pub struct LinoNode { + /// The key/name of this node (empty for list items). + pub key: String, + /// The value of this node. + pub value: LinoValue, +} + +impl LinoNode { + /// Creates a new node with a key and value. + #[must_use] + pub fn new(key: impl Into, value: LinoValue) -> Self { + Self { + key: key.into(), + value, + } + } + + /// Creates a new string node. + #[must_use] + pub fn string(key: impl Into, value: impl Into) -> Self { + Self { + key: key.into(), + value: LinoValue::String(value.into()), + } + } +} + +/// Error type for Links Notation parsing. +#[derive(Debug, Clone)] +pub struct LinoParseError { + /// Line number where the error occurred. + pub line: usize, + /// Description of the error. + pub message: String, +} + +impl std::fmt::Display for LinoParseError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "Parse error at line {}: {}", self.line, self.message) + } +} + +impl std::error::Error for LinoParseError {} + +/// Represents a parsed line with its indentation level. +#[derive(Debug)] +struct ParsedLine { + indent: usize, + content: String, +} + +/// Parses Links Notation content into a structured value. +/// +/// # Arguments +/// +/// * `content` - The Links Notation text to parse +/// +/// # Returns +/// +/// Returns a `LinoValue::Object` containing the parsed structure. +/// +/// # Examples +/// +/// ``` +/// use trader_bot::lino_args::lino::parse_lino; +/// +/// let content = r#" +/// settings +/// log_level info +/// verbose false +/// +/// users +/// user1 +/// name John +/// "#; +/// +/// let config = parse_lino(content).unwrap(); +/// assert_eq!(config.get_str("settings/log_level"), Some("info")); +/// ``` +pub fn parse_lino(content: &str) -> Result { + let lines = parse_lines(content); + if lines.is_empty() { + return Ok(LinoValue::Object(HashMap::new())); + } + + let (value, _) = parse_block(&lines, 0, 0)?; + Ok(value) +} + +/// Parses raw content into ParsedLine structures. +fn parse_lines(content: &str) -> Vec { + let mut lines = Vec::new(); + + for line in content.lines() { + // Skip empty lines and comments + let trimmed = line.trim(); + if trimmed.is_empty() || trimmed.starts_with('#') { + continue; + } + + // Calculate indentation (count leading spaces) + let indent = line.len() - line.trim_start().len(); + + lines.push(ParsedLine { + indent, + content: trimmed.to_string(), + }); + } + + lines +} + +/// Parses a block of lines at a given indentation level. +fn parse_block( + lines: &[ParsedLine], + start: usize, + base_indent: usize, +) -> Result<(LinoValue, usize), LinoParseError> { + let mut result: HashMap = HashMap::new(); + let mut list_items: Vec = Vec::new(); + let mut is_list = false; + let mut i = start; + + while i < lines.len() { + let line = &lines[i]; + + // If we've dedented past our block, we're done + if line.indent < base_indent && i > start { + break; + } + + // Skip lines that are deeper than expected (they belong to a parent block) + if line.indent > base_indent && i == start { + i += 1; + continue; + } + + // Handle lines at our indentation level + if line.indent == base_indent || (i == start && line.indent >= base_indent) { + let current_indent = line.indent; + + // Check if this is a list item (starts with parenthesis) + if line.content.starts_with('(') { + is_list = true; + + // Check if it's a single-line tuple or multi-line block + if line.content.ends_with(')') && line.content.len() > 2 { + // Single line tuple: (key value) + let inner = &line.content[1..line.content.len() - 1]; + let node = parse_tuple_content(inner); + list_items.push(node); + i += 1; + } else if line.content == "(" { + // Multi-line block - parse children + let child_indent = if i + 1 < lines.len() { + lines[i + 1].indent + } else { + current_indent + 2 + }; + + let (child_value, next_i) = parse_block(lines, i + 1, child_indent)?; + list_items.push(LinoNode::new("", child_value)); + + // Find the closing parenthesis + i = next_i; + while i < lines.len() && lines[i].content != ")" { + i += 1; + } + if i < lines.len() && lines[i].content == ")" { + i += 1; + } + } else { + // Inline content after opening paren + i += 1; + } + } else if line.content == ")" { + // End of list block + i += 1; + break; + } else { + // Regular key-value or key with children + let (key, value_str) = parse_key_value(&line.content); + + // Check if there are children at a deeper indentation + let has_children = i + 1 < lines.len() && lines[i + 1].indent > current_indent; + + if has_children { + // Parse nested block + let child_indent = lines[i + 1].indent; + let (child_value, next_i) = parse_block(lines, i + 1, child_indent)?; + + // If there was an inline value too, merge it + if let Some(v) = value_str { + if let LinoValue::Object(mut obj) = child_value { + // Add the inline value as a special "_value" key + obj.insert("_value".to_string(), parse_value_string(&v)); + result.insert(key, LinoValue::Object(obj)); + } else { + result.insert(key, child_value); + } + } else { + result.insert(key, child_value); + } + + i = next_i; + } else { + // Simple key-value + let value = value_str + .map(|v| parse_value_string(&v)) + .unwrap_or(LinoValue::Null); + result.insert(key, value); + i += 1; + } + } + } else { + // Line is at different indentation - move on + i += 1; + } + } + + if is_list { + Ok((LinoValue::List(list_items), i)) + } else { + Ok((LinoValue::Object(result), i)) + } +} + +/// Parses a single line into key and optional value. +fn parse_key_value(line: &str) -> (String, Option) { + // Handle quoted keys like "log level" info + if let Some(stripped) = line.strip_prefix('"') { + if let Some(end_quote) = stripped.find('"') { + let key = &stripped[..end_quote]; + let rest = stripped[end_quote + 1..].trim(); + + if rest.is_empty() { + return (normalize_key(key), None); + } + + // Check for colon separator after the quoted key + if let Some(value) = rest.strip_prefix(": ") { + return (normalize_key(key), Some(value.trim().to_string())); + } + + return (normalize_key(key), Some(rest.to_string())); + } + } + + // Handle "key: value" format (Links Notation with colon) + if let Some(colon_pos) = line.find(": ") { + let key = line[..colon_pos].trim(); + let value = line[colon_pos + 2..].trim(); + return (normalize_key(key), Some(value.to_string())); + } + + // Handle "key value" format (space separated) + if let Some(space_pos) = line.find(' ') { + let key = line[..space_pos].trim(); + let value = line[space_pos + 1..].trim(); + + // Check if key ends with colon (block indicator) + if let Some(key) = key.strip_suffix(':') { + if value.is_empty() { + return (normalize_key(key), None); + } + return (normalize_key(key), Some(value.to_string())); + } + + return (normalize_key(key), Some(value.to_string())); + } + + // Handle "key:" format (block indicator with no value) + if let Some(key) = line.strip_suffix(':') { + return (normalize_key(key), None); + } + + // Just a key + (normalize_key(line), None) +} + +/// Parses tuple content like "key value" or "key: value". +fn parse_tuple_content(content: &str) -> LinoNode { + let trimmed = content.trim(); + + if trimmed.is_empty() { + return LinoNode::new("", LinoValue::Null); + } + + let (key, value) = parse_key_value(trimmed); + let value = value + .map(|v| parse_value_string(&v)) + .unwrap_or(LinoValue::Null); + + LinoNode::new(key, value) +} + +/// Normalizes a key by replacing quoted spaces with underscores. +fn normalize_key(key: &str) -> String { + // Handle quoted keys like "log level" + if key.starts_with('"') && key.ends_with('"') && key.len() > 2 { + return key[1..key.len() - 1].replace(' ', "_"); + } + + // Replace spaces in unquoted keys with underscores + key.replace(' ', "_") +} + +/// Parses a value string into the appropriate LinoValue type. +fn parse_value_string(s: &str) -> LinoValue { + let s = s.trim(); + + // Handle quoted strings + if ((s.starts_with('"') && s.ends_with('"')) || (s.starts_with('\'') && s.ends_with('\''))) + && s.len() >= 2 + { + return LinoValue::String(s[1..s.len() - 1].to_string()); + } + + // Handle booleans + match s.to_lowercase().as_str() { + "true" | "yes" | "on" => return LinoValue::Bool(true), + "false" | "no" | "off" => return LinoValue::Bool(false), + "null" | "nil" | "none" => return LinoValue::Null, + _ => {} + } + + // Handle numbers + if let Ok(n) = s.parse::() { + return LinoValue::Number(n); + } + + // Default to string + LinoValue::String(s.to_string()) +} + +/// Flattens a LinoValue into environment variable style key-value pairs. +/// +/// This is useful for converting hierarchical config to flat environment variables. +/// +/// # Example +/// +/// Given this config: +/// ```text +/// settings +/// log_level info +/// ``` +/// +/// Produces: +/// ```text +/// SETTINGS_LOG_LEVEL=info +/// ``` +pub fn flatten_to_env(value: &LinoValue, prefix: &str) -> Vec<(String, String)> { + let mut result = Vec::new(); + flatten_recursive(value, prefix, &mut result); + result +} + +fn flatten_recursive(value: &LinoValue, prefix: &str, result: &mut Vec<(String, String)>) { + match value { + LinoValue::Object(obj) => { + for (key, val) in obj { + let new_prefix = if prefix.is_empty() { + key.to_uppercase() + } else { + format!("{}_{}", prefix, key.to_uppercase()) + }; + flatten_recursive(val, &new_prefix, result); + } + } + LinoValue::List(list) => { + for (i, node) in list.iter().enumerate() { + let new_prefix = format!("{}_{}", prefix, i); + flatten_recursive(&node.value, &new_prefix, result); + } + } + LinoValue::String(s) => { + if !prefix.is_empty() { + result.push((prefix.to_string(), s.clone())); + } + } + LinoValue::Number(n) => { + if !prefix.is_empty() { + result.push((prefix.to_string(), n.to_string())); + } + } + LinoValue::Bool(b) => { + if !prefix.is_empty() { + result.push((prefix.to_string(), b.to_string())); + } + } + LinoValue::Null => {} + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_parse_simple_key_value() { + let content = r" +key value +another_key another_value +"; + let result = parse_lino(content).unwrap(); + assert_eq!(result.get_str("key"), Some("value")); + assert_eq!(result.get_str("another_key"), Some("another_value")); + } + + #[test] + fn test_parse_nested_structure() { + let content = r" +settings + log_level info + verbose false +"; + let result = parse_lino(content).unwrap(); + assert_eq!(result.get_str("settings/log_level"), Some("info")); + assert_eq!(result.get_bool("settings/verbose"), Some(false)); + } + + #[test] + fn test_parse_deeply_nested() { + let content = r" +users + user1 + name John + accounts + main + balance 1000 +"; + let result = parse_lino(content).unwrap(); + assert_eq!(result.get_str("users/user1/name"), Some("John")); + assert_eq!( + result.get_number("users/user1/accounts/main/balance"), + Some(1000.0) + ); + } + + #[test] + fn test_parse_with_colons() { + let content = r" +settings: + log_level: info + verbose: false +"; + let result = parse_lino(content).unwrap(); + assert_eq!(result.get_str("settings/log_level"), Some("info")); + } + + #[test] + fn test_parse_allocation_config() { + let content = r" +allocation + SBER 30 + LKOH 30 + GAZP 40 +"; + let result = parse_lino(content).unwrap(); + assert_eq!(result.get_number("allocation/SBER"), Some(30.0)); + assert_eq!(result.get_number("allocation/LKOH"), Some(30.0)); + assert_eq!(result.get_number("allocation/GAZP"), Some(40.0)); + } + + #[test] + fn test_parse_comments() { + let content = r" +# This is a comment +key value +# Another comment +another_key value2 +"; + let result = parse_lino(content).unwrap(); + assert_eq!(result.get_str("key"), Some("value")); + assert_eq!(result.get_str("another_key"), Some("value2")); + } + + #[test] + fn test_flatten_to_env() { + let content = r" +settings + log_level info + verbose true +"; + let result = parse_lino(content).unwrap(); + let env_vars = flatten_to_env(&result, ""); + + assert!(env_vars.contains(&("SETTINGS_LOG_LEVEL".to_string(), "info".to_string()))); + assert!(env_vars.contains(&("SETTINGS_VERBOSE".to_string(), "true".to_string()))); + } + + #[test] + fn test_parse_list_items() { + let content = r" +users + ( + name John + broker tbank + ) + ( + name Jane + broker binance + ) +"; + let result = parse_lino(content).unwrap(); + if let Some(LinoValue::List(users)) = result.get("users") { + assert_eq!(users.len(), 2); + } else { + panic!("Expected users to be a list"); + } + } + + #[test] + fn test_parse_quoted_keys() { + let content = r#" +"log level" info +"dry run" false +"#; + let result = parse_lino(content).unwrap(); + assert_eq!(result.get_str("log_level"), Some("info")); + assert_eq!(result.get_bool("dry_run"), Some(false)); + } +} diff --git a/lib/rust/lino_args/mod.rs b/lib/rust/lino_args/mod.rs index 28ec764..cb65cd5 100644 --- a/lib/rust/lino_args/mod.rs +++ b/lib/rust/lino_args/mod.rs @@ -1,7 +1,7 @@ //! lino-arguments - A unified configuration library for Rust. //! //! This module provides a unified configuration system combining environment variables -//! and CLI arguments with a clear priority chain. +//! and CLI arguments with a clear priority chain, using Links Notation format. //! //! # Priority (highest to lowest) //! @@ -10,6 +10,12 @@ //! 3. Configuration file (lenv file specified via CLI) //! 4. Default values //! +//! # Links Notation +//! +//! This module supports [Links Notation](https://github.com/link-foundation/links-notation) +//! for hierarchical configuration files. The indented syntax allows for complex nested +//! structures with intuitive readability. +//! //! # Example //! //! ```rust,no_run @@ -24,10 +30,36 @@ //! // Get boolean with default //! let debug = getenv_bool("DEBUG", false); //! ``` +//! +//! ## Hierarchical Configuration +//! +//! ```rust,no_run +//! use trader_bot::lino_args::lino::parse_lino; +//! +//! let content = r#" +//! settings +//! log_level info +//! verbose false +//! +//! users +//! ( +//! broker tbank +//! accounts +//! main +//! allocation +//! SBER 30 +//! LKOH 70 +//! ) +//! "#; +//! +//! let config = parse_lino(content).unwrap(); +//! assert_eq!(config.get_str("settings/log_level"), Some("info")); +//! ``` mod case; mod env; mod lenv; +pub mod lino; pub use case::*; pub use env::*; diff --git a/lib/rust/lino_args/tests.rs b/lib/rust/lino_args/tests.rs index 07fb71e..fa72e6c 100644 --- a/lib/rust/lino_args/tests.rs +++ b/lib/rust/lino_args/tests.rs @@ -133,11 +133,12 @@ mod getenv_tests { mod lenv_tests { use super::*; + // Tests for Links Notation format (": " separator - preferred) #[test] - fn test_parse_lenv_simple() { + fn test_parse_lenv_links_notation_simple() { let content = r" -API_KEY=my_secret -PORT=8080 +API_KEY: my_secret +PORT: 8080 "; let vars = parse_lenv(content).unwrap(); assert_eq!(vars.get("API_KEY"), Some(&"my_secret".to_string())); @@ -145,12 +146,12 @@ PORT=8080 } #[test] - fn test_parse_lenv_with_comments() { + fn test_parse_lenv_links_notation_with_comments() { let content = r" # This is a comment -API_KEY=secret +API_KEY: secret # Another comment -DEBUG=true +DEBUG: true "; let vars = parse_lenv(content).unwrap(); assert_eq!(vars.len(), 2); @@ -159,11 +160,11 @@ DEBUG=true } #[test] - fn test_parse_lenv_quoted_values() { + fn test_parse_lenv_links_notation_quoted_values() { let content = r#" -MESSAGE="Hello, World!" -SINGLE='Single quoted' -PLAIN=NoQuotes +MESSAGE: "Hello, World!" +SINGLE: 'Single quoted' +PLAIN: NoQuotes "#; let vars = parse_lenv(content).unwrap(); assert_eq!(vars.get("MESSAGE"), Some(&"Hello, World!".to_string())); @@ -171,13 +172,48 @@ PLAIN=NoQuotes assert_eq!(vars.get("PLAIN"), Some(&"NoQuotes".to_string())); } + #[test] + fn test_parse_lenv_links_notation_value_with_colons() { + // Value can contain colons (URL case) + let content = "URL: https://example.com:8080/api"; + let vars = parse_lenv(content).unwrap(); + assert_eq!( + vars.get("URL"), + Some(&"https://example.com:8080/api".to_string()) + ); + } + + // Tests for backwards compatibility with = separator + #[test] + fn test_parse_lenv_equals_separator() { + let content = r" +API_KEY=my_secret +PORT=8080 +"; + let vars = parse_lenv(content).unwrap(); + assert_eq!(vars.get("API_KEY"), Some(&"my_secret".to_string())); + assert_eq!(vars.get("PORT"), Some(&"8080".to_string())); + } + + #[test] + fn test_parse_lenv_mixed_separators() { + // Support both formats in the same file for migration + let content = r" +API_KEY: new_format +LEGACY_VAR=old_format +"; + let vars = parse_lenv(content).unwrap(); + assert_eq!(vars.get("API_KEY"), Some(&"new_format".to_string())); + assert_eq!(vars.get("LEGACY_VAR"), Some(&"old_format".to_string())); + } + #[test] fn test_parse_lenv_empty_lines() { let content = r" -API_KEY=value +API_KEY: value -PORT=8080 +PORT: 8080 "; let vars = parse_lenv(content).unwrap(); @@ -185,7 +221,7 @@ PORT=8080 } #[test] - fn test_parse_lenv_error_no_equals() { + fn test_parse_lenv_error_no_separator() { let content = "INVALID_LINE"; let result = parse_lenv(content); assert!(result.is_err()); @@ -193,8 +229,13 @@ PORT=8080 #[test] fn test_parse_lenv_error_empty_key() { - let content = "=value"; + let content = ": value"; let result = parse_lenv(content); assert!(result.is_err()); + + // Also test equals case + let content2 = "=value"; + let result2 = parse_lenv(content2); + assert!(result2.is_err()); } } diff --git a/src/cli.rs b/src/cli.rs index 7d5481b..91c2d65 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -43,6 +43,11 @@ pub struct Cli { #[arg(long, default_value = "false", env = "TRADER_BOT_DRY_RUN")] pub dry_run: bool, + /// Run in plan mode - connect to real market, calculate orders but don't execute them. + /// Displays detailed information about what orders would be placed. + #[arg(long, default_value = "false", env = "TRADER_BOT_PLAN")] + pub plan: bool, + /// Account ID to use (if not specified, uses all configured accounts). #[arg(short, long, env = "TRADER_BOT_ACCOUNT")] pub account: Option, @@ -180,6 +185,8 @@ pub struct RuntimeConfig { pub verbose: bool, /// Dry run mode enabled. pub dry_run: bool, + /// Plan mode enabled - shows what orders would be placed without executing. + pub plan: bool, /// Specific account to use. pub account: Option, /// Specific user to use. @@ -206,6 +213,7 @@ impl RuntimeConfig { log_level, verbose: cli.verbose, dry_run: cli.dry_run, + plan: cli.plan, account: cli.account, user: cli.user, balance_interval: cli.balance_interval, @@ -243,6 +251,7 @@ impl RuntimeConfig { log_level, verbose: getenv_bool("TRADER_BOT_VERBOSE", false), dry_run: getenv_bool("TRADER_BOT_DRY_RUN", false), + plan: getenv_bool("TRADER_BOT_PLAN", false), account: std::env::var("TRADER_BOT_ACCOUNT").ok(), user: std::env::var("TRADER_BOT_USER").ok(), balance_interval: { diff --git a/src/domain/allocation.rs b/src/domain/allocation.rs new file mode 100644 index 0000000..1970463 --- /dev/null +++ b/src/domain/allocation.rs @@ -0,0 +1,438 @@ +//! Recursive allocation system for portfolio management. +//! +//! This module provides a flexible allocation system that supports: +//! - Simple ticker-based allocations (e.g., SBER 30%, LKOH 30%, GAZP 40%) +//! - Strategy-based allocations (e.g., 50% balancer, 30% scalper, 20% hold) +//! - Nested/recursive allocations (balancers containing other balancers) +//! +//! # Links Notation Configuration +//! +//! ```text +//! allocation +//! strategy balancer +//! portion 30 +//! allocation +//! strategy hold +//! ticker SBER +//! portion 30 +//! allocation +//! strategy hold +//! ticker LKOH +//! portion 40 +//! allocation +//! strategy hold +//! ticker GAZP +//! ``` +//! +//! This is equivalent to the short notation: +//! +//! ```text +//! allocation +//! SBER 30 +//! LKOH 30 +//! GAZP 40 +//! ``` + +use rust_decimal::Decimal; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; + +/// The type of allocation strategy. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)] +#[serde(rename_all = "snake_case")] +pub enum StrategyType { + /// Balancer strategy - maintains target allocations across sub-allocations. + #[default] + Balancer, + /// Hold strategy - maintains a position in a single ticker. + Hold, + /// Scalper strategy - buy-low/sell-high with FIFO tracking. + Scalper, + /// DCA (Dollar Cost Average) strategy - regular purchases. + Dca, + /// Custom strategy with a name. + Custom(String), +} + +impl StrategyType { + /// Creates a strategy type from a string name. + #[must_use] + pub fn from_name(name: &str) -> Self { + match name.to_lowercase().as_str() { + "balancer" | "balance" | "rebalance" => Self::Balancer, + "hold" | "holding" | "hodl" => Self::Hold, + "scalp" | "scalper" | "scalping" => Self::Scalper, + "dca" | "dollar_cost_average" => Self::Dca, + _ => Self::Custom(name.to_string()), + } + } + + /// Returns the strategy name as a string. + #[must_use] + pub fn as_str(&self) -> &str { + match self { + Self::Balancer => "balancer", + Self::Hold => "hold", + Self::Scalper => "scalper", + Self::Dca => "dca", + Self::Custom(name) => name, + } + } +} + +/// A portion of an allocation with its own strategy. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct AllocationPortion { + /// The percentage of the parent allocation (0-100). + pub percentage: Decimal, + /// The allocation details for this portion. + pub allocation: Allocation, +} + +impl AllocationPortion { + /// Creates a new allocation portion. + #[must_use] + pub fn new(percentage: Decimal, allocation: Allocation) -> Self { + Self { + percentage, + allocation, + } + } + + /// Creates a simple hold portion for a ticker. + #[must_use] + pub fn hold(ticker: impl Into, percentage: Decimal) -> Self { + Self { + percentage, + allocation: Allocation::hold(ticker), + } + } +} + +/// A recursive allocation structure supporting nested strategies. +/// +/// An allocation can be: +/// 1. A simple ticker hold (ticker + optional strategy) +/// 2. A strategy with portions (balancer managing sub-allocations) +/// 3. A mix of both +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)] +pub struct Allocation { + /// The strategy to use for this allocation. + #[serde(default)] + pub strategy: StrategyType, + + /// The ticker symbol (for hold/scalper strategies). + #[serde(skip_serializing_if = "Option::is_none")] + pub ticker: Option, + + /// Child portions (for balancer strategy). + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub portions: Vec, + + /// Strategy-specific parameters. + #[serde(default, skip_serializing_if = "HashMap::is_empty")] + pub params: HashMap, +} + +impl Allocation { + /// Creates a new empty allocation with default balancer strategy. + #[must_use] + pub fn new() -> Self { + Self::default() + } + + /// Creates a new balancer allocation with portions. + #[must_use] + pub fn balancer(portions: Vec) -> Self { + Self { + strategy: StrategyType::Balancer, + ticker: None, + portions, + params: HashMap::new(), + } + } + + /// Creates a simple hold allocation for a ticker. + #[must_use] + pub fn hold(ticker: impl Into) -> Self { + Self { + strategy: StrategyType::Hold, + ticker: Some(ticker.into()), + portions: Vec::new(), + params: HashMap::new(), + } + } + + /// Creates a scalper allocation for a ticker. + #[must_use] + pub fn scalper(ticker: impl Into) -> Self { + Self { + strategy: StrategyType::Scalper, + ticker: Some(ticker.into()), + portions: Vec::new(), + params: HashMap::new(), + } + } + + /// Creates a simple allocation from ticker-percentage pairs. + /// + /// This is the "short notation" equivalent where: + /// ```text + /// allocation + /// SBER 30 + /// LKOH 30 + /// GAZP 40 + /// ``` + /// + /// Becomes a balancer with hold portions. + #[must_use] + pub fn from_simple(allocations: &[(String, Decimal)]) -> Self { + let portions = allocations + .iter() + .map(|(ticker, pct)| AllocationPortion::hold(ticker, *pct)) + .collect(); + + Self::balancer(portions) + } + + /// Creates an allocation from a HashMap of ticker -> percentage. + #[must_use] + pub fn from_map(allocations: HashMap) -> Self { + let portions = allocations + .into_iter() + .map(|(ticker, pct)| AllocationPortion::hold(ticker, pct)) + .collect(); + + Self::balancer(portions) + } + + /// Sets a strategy parameter. + pub fn set_param(&mut self, key: impl Into, value: impl Into) { + self.params.insert(key.into(), value.into()); + } + + /// Gets a strategy parameter. + #[must_use] + pub fn get_param(&self, key: &str) -> Option<&str> { + self.params.get(key).map(|s| s.as_str()) + } + + /// Adds a portion to the allocation. + pub fn add_portion(&mut self, portion: AllocationPortion) { + self.portions.push(portion); + } + + /// Returns true if this is a simple hold strategy. + #[must_use] + pub fn is_hold(&self) -> bool { + matches!(self.strategy, StrategyType::Hold) && self.ticker.is_some() + } + + /// Returns true if this is a balancer strategy. + #[must_use] + pub fn is_balancer(&self) -> bool { + matches!(self.strategy, StrategyType::Balancer) && !self.portions.is_empty() + } + + /// Returns all tickers involved in this allocation (recursively). + #[must_use] + pub fn all_tickers(&self) -> Vec { + let mut tickers = Vec::new(); + self.collect_tickers(&mut tickers); + tickers + } + + fn collect_tickers(&self, tickers: &mut Vec) { + if let Some(ref ticker) = self.ticker { + if !tickers.contains(ticker) { + tickers.push(ticker.clone()); + } + } + for portion in &self.portions { + portion.allocation.collect_tickers(tickers); + } + } + + /// Calculates the total percentage of all portions. + #[must_use] + pub fn total_percentage(&self) -> Decimal { + self.portions.iter().map(|p| p.percentage).sum() + } + + /// Validates that portions sum to approximately 100%. + #[must_use] + pub fn is_valid(&self) -> bool { + if self.portions.is_empty() { + // Simple hold/scalper allocation is always valid if it has a ticker + return self.ticker.is_some(); + } + + let total = self.total_percentage(); + let diff = (total - Decimal::from(100)).abs(); + diff < Decimal::ONE // Allow 1% tolerance + } + + /// Normalizes portions to sum to exactly 100%. + pub fn normalize(&mut self) { + let total = self.total_percentage(); + if total.is_zero() { + return; + } + + let factor = Decimal::from(100) / total; + for portion in &mut self.portions { + portion.percentage *= factor; + } + } + + /// Flattens the allocation to a simple ticker -> percentage map. + /// + /// This recursively expands all nested allocations and calculates + /// the effective percentage for each ticker. + #[must_use] + pub fn flatten(&self) -> HashMap { + let mut result = HashMap::new(); + self.flatten_recursive(Decimal::from(100), &mut result); + result + } + + fn flatten_recursive(&self, parent_pct: Decimal, result: &mut HashMap) { + // If this is a simple hold/scalper, add the ticker + if let Some(ref ticker) = self.ticker { + let entry = result.entry(ticker.clone()).or_insert(Decimal::ZERO); + *entry += parent_pct; + return; + } + + // Otherwise, process portions + for portion in &self.portions { + let effective_pct = parent_pct * portion.percentage / Decimal::from(100); + portion.allocation.flatten_recursive(effective_pct, result); + } + } +} + +/// Converts a `DesiredAllocation` to the new `Allocation` format. +impl From for Allocation { + fn from(desired: super::DesiredAllocation) -> Self { + let map: HashMap = desired.iter().map(|(k, v)| (k.clone(), *v)).collect(); + Self::from_map(map) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use rust_decimal_macros::dec; + + #[test] + fn test_simple_allocation() { + let alloc = Allocation::from_simple(&[ + ("SBER".to_string(), dec!(30)), + ("LKOH".to_string(), dec!(30)), + ("GAZP".to_string(), dec!(40)), + ]); + + assert!(alloc.is_balancer()); + assert!(alloc.is_valid()); + assert_eq!(alloc.portions.len(), 3); + + let flat = alloc.flatten(); + assert_eq!(flat.get("SBER"), Some(&dec!(30))); + assert_eq!(flat.get("LKOH"), Some(&dec!(30))); + assert_eq!(flat.get("GAZP"), Some(&dec!(40))); + } + + #[test] + fn test_hold_allocation() { + let alloc = Allocation::hold("BTC"); + assert!(alloc.is_hold()); + assert_eq!(alloc.ticker, Some("BTC".to_string())); + assert_eq!(alloc.all_tickers(), vec!["BTC".to_string()]); + } + + #[test] + fn test_nested_allocation() { + // Create nested allocation: + // 60% to stocks (SBER 50%, LKOH 50%) + // 40% to crypto (BTC 70%, ETH 30%) + let stocks = Allocation::balancer(vec![ + AllocationPortion::hold("SBER", dec!(50)), + AllocationPortion::hold("LKOH", dec!(50)), + ]); + + let crypto = Allocation::balancer(vec![ + AllocationPortion::hold("BTC", dec!(70)), + AllocationPortion::hold("ETH", dec!(30)), + ]); + + let root = Allocation::balancer(vec![ + AllocationPortion::new(dec!(60), stocks), + AllocationPortion::new(dec!(40), crypto), + ]); + + assert!(root.is_valid()); + + let flat = root.flatten(); + // SBER: 60% * 50% = 30% + // LKOH: 60% * 50% = 30% + // BTC: 40% * 70% = 28% + // ETH: 40% * 30% = 12% + assert_eq!(flat.get("SBER"), Some(&dec!(30))); + assert_eq!(flat.get("LKOH"), Some(&dec!(30))); + assert_eq!(flat.get("BTC"), Some(&dec!(28))); + assert_eq!(flat.get("ETH"), Some(&dec!(12))); + + let all_tickers = root.all_tickers(); + assert_eq!(all_tickers.len(), 4); + } + + #[test] + fn test_strategy_types() { + assert_eq!(StrategyType::from_name("balancer"), StrategyType::Balancer); + assert_eq!(StrategyType::from_name("HOLD"), StrategyType::Hold); + assert_eq!(StrategyType::from_name("scalper"), StrategyType::Scalper); + assert_eq!( + StrategyType::from_name("custom_strat"), + StrategyType::Custom("custom_strat".to_string()) + ); + } + + #[test] + fn test_normalize_allocation() { + let mut alloc = Allocation::balancer(vec![ + AllocationPortion::hold("A", dec!(30)), + AllocationPortion::hold("B", dec!(20)), + ]); + + // Total is 50%, normalize to 100% + alloc.normalize(); + + assert_eq!(alloc.portions[0].percentage, dec!(60)); // 30/50 * 100 + assert_eq!(alloc.portions[1].percentage, dec!(40)); // 20/50 * 100 + } + + #[test] + fn test_deeply_nested_allocation() { + // Test 3-level nesting + let level3 = Allocation::balancer(vec![ + AllocationPortion::hold("X", dec!(50)), + AllocationPortion::hold("Y", dec!(50)), + ]); + + let level2 = Allocation::balancer(vec![AllocationPortion::new(dec!(100), level3)]); + + let root = Allocation::balancer(vec![ + AllocationPortion::new(dec!(50), level2), + AllocationPortion::hold("Z", dec!(50)), + ]); + + let flat = root.flatten(); + // X: 50% * 100% * 50% = 25% + // Y: 50% * 100% * 50% = 25% + // Z: 50% + assert_eq!(flat.get("X"), Some(&dec!(25))); + assert_eq!(flat.get("Y"), Some(&dec!(25))); + assert_eq!(flat.get("Z"), Some(&dec!(50))); + } +} diff --git a/src/domain/mod.rs b/src/domain/mod.rs index d62932d..f77ff1f 100644 --- a/src/domain/mod.rs +++ b/src/domain/mod.rs @@ -2,17 +2,35 @@ //! //! This module contains core domain models that represent trading concepts //! independent of any specific exchange or broker API. +//! +//! # Allocation System +//! +//! The allocation module provides a recursive allocation system that supports: +//! - Simple ticker-based allocations (e.g., SBER 30%, LKOH 30%, GAZP 40%) +//! - Strategy-based allocations (e.g., 50% balancer, 30% scalper, 20% hold) +//! - Nested/recursive allocations (balancers containing other balancers) +//! +//! # User/Account Model +//! +//! Users represent connections to exchanges/brokers and can have multiple accounts. +//! Each account has its own allocation configuration. +mod allocation; mod decimal; mod money; mod order; +mod plan; mod position; mod trade; +mod user; mod wallet; +pub use allocation::{Allocation, AllocationPortion, StrategyType}; pub use decimal::Decimal; pub use money::Money; pub use order::{Order, OrderDirection, OrderStatus, OrderType}; +pub use plan::{PlannedOrder, PlannedOrders}; pub use position::Position; pub use trade::{Trade, TradeHistory, TradeId}; +pub use user::{Account, BrokerType, ConfigValidationError, Settings, TradingConfig, User}; pub use wallet::{DesiredAllocation, Wallet}; diff --git a/src/domain/plan.rs b/src/domain/plan.rs new file mode 100644 index 0000000..02b7dbc --- /dev/null +++ b/src/domain/plan.rs @@ -0,0 +1,380 @@ +//! Plan mode types for displaying planned orders. +//! +//! This module provides types and utilities for the `--plan` option, +//! which allows users to see what orders would be placed without executing them. + +use std::collections::HashMap; +use std::fmt; + +use rust_decimal::Decimal; +use serde::{Deserialize, Serialize}; + +use super::Money; +use crate::strategy::RebalanceAction; + +/// A planned order that would be executed if not in plan mode. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct PlannedOrder { + /// The trading symbol. + pub symbol: String, + /// The exchange. + pub exchange: String, + /// Order direction ("BUY" or "SELL"). + pub direction: String, + /// Number of lots to trade. + pub lots: u32, + /// Estimated price per lot. + pub estimated_price: Money, + /// Estimated total value of the trade. + pub estimated_value: Money, + /// Current allocation percentage. + pub current_percent: Decimal, + /// Target allocation percentage. + pub target_percent: Decimal, + /// The account this order is for. + pub account_id: String, + /// Optional reason or note for the order. + pub reason: Option, +} + +impl PlannedOrder { + /// Creates a new planned order from a rebalance action. + #[must_use] + pub fn from_rebalance_action(action: &RebalanceAction, account_id: &str) -> Self { + Self { + symbol: action.symbol.clone(), + exchange: action.exchange.clone(), + direction: if action.is_buy() { + "BUY".to_string() + } else { + "SELL".to_string() + }, + lots: action.lots, + estimated_price: action.estimated_price.clone(), + estimated_value: action.estimated_value.clone(), + current_percent: action.current_percent, + target_percent: action.target_percent, + account_id: account_id.to_string(), + reason: None, + } + } + + /// Adds a reason to this planned order. + #[must_use] + pub fn with_reason(mut self, reason: impl Into) -> Self { + self.reason = Some(reason.into()); + self + } +} + +impl fmt::Display for PlannedOrder { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!( + f, + "{} {} lots of {} @ {} (value: {}) [{:.2}% -> {:.2}%]", + self.direction, + self.lots, + self.symbol, + self.estimated_price, + self.estimated_value, + self.current_percent, + self.target_percent + ) + } +} + +/// A collection of planned orders grouped by account. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct PlannedOrders { + /// Planned orders grouped by account ID. + orders: HashMap>, + /// Total value of all planned buy orders. + total_buy_value: Option, + /// Total value of all planned sell orders. + total_sell_value: Option, +} + +impl PlannedOrders { + /// Creates a new empty collection of planned orders. + #[must_use] + pub fn new() -> Self { + Self::default() + } + + /// Adds a planned order for an account. + pub fn add_order(&mut self, order: PlannedOrder) { + let account_id = order.account_id.clone(); + self.orders.entry(account_id).or_default().push(order); + } + + /// Adds multiple planned orders for an account. + pub fn add_orders(&mut self, account_id: &str, orders: impl IntoIterator) { + for mut order in orders { + order.account_id = account_id.to_string(); + self.add_order(order); + } + } + + /// Returns true if there are no planned orders. + #[must_use] + pub fn is_empty(&self) -> bool { + self.orders.is_empty() || self.orders.values().all(|v| v.is_empty()) + } + + /// Returns the total number of planned orders. + #[must_use] + pub fn total_count(&self) -> usize { + self.orders.values().map(Vec::len).sum() + } + + /// Returns the number of accounts with planned orders. + #[must_use] + pub fn account_count(&self) -> usize { + self.orders.values().filter(|v| !v.is_empty()).count() + } + + /// Returns an iterator over all account IDs with orders. + pub fn accounts(&self) -> impl Iterator { + self.orders.keys().map(String::as_str) + } + + /// Returns orders for a specific account. + #[must_use] + pub fn orders_for_account(&self, account_id: &str) -> Option<&[PlannedOrder]> { + self.orders.get(account_id).map(Vec::as_slice) + } + + /// Returns an iterator over all planned orders. + pub fn all_orders(&self) -> impl Iterator { + self.orders.values().flat_map(|v| v.iter()) + } + + /// Sets the total buy value. + pub fn set_total_buy_value(&mut self, value: Money) { + self.total_buy_value = Some(value); + } + + /// Sets the total sell value. + pub fn set_total_sell_value(&mut self, value: Money) { + self.total_sell_value = Some(value); + } + + /// Returns the total buy value if set. + #[must_use] + pub fn total_buy_value(&self) -> Option<&Money> { + self.total_buy_value.as_ref() + } + + /// Returns the total sell value if set. + #[must_use] + pub fn total_sell_value(&self) -> Option<&Money> { + self.total_sell_value.as_ref() + } + + /// Displays the planned orders in a formatted way. + pub fn display(&self) { + if self.is_empty() { + println!("\n📋 PLAN MODE: No orders would be placed"); + println!(" Portfolio is already balanced within tolerance."); + return; + } + + println!("\n╔════════════════════════════════════════════════════════════════╗"); + println!("║ 📋 PLANNED ORDERS ║"); + println!("║ (Plan mode - no actual orders will be placed) ║"); + println!("╚════════════════════════════════════════════════════════════════╝\n"); + + for account_id in self.accounts() { + if let Some(orders) = self.orders_for_account(account_id) { + if orders.is_empty() { + continue; + } + + println!("Account: {}", account_id); + println!("{}", "-".repeat(60)); + + let buys: Vec<_> = orders.iter().filter(|o| o.direction == "BUY").collect(); + let sells: Vec<_> = orders.iter().filter(|o| o.direction == "SELL").collect(); + + if !sells.is_empty() { + println!(" SELL orders:"); + for order in &sells { + println!( + " • {} {} lots @ {} = {}", + order.symbol, order.lots, order.estimated_price, order.estimated_value + ); + println!( + " Allocation: {:.2}% → {:.2}%", + order.current_percent, order.target_percent + ); + } + } + + if !buys.is_empty() { + println!(" BUY orders:"); + for order in &buys { + println!( + " • {} {} lots @ {} = {}", + order.symbol, order.lots, order.estimated_price, order.estimated_value + ); + println!( + " Allocation: {:.2}% → {:.2}%", + order.current_percent, order.target_percent + ); + } + } + + println!(); + } + } + + // Summary + println!("═══════════════════════════════════════════════════════════════"); + println!("SUMMARY:"); + println!(" Total accounts: {}", self.account_count()); + println!(" Total orders: {}", self.total_count()); + if let Some(sell_value) = &self.total_sell_value { + println!(" Total sells: {}", sell_value); + } + if let Some(buy_value) = &self.total_buy_value { + println!(" Total buys: {}", buy_value); + } + println!("═══════════════════════════════════════════════════════════════\n"); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use rust_decimal_macros::dec; + + fn make_usd(amount: Decimal) -> Money { + Money::new(amount, "USD") + } + + #[test] + fn test_planned_order_display() { + let order = PlannedOrder { + symbol: "AAPL".to_string(), + exchange: "NASDAQ".to_string(), + direction: "BUY".to_string(), + lots: 10, + estimated_price: make_usd(dec!(150)), + estimated_value: make_usd(dec!(1500)), + current_percent: dec!(20), + target_percent: dec!(30), + account_id: "acc1".to_string(), + reason: None, + }; + + let display = format!("{order}"); + assert!(display.contains("BUY")); + assert!(display.contains("10 lots")); + assert!(display.contains("AAPL")); + assert!(display.contains("20.00%")); + assert!(display.contains("30.00%")); + } + + #[test] + fn test_planned_orders_collection() { + let mut planned = PlannedOrders::new(); + assert!(planned.is_empty()); + assert_eq!(planned.total_count(), 0); + + let order1 = PlannedOrder { + symbol: "AAPL".to_string(), + exchange: "NASDAQ".to_string(), + direction: "BUY".to_string(), + lots: 10, + estimated_price: make_usd(dec!(150)), + estimated_value: make_usd(dec!(1500)), + current_percent: dec!(20), + target_percent: dec!(30), + account_id: "acc1".to_string(), + reason: None, + }; + + let order2 = PlannedOrder { + symbol: "GOOGL".to_string(), + exchange: "NASDAQ".to_string(), + direction: "SELL".to_string(), + lots: 5, + estimated_price: make_usd(dec!(2800)), + estimated_value: make_usd(dec!(14000)), + current_percent: dec!(40), + target_percent: dec!(25), + account_id: "acc1".to_string(), + reason: None, + }; + + planned.add_order(order1); + planned.add_order(order2); + + assert!(!planned.is_empty()); + assert_eq!(planned.total_count(), 2); + assert_eq!(planned.account_count(), 1); + + let acc_orders = planned.orders_for_account("acc1").unwrap(); + assert_eq!(acc_orders.len(), 2); + } + + #[test] + fn test_planned_order_with_reason() { + let order = PlannedOrder { + symbol: "AAPL".to_string(), + exchange: "NASDAQ".to_string(), + direction: "BUY".to_string(), + lots: 10, + estimated_price: make_usd(dec!(150)), + estimated_value: make_usd(dec!(1500)), + current_percent: dec!(20), + target_percent: dec!(30), + account_id: "acc1".to_string(), + reason: None, + }; + + let order = order.with_reason("Underweight in portfolio"); + assert_eq!(order.reason, Some("Underweight in portfolio".to_string())); + } + + #[test] + fn test_planned_orders_multiple_accounts() { + let mut planned = PlannedOrders::new(); + + let order1 = PlannedOrder { + symbol: "AAPL".to_string(), + exchange: "NASDAQ".to_string(), + direction: "BUY".to_string(), + lots: 10, + estimated_price: make_usd(dec!(150)), + estimated_value: make_usd(dec!(1500)), + current_percent: dec!(20), + target_percent: dec!(30), + account_id: "acc1".to_string(), + reason: None, + }; + + let order2 = PlannedOrder { + symbol: "MSFT".to_string(), + exchange: "NASDAQ".to_string(), + direction: "BUY".to_string(), + lots: 5, + estimated_price: make_usd(dec!(400)), + estimated_value: make_usd(dec!(2000)), + current_percent: dec!(10), + target_percent: dec!(25), + account_id: "acc2".to_string(), + reason: None, + }; + + planned.add_order(order1); + planned.add_order(order2); + + assert_eq!(planned.account_count(), 2); + assert_eq!(planned.total_count(), 2); + + assert!(planned.orders_for_account("acc1").is_some()); + assert!(planned.orders_for_account("acc2").is_some()); + assert!(planned.orders_for_account("acc3").is_none()); + } +} diff --git a/src/domain/user.rs b/src/domain/user.rs new file mode 100644 index 0000000..18e8cfc --- /dev/null +++ b/src/domain/user.rs @@ -0,0 +1,499 @@ +//! User and account domain models. +//! +//! This module provides the core domain models for multi-user, multi-account +//! trading configurations following the Links Notation structure. +//! +//! # Terminology +//! +//! - **User**: A connection to an exchange/broker (has API credentials) +//! - **Account**: A trading account within that broker (can have multiple per user) +//! - **Allocation**: The target portfolio allocation for an account +//! +//! # Links Notation Configuration +//! +//! ```text +//! users +//! ( +//! broker tbank +//! token TBANK_API_TOKEN +//! accounts +//! main +//! allocation +//! SBER 30 +//! LKOH 30 +//! GAZP 40 +//! trading +//! allocation +//! AAPL 50 +//! GOOGL 50 +//! ) +//! ( +//! exchange binance +//! "api key" BINANCE_API_KEY +//! accounts +//! spot_main +//! allocation +//! BTC 50 +//! ETH 50 +//! ) +//! ``` + +use super::Allocation; +use rust_decimal::Decimal; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; + +/// The type of broker/exchange connection. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)] +#[serde(rename_all = "snake_case")] +pub enum BrokerType { + /// T-Bank (formerly Tinkoff Investments). + #[default] + TBank, + /// Binance cryptocurrency exchange. + Binance, + /// Interactive Brokers. + InteractiveBrokers, + /// Simulated exchange (for testing). + Simulator, + /// Custom broker with a name. + Custom(String), +} + +impl BrokerType { + /// Creates a broker type from a string name. + #[must_use] + pub fn from_name(name: &str) -> Self { + match name.to_lowercase().as_str() { + "tbank" | "t-bank" | "tinkoff" => Self::TBank, + "binance" => Self::Binance, + "ib" | "ibkr" | "interactive_brokers" | "interactivebrokers" => { + Self::InteractiveBrokers + } + "sim" | "simulator" | "simulated" | "demo" => Self::Simulator, + _ => Self::Custom(name.to_string()), + } + } + + /// Returns the broker name as a string. + #[must_use] + pub fn as_str(&self) -> &str { + match self { + Self::TBank => "tbank", + Self::Binance => "binance", + Self::InteractiveBrokers => "interactive_brokers", + Self::Simulator => "simulator", + Self::Custom(name) => name, + } + } +} + +/// Configuration for a trading account. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Account { + /// Unique account identifier within the user. + pub id: String, + + /// Human-readable account name. + #[serde(default)] + pub name: String, + + /// Exchange account ID (from broker's system). + #[serde(default)] + pub exchange_account_id: String, + + /// Target portfolio allocation for this account. + pub allocation: Allocation, + + /// Rebalancing interval in seconds. + #[serde(default = "default_balance_interval")] + pub balance_interval_secs: u64, + + /// Delay between orders in milliseconds. + #[serde(default = "default_order_delay")] + pub order_delay_ms: u64, + + /// Minimum trade percentage threshold. + #[serde(default = "default_min_trade_percent")] + pub min_trade_percent: Decimal, + + /// Tolerance for considering allocation balanced. + #[serde(default = "default_tolerance")] + pub tolerance_percent: Decimal, +} + +fn default_balance_interval() -> u64 { + 3600 // 1 hour +} + +fn default_order_delay() -> u64 { + 1000 // 1 second +} + +fn default_min_trade_percent() -> Decimal { + Decimal::new(1, 1) // 0.1% +} + +fn default_tolerance() -> Decimal { + Decimal::new(5, 1) // 0.5% +} + +impl Account { + /// Creates a new account with the given ID and allocation. + #[must_use] + pub fn new(id: impl Into, allocation: Allocation) -> Self { + let id = id.into(); + Self { + name: id.clone(), + id, + exchange_account_id: String::new(), + allocation, + balance_interval_secs: default_balance_interval(), + order_delay_ms: default_order_delay(), + min_trade_percent: default_min_trade_percent(), + tolerance_percent: default_tolerance(), + } + } + + /// Sets the account name. + #[must_use] + pub fn with_name(mut self, name: impl Into) -> Self { + self.name = name.into(); + self + } + + /// Sets the exchange account ID. + #[must_use] + pub fn with_exchange_id(mut self, id: impl Into) -> Self { + self.exchange_account_id = id.into(); + self + } + + /// Sets the balance interval. + #[must_use] + pub fn with_balance_interval(mut self, secs: u64) -> Self { + self.balance_interval_secs = secs; + self + } +} + +/// A user representing a connection to an exchange/broker. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct User { + /// Unique user identifier (auto-generated if not provided). + #[serde(default)] + pub id: String, + + /// Human-readable user name. + #[serde(default)] + pub name: String, + + /// The broker/exchange type. + pub broker: BrokerType, + + /// Environment variable name for the API token. + pub token_env_var: String, + + /// Additional API credentials (e.g., API secret for Binance). + #[serde(default, skip_serializing_if = "HashMap::is_empty")] + pub credentials: HashMap, + + /// Trading accounts for this user. + #[serde(default)] + pub accounts: Vec, + + /// Whether this user is enabled. + #[serde(default = "default_true")] + pub enabled: bool, +} + +fn default_true() -> bool { + true +} + +impl User { + /// Creates a new user for a broker. + #[must_use] + pub fn new(broker: BrokerType, token_env_var: impl Into) -> Self { + Self { + id: uuid::Uuid::new_v4().to_string(), + name: String::new(), + broker, + token_env_var: token_env_var.into(), + credentials: HashMap::new(), + accounts: Vec::new(), + enabled: true, + } + } + + /// Sets the user ID. + #[must_use] + pub fn with_id(mut self, id: impl Into) -> Self { + self.id = id.into(); + self + } + + /// Sets the user name. + #[must_use] + pub fn with_name(mut self, name: impl Into) -> Self { + self.name = name.into(); + self + } + + /// Adds a credential (e.g., API secret). + #[must_use] + pub fn with_credential(mut self, key: impl Into, env_var: impl Into) -> Self { + self.credentials.insert(key.into(), env_var.into()); + self + } + + /// Adds an account to this user. + pub fn add_account(&mut self, account: Account) { + self.accounts.push(account); + } + + /// Returns an account by ID. + #[must_use] + pub fn get_account(&self, id: &str) -> Option<&Account> { + self.accounts.iter().find(|a| a.id == id) + } + + /// Returns a mutable account by ID. + pub fn get_account_mut(&mut self, id: &str) -> Option<&mut Account> { + self.accounts.iter_mut().find(|a| a.id == id) + } + + /// Returns all enabled accounts. + pub fn active_accounts(&self) -> impl Iterator { + self.accounts.iter() + } + + /// Returns all tickers across all accounts. + #[must_use] + pub fn all_tickers(&self) -> Vec { + let mut tickers = Vec::new(); + for account in &self.accounts { + for ticker in account.allocation.all_tickers() { + if !tickers.contains(&ticker) { + tickers.push(ticker); + } + } + } + tickers + } +} + +/// Global settings for the trading bot. +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct Settings { + /// Log level (trace, debug, info, warn, error). + #[serde(default = "default_log_level")] + pub log_level: String, + + /// Enable verbose output. + #[serde(default)] + pub verbose: bool, + + /// Run in dry-run mode (no actual trades). + #[serde(default)] + pub dry_run: bool, + + /// Run in plan mode (show what would happen). + #[serde(default)] + pub plan: bool, +} + +fn default_log_level() -> String { + "info".to_string() +} + +/// Top-level configuration containing users and global settings. +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct TradingConfig { + /// Global settings. + #[serde(default)] + pub settings: Settings, + + /// User configurations. + #[serde(default)] + pub users: Vec, +} + +impl TradingConfig { + /// Creates a new empty configuration. + #[must_use] + pub fn new() -> Self { + Self::default() + } + + /// Adds a user to the configuration. + pub fn add_user(&mut self, user: User) { + self.users.push(user); + } + + /// Returns a user by ID. + #[must_use] + pub fn get_user(&self, id: &str) -> Option<&User> { + self.users.iter().find(|u| u.id == id) + } + + /// Returns all enabled users. + pub fn active_users(&self) -> impl Iterator { + self.users.iter().filter(|u| u.enabled) + } + + /// Returns all accounts across all users. + pub fn all_accounts(&self) -> impl Iterator { + self.users + .iter() + .flat_map(|u| u.accounts.iter().map(move |a| (u, a))) + } + + /// Validates the configuration. + pub fn validate(&self) -> Result<(), ConfigValidationError> { + if self.users.is_empty() { + return Err(ConfigValidationError::NoUsers); + } + + for user in &self.users { + if user.token_env_var.is_empty() { + return Err(ConfigValidationError::MissingToken(user.id.clone())); + } + + if user.accounts.is_empty() { + return Err(ConfigValidationError::NoAccounts(user.id.clone())); + } + + for account in &user.accounts { + if !account.allocation.is_valid() { + return Err(ConfigValidationError::InvalidAllocation( + user.id.clone(), + account.id.clone(), + )); + } + } + } + + Ok(()) + } +} + +/// Configuration validation errors. +#[derive(Debug, Clone)] +pub enum ConfigValidationError { + /// No users configured. + NoUsers, + /// Missing API token for a user. + MissingToken(String), + /// No accounts configured for a user. + NoAccounts(String), + /// Invalid allocation for an account. + InvalidAllocation(String, String), +} + +impl std::fmt::Display for ConfigValidationError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::NoUsers => write!(f, "No users configured"), + Self::MissingToken(user) => write!(f, "Missing API token for user '{user}'"), + Self::NoAccounts(user) => write!(f, "No accounts configured for user '{user}'"), + Self::InvalidAllocation(user, account) => { + write!( + f, + "Invalid allocation for account '{account}' of user '{user}'" + ) + } + } + } +} + +impl std::error::Error for ConfigValidationError {} + +#[cfg(test)] +mod tests { + use super::*; + use rust_decimal_macros::dec; + + #[test] + fn test_broker_type_from_name() { + assert_eq!(BrokerType::from_name("tbank"), BrokerType::TBank); + assert_eq!(BrokerType::from_name("BINANCE"), BrokerType::Binance); + assert_eq!(BrokerType::from_name("ib"), BrokerType::InteractiveBrokers); + assert_eq!(BrokerType::from_name("simulator"), BrokerType::Simulator); + assert_eq!( + BrokerType::from_name("custom"), + BrokerType::Custom("custom".to_string()) + ); + } + + #[test] + fn test_create_user() { + let user = User::new(BrokerType::TBank, "TBANK_TOKEN") + .with_name("Test User") + .with_credential("secret", "TBANK_SECRET"); + + assert_eq!(user.broker, BrokerType::TBank); + assert_eq!(user.token_env_var, "TBANK_TOKEN"); + assert_eq!(user.name, "Test User"); + assert_eq!( + user.credentials.get("secret"), + Some(&"TBANK_SECRET".to_string()) + ); + } + + #[test] + fn test_create_account() { + let alloc = Allocation::from_simple(&[ + ("SBER".to_string(), dec!(50)), + ("LKOH".to_string(), dec!(50)), + ]); + + let account = Account::new("main", alloc) + .with_name("Main Account") + .with_exchange_id("12345"); + + assert_eq!(account.id, "main"); + assert_eq!(account.name, "Main Account"); + assert_eq!(account.exchange_account_id, "12345"); + } + + #[test] + fn test_config_validation() { + let mut config = TradingConfig::new(); + + // Empty config should fail + assert!(config.validate().is_err()); + + // Add user without accounts + let user = User::new(BrokerType::TBank, "TOKEN"); + config.add_user(user); + assert!(config.validate().is_err()); + + // Add account + let alloc = + Allocation::from_simple(&[("A".to_string(), dec!(50)), ("B".to_string(), dec!(50))]); + config.users[0].add_account(Account::new("main", alloc)); + + // Now it should pass + assert!(config.validate().is_ok()); + } + + #[test] + fn test_all_tickers() { + let mut user = User::new(BrokerType::TBank, "TOKEN"); + + let alloc1 = Allocation::from_simple(&[ + ("SBER".to_string(), dec!(50)), + ("LKOH".to_string(), dec!(50)), + ]); + user.add_account(Account::new("acc1", alloc1)); + + let alloc2 = Allocation::from_simple(&[("GAZP".to_string(), dec!(100))]); + user.add_account(Account::new("acc2", alloc2)); + + let tickers = user.all_tickers(); + assert!(tickers.contains(&"SBER".to_string())); + assert!(tickers.contains(&"LKOH".to_string())); + assert!(tickers.contains(&"GAZP".to_string())); + } +} diff --git a/src/lib.rs b/src/lib.rs index b78a2f5..70e561b 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -90,7 +90,8 @@ pub mod prelude { pub use crate::cli::{Cli, RuntimeConfig}; pub use crate::config::{AccountConfig, AppConfig, UserConfig}; pub use crate::domain::{ - DesiredAllocation, Money, Order, OrderDirection, OrderStatus, Position, Trade, Wallet, + DesiredAllocation, Money, Order, OrderDirection, OrderStatus, PlannedOrder, PlannedOrders, + Position, Trade, Wallet, }; pub use crate::exchange::{ExchangeError, ExchangeProvider, ExchangeResult}; pub use crate::lino_args::{getenv, getenv_bool, getenv_decimal, getenv_int, getenv_u64}; diff --git a/src/main.rs b/src/main.rs index 1fe3866..06b7bd5 100644 --- a/src/main.rs +++ b/src/main.rs @@ -13,7 +13,7 @@ use std::sync::Arc; use tracing::{debug, error, info}; use tracing_subscriber::FmtSubscriber; use trader_bot::cli::Cli; -use trader_bot::domain::DesiredAllocation; +use trader_bot::domain::{DesiredAllocation, PlannedOrder, PlannedOrders}; use trader_bot::prelude::*; use trader_bot::simulator::SimulatedExchange; @@ -50,6 +50,9 @@ async fn main() { Ok(None) => { if cli.demo { info!("Running demo mode..."); + if cli.plan { + info!("PLAN MODE enabled - orders will be calculated but not executed"); + } demo_simulation(&cli).await; } else { info!("No configuration file found. Use --config to specify one, or --demo for demo mode."); @@ -68,7 +71,9 @@ async fn main() { async fn run_with_config(cli: &Cli, config: trader_bot::config::AppConfig) { info!("Starting trading bot..."); - if cli.dry_run || config.settings.dry_run { + if cli.plan { + info!("Running in PLAN MODE - orders will be calculated but not executed"); + } else if cli.dry_run || config.settings.dry_run { info!("Running in DRY-RUN mode - no actual trades will be executed"); } @@ -108,9 +113,11 @@ async fn run_with_config(cli: &Cli, config: trader_bot::config::AppConfig) { // In real implementation, we would: // 1. Create exchange adapter based on account.exchange // 2. Create balancer engine - // 3. Run rebalancing + // 3. Run rebalancing (or just plan if --plan mode) - if cli.run_once { + if cli.plan { + info!(" Would calculate rebalancing plan (plan mode)..."); + } else if cli.run_once { info!(" Would execute single rebalance..."); } else { info!( @@ -122,6 +129,9 @@ async fn run_with_config(cli: &Cli, config: trader_bot::config::AppConfig) { info!("Configuration loaded successfully. Exchange adapters are placeholders."); info!("Use --demo for a working demonstration with simulated exchange."); + if cli.plan { + info!("Use --demo --plan to see plan mode in action with simulated data."); + } } /// Demonstrates the balancer with a simulated exchange. @@ -159,61 +169,109 @@ async fn demo_simulation(cli: &Cli) { // Create balancer with CLI options let exchange = Arc::new(exchange); let order_delay = cli.order_delay.unwrap_or(100); + + // In plan mode, we always set dry_run to true to prevent execution + let effective_dry_run = cli.dry_run || cli.plan; let config = BalancerConfig { - dry_run: cli.dry_run, + dry_run: effective_dry_run, order_delay_ms: order_delay, ..BalancerConfig::default() }; let mut engine = BalancerEngine::new(Arc::clone(&exchange), config); - if cli.dry_run { + if cli.plan { + info!("PLAN MODE enabled - calculating orders without executing"); + } else if cli.dry_run { info!("DRY-RUN mode enabled - simulating without actual orders"); } - // Run initial rebalance - info!("Running initial rebalance..."); - match engine.rebalance(account_id, &target).await { - Ok(result) => { - info!("Rebalance complete!"); - info!(" Trades executed: {}", result.executed.len()); - if !result.executed.is_empty() { - for action in &result.executed { - info!( - " {} {} lots of {} @ {}", - if action.is_buy() { "BUY" } else { "SELL" }, - action.lots, - action.symbol, - action.estimated_price - ); + // In plan mode, only create the plan and display it + if cli.plan { + info!("Creating rebalancing plan..."); + match engine.create_plan(account_id, &target).await { + Ok(plan) => { + let mut planned_orders = PlannedOrders::new(); + + if plan.is_empty() { + info!("No rebalancing needed - portfolio is already balanced."); + if let Some(reason) = &plan.empty_reason { + info!("Reason: {}", reason); + } + } else { + // Convert plan actions to PlannedOrders for display + for action in &plan.actions { + let order = PlannedOrder::from_rebalance_action(action, account_id); + planned_orders.add_order(order); + } + planned_orders.set_total_buy_value(plan.total_buy_value.clone()); + planned_orders.set_total_sell_value(plan.total_sell_value.clone()); + + // Display the planned orders + planned_orders.display(); + + info!("Plan summary: {}", plan.summary()); } } + Err(e) => { + error!("Failed to create plan: {}", e); + } } - Err(e) => { - error!("Rebalance failed: {}", e); + } else { + // Normal execution mode (or dry-run mode) + info!("Running initial rebalance..."); + match engine.rebalance(account_id, &target).await { + Ok(result) => { + info!("Rebalance complete!"); + info!(" Trades executed: {}", result.executed.len()); + if !result.executed.is_empty() { + for action in &result.executed { + info!( + " {} {} lots of {} @ {}", + if action.is_buy() { "BUY" } else { "SELL" }, + action.lots, + action.symbol, + action.estimated_price + ); + } + } + } + Err(e) => { + error!("Rebalance failed: {}", e); + } } } - // Show final portfolio + // Show final portfolio (state after planning or execution) match exchange.get_wallet(account_id).await { Ok(wallet) => { - info!("Final portfolio:"); + if cli.plan { + info!("Current portfolio (unchanged - plan mode):"); + } else { + info!("Final portfolio:"); + } info!(" Cash: {}", wallet.cash()); - info!(" Positions:"); - for pos in wallet.positions() { - info!( - " {}: {} units @ {} = {}", - pos.symbol(), - pos.quantity(), - pos.current_price(), - pos.market_value() - ); + if wallet.positions().is_empty() { + info!(" Positions: none"); + } else { + info!(" Positions:"); + for pos in wallet.positions() { + info!( + " {}: {} units @ {} = {}", + pos.symbol(), + pos.quantity(), + pos.current_price(), + pos.market_value() + ); + } } info!(" Total value: {}", wallet.total_value()); let alloc = wallet.current_allocation(); - info!("Current allocation:"); - for (symbol, pct) in alloc.iter() { - info!(" {}: {:.2}%", symbol, pct); + if !alloc.is_empty() { + info!("Current allocation:"); + for (symbol, pct) in alloc.iter() { + info!(" {}: {:.2}%", symbol, pct); + } } } Err(e) => { diff --git a/tests/integration_test.rs b/tests/integration_test.rs index 618bb3d..5ab4bf7 100644 --- a/tests/integration_test.rs +++ b/tests/integration_test.rs @@ -285,3 +285,321 @@ mod version_tests { assert!(VERSION.starts_with("0.")); } } + +mod plan_mode_tests { + use super::*; + use trader_bot::domain::OrderDirection; + use trader_bot::domain::{PlannedOrder, PlannedOrders}; + use trader_bot::strategy::balancer::RebalanceAction; + + #[tokio::test] + async fn test_plan_mode_creates_plan_without_executing() { + // Create exchange with instruments + let exchange = SimulatedExchange::new("USD"); + exchange.add_instrument("AAPL", "Apple", dec!(150), 1); + exchange.add_instrument("GOOGL", "Alphabet", dec!(2800), 1); + + // Create account with cash + exchange.create_account("plan_test", dec!(100000)); + + // Define target allocation + let mut target = DesiredAllocation::new(); + target.set("AAPL", dec!(60)); + target.set("GOOGL", dec!(40)); + + // Create balancer engine + let exchange = Arc::new(exchange); + let config = BalancerConfig { + dry_run: true, // In plan mode, this should be true + order_delay_ms: 0, + ..BalancerConfig::default() + }; + let mut engine = BalancerEngine::new(Arc::clone(&exchange), config); + + // Create plan only (don't execute) + let plan = engine.create_plan("plan_test", &target).await.unwrap(); + + // Verify plan was created with actions + assert!(!plan.is_empty(), "Plan should have actions"); + assert_eq!(plan.actions.len(), 2, "Should have 2 buy actions"); + + // Verify wallet is unchanged + let wallet = exchange.get_wallet("plan_test").await.unwrap(); + assert_eq!(wallet.position_count(), 0, "Should have no positions"); + assert_eq!( + wallet.cash().amount(), + dec!(100000), + "Cash should be unchanged" + ); + } + + #[tokio::test] + async fn test_plan_mode_collects_all_orders() { + let exchange = SimulatedExchange::new("USD"); + exchange.add_instrument("A", "Stock A", dec!(100), 1); + exchange.add_instrument("B", "Stock B", dec!(50), 1); + exchange.add_instrument("C", "Stock C", dec!(200), 1); + + exchange.create_account("multi_test", dec!(10000)); + + let mut target = DesiredAllocation::new(); + target.set("A", dec!(40)); + target.set("B", dec!(30)); + target.set("C", dec!(30)); + + let exchange = Arc::new(exchange); + let config = BalancerConfig { + dry_run: true, + order_delay_ms: 0, + ..BalancerConfig::default() + }; + let mut engine = BalancerEngine::new(Arc::clone(&exchange), config); + + let plan = engine.create_plan("multi_test", &target).await.unwrap(); + + // Should have planned orders for all three assets + assert_eq!(plan.actions.len(), 3, "Should have 3 planned actions"); + assert!( + plan.total_buy_value.amount() > dec!(0), + "Should have buy value" + ); + } + + #[test] + fn test_planned_order_from_rebalance_action() { + let action = RebalanceAction { + symbol: "AAPL".to_string(), + exchange: "NASDAQ".to_string(), + direction: OrderDirection::Buy, + lots: 100, + estimated_price: Money::new(dec!(150), "USD"), + estimated_value: Money::new(dec!(15000), "USD"), + current_percent: dec!(0), + target_percent: dec!(30), + priority: 0, + }; + + let planned = PlannedOrder::from_rebalance_action(&action, "test_account"); + + assert_eq!(planned.symbol, "AAPL"); + assert_eq!(planned.direction, "BUY"); + assert_eq!(planned.lots, 100); + assert_eq!(planned.account_id, "test_account"); + assert_eq!(planned.estimated_price.amount(), dec!(150)); + assert_eq!(planned.estimated_value.amount(), dec!(15000)); + assert_eq!(planned.current_percent, dec!(0)); + assert_eq!(planned.target_percent, dec!(30)); + } + + #[test] + fn test_planned_orders_collection() { + let mut planned_orders = PlannedOrders::new(); + assert!(planned_orders.is_empty()); + + let order1 = PlannedOrder { + symbol: "AAPL".to_string(), + exchange: "NASDAQ".to_string(), + direction: "BUY".to_string(), + lots: 100, + estimated_price: Money::new(dec!(150), "USD"), + estimated_value: Money::new(dec!(15000), "USD"), + current_percent: dec!(0), + target_percent: dec!(30), + account_id: "acc1".to_string(), + reason: None, + }; + + let order2 = PlannedOrder { + symbol: "GOOGL".to_string(), + exchange: "NASDAQ".to_string(), + direction: "SELL".to_string(), + lots: 5, + estimated_price: Money::new(dec!(2800), "USD"), + estimated_value: Money::new(dec!(14000), "USD"), + current_percent: dec!(40), + target_percent: dec!(25), + account_id: "acc1".to_string(), + reason: None, + }; + + planned_orders.add_order(order1); + planned_orders.add_order(order2); + + assert!(!planned_orders.is_empty()); + assert_eq!(planned_orders.total_count(), 2); + assert_eq!(planned_orders.account_count(), 1); + + let acc1_orders = planned_orders.orders_for_account("acc1").unwrap(); + assert_eq!(acc1_orders.len(), 2); + } + + #[test] + fn test_planned_orders_multiple_accounts() { + let mut planned_orders = PlannedOrders::new(); + + let order1 = PlannedOrder { + symbol: "AAPL".to_string(), + exchange: "NASDAQ".to_string(), + direction: "BUY".to_string(), + lots: 50, + estimated_price: Money::new(dec!(150), "USD"), + estimated_value: Money::new(dec!(7500), "USD"), + current_percent: dec!(0), + target_percent: dec!(30), + account_id: "account_1".to_string(), + reason: None, + }; + + let order2 = PlannedOrder { + symbol: "MSFT".to_string(), + exchange: "NASDAQ".to_string(), + direction: "BUY".to_string(), + lots: 25, + estimated_price: Money::new(dec!(400), "USD"), + estimated_value: Money::new(dec!(10000), "USD"), + current_percent: dec!(0), + target_percent: dec!(25), + account_id: "account_2".to_string(), + reason: None, + }; + + planned_orders.add_order(order1); + planned_orders.add_order(order2); + + assert_eq!(planned_orders.account_count(), 2); + assert_eq!(planned_orders.total_count(), 2); + + assert!(planned_orders.orders_for_account("account_1").is_some()); + assert!(planned_orders.orders_for_account("account_2").is_some()); + assert!(planned_orders.orders_for_account("nonexistent").is_none()); + } + + #[tokio::test] + async fn test_plan_mode_with_already_balanced_portfolio() { + let exchange = SimulatedExchange::new("USD"); + exchange.add_instrument("A", "Stock A", dec!(100), 1); + exchange.add_instrument("B", "Stock B", dec!(100), 1); + + exchange.create_account("balanced_test", dec!(0)); + + // Add perfectly balanced positions (50/50) + exchange.add_position( + "balanced_test", + Position::builder("A", "SIMULATOR") + .quantity(dec!(50)) + .lot_size(1) + .current_price(Money::new(dec!(100), "USD")) + .build(), + ); + exchange.add_position( + "balanced_test", + Position::builder("B", "SIMULATOR") + .quantity(dec!(50)) + .lot_size(1) + .current_price(Money::new(dec!(100), "USD")) + .build(), + ); + + let mut target = DesiredAllocation::new(); + target.set("A", dec!(50)); + target.set("B", dec!(50)); + + let exchange = Arc::new(exchange); + let config = BalancerConfig { + dry_run: true, + order_delay_ms: 0, + tolerance_percent: dec!(1), + ..BalancerConfig::default() + }; + let mut engine = BalancerEngine::new(Arc::clone(&exchange), config); + + let plan = engine.create_plan("balanced_test", &target).await.unwrap(); + + // Plan should be empty as portfolio is balanced + assert!( + plan.is_empty(), + "Plan should be empty for balanced portfolio" + ); + assert!( + plan.empty_reason.is_some(), + "Should have reason for empty plan" + ); + } + + #[tokio::test] + async fn test_plan_mode_shows_sell_and_buy_orders() { + let exchange = SimulatedExchange::new("USD"); + exchange.add_instrument("A", "Stock A", dec!(100), 1); + exchange.add_instrument("B", "Stock B", dec!(100), 1); + + exchange.create_account("rebalance_test", dec!(0)); + + // Add unbalanced positions: 80% A, 20% B + exchange.add_position( + "rebalance_test", + Position::builder("A", "SIMULATOR") + .quantity(dec!(80)) + .lot_size(1) + .current_price(Money::new(dec!(100), "USD")) + .build(), + ); + exchange.add_position( + "rebalance_test", + Position::builder("B", "SIMULATOR") + .quantity(dec!(20)) + .lot_size(1) + .current_price(Money::new(dec!(100), "USD")) + .build(), + ); + + // Target: 50% A, 50% B (need to sell A, buy B) + let mut target = DesiredAllocation::new(); + target.set("A", dec!(50)); + target.set("B", dec!(50)); + + let exchange = Arc::new(exchange); + let config = BalancerConfig { + dry_run: true, + order_delay_ms: 0, + tolerance_percent: dec!(1), + ..BalancerConfig::default() + }; + let mut engine = BalancerEngine::new(Arc::clone(&exchange), config); + + let plan = engine.create_plan("rebalance_test", &target).await.unwrap(); + + // Should have both sell and buy actions + assert!(!plan.is_empty()); + + let has_sell = plan.actions.iter().any(|a| a.is_sell()); + let has_buy = plan.actions.iter().any(|a| a.is_buy()); + + assert!(has_sell, "Should have sell action for A"); + assert!(has_buy, "Should have buy action for B"); + } + + #[test] + fn test_planned_order_display_format() { + let order = PlannedOrder { + symbol: "AAPL".to_string(), + exchange: "NASDAQ".to_string(), + direction: "BUY".to_string(), + lots: 100, + estimated_price: Money::new(dec!(150), "USD"), + estimated_value: Money::new(dec!(15000), "USD"), + current_percent: dec!(10), + target_percent: dec!(30), + account_id: "test".to_string(), + reason: None, + }; + + let display = format!("{order}"); + assert!(display.contains("BUY")); + assert!(display.contains("100 lots")); + assert!(display.contains("AAPL")); + assert!(display.contains("150")); + assert!(display.contains("15000")); + assert!(display.contains("10.00%")); + assert!(display.contains("30.00%")); + } +}