mirror of
https://github.com/outbackdingo/optimclaw.git
synced 2026-09-03 10:09:30 +00:00
Initial implementation of the agent framework
This commit is contained in:
@@ -0,0 +1,88 @@
|
||||
//! Cost estimation.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use rust_decimal::Decimal;
|
||||
use rust_decimal_macros::dec;
|
||||
|
||||
/// Estimates costs for tools and operations.
|
||||
pub struct CostEstimator {
|
||||
/// Base costs per tool.
|
||||
tool_costs: HashMap<String, Decimal>,
|
||||
/// LLM cost per 1K tokens.
|
||||
llm_cost_per_1k: Decimal,
|
||||
}
|
||||
|
||||
impl CostEstimator {
|
||||
/// Create a new cost estimator.
|
||||
pub fn new() -> Self {
|
||||
let mut tool_costs = HashMap::new();
|
||||
|
||||
// Default tool costs (in USD or equivalent)
|
||||
tool_costs.insert("http".to_string(), dec!(0.0001)); // API call
|
||||
tool_costs.insert("marketplace".to_string(), dec!(0.01)); // Gas costs
|
||||
tool_costs.insert("ecommerce".to_string(), dec!(0.001)); // API call
|
||||
tool_costs.insert("taskrabbit".to_string(), dec!(0.0)); // Cost comes from task itself
|
||||
tool_costs.insert("restaurant".to_string(), dec!(0.001)); // API call
|
||||
tool_costs.insert("echo".to_string(), dec!(0.0)); // Free
|
||||
tool_costs.insert("time".to_string(), dec!(0.0)); // Free
|
||||
tool_costs.insert("json".to_string(), dec!(0.0)); // Free
|
||||
|
||||
Self {
|
||||
tool_costs,
|
||||
llm_cost_per_1k: dec!(0.01), // Approximate
|
||||
}
|
||||
}
|
||||
|
||||
/// Estimate cost for a tool call.
|
||||
pub fn estimate_tool(&self, tool_name: &str) -> Decimal {
|
||||
self.tool_costs
|
||||
.get(tool_name)
|
||||
.copied()
|
||||
.unwrap_or(dec!(0.001)) // Default for unknown tools
|
||||
}
|
||||
|
||||
/// Estimate LLM cost for tokens.
|
||||
pub fn estimate_llm_tokens(&self, input_tokens: u32, output_tokens: u32) -> Decimal {
|
||||
let total_tokens = Decimal::from(input_tokens + output_tokens);
|
||||
(total_tokens / dec!(1000)) * self.llm_cost_per_1k
|
||||
}
|
||||
|
||||
/// Set a tool's base cost.
|
||||
pub fn set_tool_cost(&mut self, tool_name: impl Into<String>, cost: Decimal) {
|
||||
self.tool_costs.insert(tool_name.into(), cost);
|
||||
}
|
||||
|
||||
/// Get all tool costs.
|
||||
pub fn all_tool_costs(&self) -> &HashMap<String, Decimal> {
|
||||
&self.tool_costs
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for CostEstimator {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_tool_cost_estimation() {
|
||||
let estimator = CostEstimator::new();
|
||||
|
||||
assert_eq!(estimator.estimate_tool("echo"), dec!(0.0));
|
||||
assert_eq!(estimator.estimate_tool("marketplace"), dec!(0.01));
|
||||
assert!(estimator.estimate_tool("unknown") > dec!(0.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_llm_cost_estimation() {
|
||||
let estimator = CostEstimator::new();
|
||||
|
||||
let cost = estimator.estimate_llm_tokens(1000, 500);
|
||||
assert!(cost > dec!(0.0));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
//! Statistical learning for estimation improvement.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::time::Duration;
|
||||
|
||||
use rust_decimal::Decimal;
|
||||
|
||||
/// Learning model for estimation adjustments.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct LearningModel {
|
||||
/// Cost adjustment factor (multiplier).
|
||||
pub cost_factor: f64,
|
||||
/// Time adjustment factor (multiplier).
|
||||
pub time_factor: f64,
|
||||
/// Number of samples.
|
||||
pub sample_count: u64,
|
||||
/// Running error rate for cost.
|
||||
pub cost_error_rate: f64,
|
||||
/// Running error rate for time.
|
||||
pub time_error_rate: f64,
|
||||
}
|
||||
|
||||
impl Default for LearningModel {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
cost_factor: 1.0,
|
||||
time_factor: 1.0,
|
||||
sample_count: 0,
|
||||
cost_error_rate: 0.0,
|
||||
time_error_rate: 0.0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Learner that improves estimates over time.
|
||||
pub struct EstimationLearner {
|
||||
/// Models per category.
|
||||
models: HashMap<String, LearningModel>,
|
||||
/// Exponential moving average alpha.
|
||||
alpha: f64,
|
||||
/// Minimum samples before adjusting.
|
||||
min_samples: u64,
|
||||
}
|
||||
|
||||
impl EstimationLearner {
|
||||
/// Create a new estimation learner.
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
models: HashMap::new(),
|
||||
alpha: 0.1, // EMA smoothing factor
|
||||
min_samples: 5,
|
||||
}
|
||||
}
|
||||
|
||||
/// Record actual results and update the model.
|
||||
pub fn record(
|
||||
&mut self,
|
||||
category: &str,
|
||||
estimated_cost: Decimal,
|
||||
actual_cost: Decimal,
|
||||
estimated_time: Duration,
|
||||
actual_time: Duration,
|
||||
) {
|
||||
let model = self.models.entry(category.to_string()).or_default();
|
||||
model.sample_count += 1;
|
||||
|
||||
// Calculate errors
|
||||
let cost_ratio = if !estimated_cost.is_zero() {
|
||||
(actual_cost / estimated_cost)
|
||||
.to_string()
|
||||
.parse::<f64>()
|
||||
.unwrap_or(1.0)
|
||||
} else {
|
||||
1.0
|
||||
};
|
||||
|
||||
let time_ratio = if !estimated_time.is_zero() {
|
||||
actual_time.as_secs_f64() / estimated_time.as_secs_f64()
|
||||
} else {
|
||||
1.0
|
||||
};
|
||||
|
||||
// Update factors using exponential moving average
|
||||
model.cost_factor = model.cost_factor * (1.0 - self.alpha) + cost_ratio * self.alpha;
|
||||
model.time_factor = model.time_factor * (1.0 - self.alpha) + time_ratio * self.alpha;
|
||||
|
||||
// Update error rates
|
||||
let cost_error = (cost_ratio - 1.0).abs();
|
||||
let time_error = (time_ratio - 1.0).abs();
|
||||
|
||||
model.cost_error_rate =
|
||||
model.cost_error_rate * (1.0 - self.alpha) + cost_error * self.alpha;
|
||||
model.time_error_rate =
|
||||
model.time_error_rate * (1.0 - self.alpha) + time_error * self.alpha;
|
||||
}
|
||||
|
||||
/// Adjust estimates based on learned factors.
|
||||
pub fn adjust(&self, category: &str, cost: Decimal, time: Duration) -> (Decimal, Duration) {
|
||||
let model = self.models.get(category);
|
||||
|
||||
match model {
|
||||
Some(m) if m.sample_count >= self.min_samples => {
|
||||
let adjusted_cost = cost * Decimal::try_from(m.cost_factor).unwrap_or(Decimal::ONE);
|
||||
let adjusted_time = Duration::from_secs_f64(time.as_secs_f64() * m.time_factor);
|
||||
(adjusted_cost, adjusted_time)
|
||||
}
|
||||
_ => (cost, time), // Not enough data, use original estimates
|
||||
}
|
||||
}
|
||||
|
||||
/// Get confidence for a category (based on sample count and error rate).
|
||||
pub fn confidence(&self, category: &str) -> f64 {
|
||||
match self.models.get(category) {
|
||||
Some(m) if m.sample_count >= self.min_samples => {
|
||||
// Higher samples and lower error = higher confidence
|
||||
let sample_factor = (m.sample_count as f64 / 100.0).min(1.0);
|
||||
let error_factor = 1.0 - ((m.cost_error_rate + m.time_error_rate) / 2.0).min(1.0);
|
||||
0.5 + (sample_factor * 0.3) + (error_factor * 0.2)
|
||||
}
|
||||
Some(_) => 0.3, // Some data but not enough
|
||||
None => 0.2, // No data
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the model for a category.
|
||||
pub fn get_model(&self, category: &str) -> Option<&LearningModel> {
|
||||
self.models.get(category)
|
||||
}
|
||||
|
||||
/// Get all models.
|
||||
pub fn all_models(&self) -> &HashMap<String, LearningModel> {
|
||||
&self.models
|
||||
}
|
||||
|
||||
/// Set the EMA alpha.
|
||||
pub fn set_alpha(&mut self, alpha: f64) {
|
||||
self.alpha = alpha.clamp(0.01, 0.5);
|
||||
}
|
||||
|
||||
/// Set minimum samples.
|
||||
pub fn set_min_samples(&mut self, min: u64) {
|
||||
self.min_samples = min;
|
||||
}
|
||||
|
||||
/// Clear all learned data.
|
||||
pub fn clear(&mut self) {
|
||||
self.models.clear();
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for EstimationLearner {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use rust_decimal_macros::dec;
|
||||
|
||||
#[test]
|
||||
fn test_learning_model_update() {
|
||||
let mut learner = EstimationLearner::new();
|
||||
learner.set_min_samples(2);
|
||||
|
||||
// Record some results where actuals are 20% higher than estimates
|
||||
for _ in 0..5 {
|
||||
learner.record(
|
||||
"test",
|
||||
dec!(100.0),
|
||||
dec!(120.0),
|
||||
Duration::from_secs(60),
|
||||
Duration::from_secs(72),
|
||||
);
|
||||
}
|
||||
|
||||
let model = learner.get_model("test").unwrap();
|
||||
assert!(model.cost_factor > 1.0);
|
||||
assert!(model.time_factor > 1.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_adjustment() {
|
||||
let mut learner = EstimationLearner::new();
|
||||
learner.set_min_samples(2);
|
||||
|
||||
// Train with consistent 50% underestimation
|
||||
for _ in 0..10 {
|
||||
learner.record(
|
||||
"test",
|
||||
dec!(100.0),
|
||||
dec!(150.0),
|
||||
Duration::from_secs(60),
|
||||
Duration::from_secs(90),
|
||||
);
|
||||
}
|
||||
|
||||
let (adjusted_cost, adjusted_time) =
|
||||
learner.adjust("test", dec!(100.0), Duration::from_secs(60));
|
||||
|
||||
// Should adjust upward
|
||||
assert!(adjusted_cost > dec!(100.0));
|
||||
assert!(adjusted_time > Duration::from_secs(60));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_confidence() {
|
||||
let mut learner = EstimationLearner::new();
|
||||
|
||||
// No data = low confidence
|
||||
assert!(learner.confidence("unknown") < 0.5);
|
||||
|
||||
// Add data
|
||||
for _ in 0..20 {
|
||||
learner.record(
|
||||
"known",
|
||||
dec!(100.0),
|
||||
dec!(100.0), // Perfect estimates
|
||||
Duration::from_secs(60),
|
||||
Duration::from_secs(60),
|
||||
);
|
||||
}
|
||||
|
||||
// More data with good accuracy = higher confidence
|
||||
assert!(learner.confidence("known") > 0.5);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
//! Cost, time, and value estimation with continuous learning.
|
||||
//!
|
||||
//! Estimates are based on:
|
||||
//! - Historical data from similar jobs
|
||||
//! - Tool cost/time characteristics
|
||||
//! - Statistical models that improve over time
|
||||
|
||||
mod cost;
|
||||
mod learner;
|
||||
mod time;
|
||||
mod value;
|
||||
|
||||
pub use cost::CostEstimator;
|
||||
pub use learner::{EstimationLearner, LearningModel};
|
||||
pub use time::TimeEstimator;
|
||||
pub use value::ValueEstimator;
|
||||
|
||||
use rust_decimal::Decimal;
|
||||
use std::time::Duration;
|
||||
|
||||
/// Combined estimation for a job.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct JobEstimate {
|
||||
/// Estimated cost to complete the job.
|
||||
pub cost: Decimal,
|
||||
/// Estimated time to complete.
|
||||
pub duration: Duration,
|
||||
/// Estimated value/earnings.
|
||||
pub value: Decimal,
|
||||
/// Confidence in the estimate (0-1).
|
||||
pub confidence: f64,
|
||||
/// Breakdown by tool.
|
||||
pub tool_breakdown: Vec<ToolEstimate>,
|
||||
}
|
||||
|
||||
/// Estimate for a single tool usage.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ToolEstimate {
|
||||
pub tool_name: String,
|
||||
pub cost: Decimal,
|
||||
pub duration: Duration,
|
||||
pub confidence: f64,
|
||||
}
|
||||
|
||||
/// Combined estimator.
|
||||
pub struct Estimator {
|
||||
cost: CostEstimator,
|
||||
time: TimeEstimator,
|
||||
value: ValueEstimator,
|
||||
learner: EstimationLearner,
|
||||
}
|
||||
|
||||
impl Estimator {
|
||||
/// Create a new estimator.
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
cost: CostEstimator::new(),
|
||||
time: TimeEstimator::new(),
|
||||
value: ValueEstimator::new(),
|
||||
learner: EstimationLearner::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Estimate for a job.
|
||||
pub fn estimate_job(
|
||||
&self,
|
||||
description: &str,
|
||||
category: Option<&str>,
|
||||
tools: &[String],
|
||||
) -> JobEstimate {
|
||||
let tool_estimates: Vec<ToolEstimate> = tools
|
||||
.iter()
|
||||
.map(|t| ToolEstimate {
|
||||
tool_name: t.clone(),
|
||||
cost: self.cost.estimate_tool(t),
|
||||
duration: self.time.estimate_tool(t),
|
||||
confidence: 0.7, // Default confidence
|
||||
})
|
||||
.collect();
|
||||
|
||||
let total_cost: Decimal = tool_estimates.iter().map(|e| e.cost).sum();
|
||||
let total_duration: Duration = tool_estimates.iter().map(|e| e.duration).sum();
|
||||
|
||||
// Apply learned adjustments
|
||||
let (adjusted_cost, adjusted_time) =
|
||||
self.learner
|
||||
.adjust(category.unwrap_or("general"), total_cost, total_duration);
|
||||
|
||||
let value = self.value.estimate(description, adjusted_cost);
|
||||
let confidence = self.learner.confidence(category.unwrap_or("general"));
|
||||
|
||||
JobEstimate {
|
||||
cost: adjusted_cost,
|
||||
duration: adjusted_time,
|
||||
value,
|
||||
confidence,
|
||||
tool_breakdown: tool_estimates,
|
||||
}
|
||||
}
|
||||
|
||||
/// Record actual results for learning.
|
||||
pub fn record_actuals(
|
||||
&mut self,
|
||||
category: &str,
|
||||
estimated_cost: Decimal,
|
||||
actual_cost: Decimal,
|
||||
estimated_time: Duration,
|
||||
actual_time: Duration,
|
||||
) {
|
||||
self.learner.record(
|
||||
category,
|
||||
estimated_cost,
|
||||
actual_cost,
|
||||
estimated_time,
|
||||
actual_time,
|
||||
);
|
||||
}
|
||||
|
||||
/// Get the cost estimator.
|
||||
pub fn cost(&self) -> &CostEstimator {
|
||||
&self.cost
|
||||
}
|
||||
|
||||
/// Get the time estimator.
|
||||
pub fn time(&self) -> &TimeEstimator {
|
||||
&self.time
|
||||
}
|
||||
|
||||
/// Get the value estimator.
|
||||
pub fn value(&self) -> &ValueEstimator {
|
||||
&self.value
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for Estimator {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
//! Time estimation.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::time::Duration;
|
||||
|
||||
/// Estimates time for tools and operations.
|
||||
pub struct TimeEstimator {
|
||||
/// Base durations per tool.
|
||||
tool_durations: HashMap<String, Duration>,
|
||||
}
|
||||
|
||||
impl TimeEstimator {
|
||||
/// Create a new time estimator.
|
||||
pub fn new() -> Self {
|
||||
let mut tool_durations = HashMap::new();
|
||||
|
||||
// Default tool durations
|
||||
tool_durations.insert("http".to_string(), Duration::from_secs(5));
|
||||
tool_durations.insert("marketplace".to_string(), Duration::from_secs(10));
|
||||
tool_durations.insert("ecommerce".to_string(), Duration::from_secs(8));
|
||||
tool_durations.insert("taskrabbit".to_string(), Duration::from_secs(30)); // Just API, not task itself
|
||||
tool_durations.insert("restaurant".to_string(), Duration::from_secs(5));
|
||||
tool_durations.insert("echo".to_string(), Duration::from_millis(10));
|
||||
tool_durations.insert("time".to_string(), Duration::from_millis(1));
|
||||
tool_durations.insert("json".to_string(), Duration::from_millis(5));
|
||||
|
||||
Self { tool_durations }
|
||||
}
|
||||
|
||||
/// Estimate duration for a tool call.
|
||||
pub fn estimate_tool(&self, tool_name: &str) -> Duration {
|
||||
self.tool_durations
|
||||
.get(tool_name)
|
||||
.copied()
|
||||
.unwrap_or(Duration::from_secs(5)) // Default for unknown tools
|
||||
}
|
||||
|
||||
/// Estimate LLM response time.
|
||||
pub fn estimate_llm_response(&self, estimated_tokens: u32) -> Duration {
|
||||
// Rough estimate: ~50 tokens/second
|
||||
let seconds = estimated_tokens as f64 / 50.0;
|
||||
Duration::from_secs_f64(seconds.max(1.0))
|
||||
}
|
||||
|
||||
/// Set a tool's base duration.
|
||||
pub fn set_tool_duration(&mut self, tool_name: impl Into<String>, duration: Duration) {
|
||||
self.tool_durations.insert(tool_name.into(), duration);
|
||||
}
|
||||
|
||||
/// Get all tool durations.
|
||||
pub fn all_tool_durations(&self) -> &HashMap<String, Duration> {
|
||||
&self.tool_durations
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for TimeEstimator {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_tool_time_estimation() {
|
||||
let estimator = TimeEstimator::new();
|
||||
|
||||
assert!(estimator.estimate_tool("echo") < Duration::from_secs(1));
|
||||
assert!(estimator.estimate_tool("http") >= Duration::from_secs(1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_llm_time_estimation() {
|
||||
let estimator = TimeEstimator::new();
|
||||
|
||||
let duration = estimator.estimate_llm_response(500);
|
||||
assert!(duration >= Duration::from_secs(1));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
//! Value/earnings estimation.
|
||||
|
||||
use rust_decimal::Decimal;
|
||||
use rust_decimal_macros::dec;
|
||||
|
||||
/// Estimates the value/earnings potential of jobs.
|
||||
pub struct ValueEstimator {
|
||||
/// Minimum profit margin to aim for.
|
||||
min_margin: Decimal,
|
||||
/// Target profit margin.
|
||||
target_margin: Decimal,
|
||||
}
|
||||
|
||||
impl ValueEstimator {
|
||||
/// Create a new value estimator.
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
min_margin: dec!(0.1), // 10% minimum
|
||||
target_margin: dec!(0.3), // 30% target
|
||||
}
|
||||
}
|
||||
|
||||
/// Estimate value for a job based on description and cost.
|
||||
pub fn estimate(&self, _description: &str, estimated_cost: Decimal) -> Decimal {
|
||||
// Simple formula: value = cost + margin
|
||||
// In practice, this would analyze the description to estimate complexity
|
||||
let margin = estimated_cost * self.target_margin;
|
||||
estimated_cost + margin
|
||||
}
|
||||
|
||||
/// Calculate minimum acceptable bid.
|
||||
pub fn minimum_bid(&self, estimated_cost: Decimal) -> Decimal {
|
||||
estimated_cost + (estimated_cost * self.min_margin)
|
||||
}
|
||||
|
||||
/// Calculate ideal bid.
|
||||
pub fn ideal_bid(&self, estimated_cost: Decimal) -> Decimal {
|
||||
estimated_cost + (estimated_cost * self.target_margin)
|
||||
}
|
||||
|
||||
/// Check if a job is profitable at a given price.
|
||||
pub fn is_profitable(&self, price: Decimal, estimated_cost: Decimal) -> bool {
|
||||
let margin = (price - estimated_cost) / price;
|
||||
margin >= self.min_margin
|
||||
}
|
||||
|
||||
/// Calculate profit for a completed job.
|
||||
pub fn calculate_profit(&self, earnings: Decimal, actual_cost: Decimal) -> Decimal {
|
||||
earnings - actual_cost
|
||||
}
|
||||
|
||||
/// Calculate profit margin.
|
||||
pub fn calculate_margin(&self, earnings: Decimal, actual_cost: Decimal) -> Decimal {
|
||||
if earnings.is_zero() {
|
||||
return Decimal::ZERO;
|
||||
}
|
||||
(earnings - actual_cost) / earnings
|
||||
}
|
||||
|
||||
/// Set minimum margin.
|
||||
pub fn set_min_margin(&mut self, margin: Decimal) {
|
||||
self.min_margin = margin;
|
||||
}
|
||||
|
||||
/// Set target margin.
|
||||
pub fn set_target_margin(&mut self, margin: Decimal) {
|
||||
self.target_margin = margin;
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for ValueEstimator {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_value_estimation() {
|
||||
let estimator = ValueEstimator::new();
|
||||
|
||||
let cost = dec!(10.0);
|
||||
let value = estimator.estimate("test job", cost);
|
||||
|
||||
assert!(value > cost);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_profitability() {
|
||||
let estimator = ValueEstimator::new();
|
||||
|
||||
let cost = dec!(10.0);
|
||||
assert!(estimator.is_profitable(dec!(15.0), cost));
|
||||
assert!(!estimator.is_profitable(dec!(10.5), cost)); // Only 5% margin
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_margin_calculation() {
|
||||
let estimator = ValueEstimator::new();
|
||||
|
||||
let margin = estimator.calculate_margin(dec!(100.0), dec!(70.0));
|
||||
assert_eq!(margin, dec!(0.30)); // 30%
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user