Null Behaviour
Metric Engine provides sophisticated null handling that lets you control how missing, invalid, or undefined values are processed throughout your financial calculations. This system ensures predictable behavior when dealing with incomplete datasets or calculation errors.
What is Null Behaviour?
Null behaviour defines how Metric Engine handles None values and invalid data in two key contexts:
Binary Operations - Arithmetic between two values (
a + b,a * b, etc.)Reduction Operations - Aggregations over collections (
sum(),mean(), etc.)
The system is context-aware, thread-safe, and provides different strategies for different use cases, from strict data validation to flexible data analysis.
Core Concepts
Binary Operations
Control how None values are handled in arithmetic operations:
from metricengine import money, NullBinaryMode, use_binary
# Default behavior: PROPAGATE - None spreads through calculations
amount = money(100)
invalid = money(None)
result = amount + invalid # Returns None (safe)
# RAISE mode: Fail fast on None values
with use_binary(NullBinaryMode.RAISE):
try:
result = amount + invalid # Raises CalculationError
except CalculationError:
print("Calculation failed due to None value")
Reduction Operations
Control how None values are handled in aggregation operations:
from metricengine import money, fv_sum, NullReductionMode, use_reduction
# Sample data with missing values
revenues = [money(1000), money(None), money(1500), money(2000)]
# SKIP mode (default): Ignore None values
with use_reduction(NullReductionMode.SKIP):
total = fv_sum(revenues) # $4,500 (skips None)
# PROPAGATE mode: None if any value is None
with use_reduction(NullReductionMode.PROPAGATE):
total = fv_sum(revenues) # None (any None makes result None)
# ZERO mode: Treat None as zero
with use_reduction(NullReductionMode.ZERO):
total = fv_sum(revenues) # $4,500 (None becomes 0)
# RAISE mode: Fail on any None
with use_reduction(NullReductionMode.RAISE):
try:
total = fv_sum(revenues) # Raises CalculationError
except CalculationError:
print("Cannot sum data containing None values")
Null Modes Explained
Binary Operation Modes
PROPAGATE (Default)
Safe mode where any None operand results in None:
from metricengine import money
# Any None operand makes the result None
valid_amount = money(100)
invalid_amount = money(None)
result1 = valid_amount + invalid_amount # None
result2 = valid_amount * invalid_amount # None
result3 = invalid_amount / valid_amount # None
print(result1.is_none()) # True
RAISE
Strict mode that raises exceptions when encountering None:
from metricengine import use_binary, NullBinaryMode, CalculationError
with use_binary(NullBinaryMode.RAISE):
try:
result = money(100) + money(None) # Raises CalculationError
except CalculationError as e:
print(f"Calculation failed: {e}")
Reduction Operation Modes
SKIP (Default)
Ignores None values and processes only valid values:
from metricengine import fv_sum, fv_mean
data = [money(100), money(None), money(200), money(300)]
total = fv_sum(data) # $600 (skips None)
average = fv_mean(data) # $200 (600/3, skips None)
PROPAGATE
Returns None if any None values are present:
from metricengine import use_reduction, NullReductionMode
with use_reduction(NullReductionMode.PROPAGATE):
clean_data = [money(100), money(200), money(300)]
dirty_data = [money(100), money(None), money(300)]
clean_total = fv_sum(clean_data) # $600
dirty_total = fv_sum(dirty_data) # None
ZERO
Treats None values as zero for calculations:
with use_reduction(NullReductionMode.ZERO):
data = [money(100), money(None), money(200)]
total = fv_sum(data) # $300 (None becomes $0)
average = fv_mean(data) # $100 (300/3, None counted as 0)
RAISE
Raises exceptions when encountering None values:
with use_reduction(NullReductionMode.RAISE):
try:
data = [money(100), money(None), money(200)]
total = fv_sum(data) # Raises CalculationError
except CalculationError:
print("Strict mode: no None values allowed")
Context Management
Combined Null Behavior
Set both binary and reduction modes together:
from metricengine import NullBehavior, use_nulls
# Create combined behavior
strict_behavior = NullBehavior(
binary=NullBinaryMode.RAISE,
reduction=NullReductionMode.RAISE
)
# Apply to entire calculation block
with use_nulls(strict_behavior):
# All operations will raise on None
amount1 = money(100)
amount2 = money(200)
amounts = [amount1, amount2, money(300)]
subtotal = amount1 + amount2 # Works: no None values
total = fv_sum(amounts) # Works: no None values
# Any None would raise CalculationError
Selective Mode Override
Override just one mode temporarily:
from metricengine import use_binary, use_reduction
# Override just binary mode
with use_binary(NullBinaryMode.RAISE):
# Binary ops raise on None, but reductions still use default (SKIP)
result = money(100) + money(200) # Works
data_with_none = [money(100), money(None), money(200)]
total = fv_sum(data_with_none) # $300 (skips None)
# Override just reduction mode
with use_reduction(NullReductionMode.ZERO):
# Reductions treat None as zero, binary ops still propagate None
mixed_result = money(100) + money(None) # None (propagates)
data_with_none = [money(100), money(None), money(200)]
total = fv_sum(data_with_none) # $300 (None as zero)
Predefined Behaviors
Metric Engine provides convenient predefined behaviors for common scenarios:
DEFAULT_NULLS
Standard safe behavior (binary: PROPAGATE, reduction: SKIP):
from metricengine import DEFAULT_NULLS, use_nulls
with use_nulls(DEFAULT_NULLS):
# Safe binary operations
safe_result = money(100) + money(None) # None
# Skip None in reductions
data = [money(100), money(None), money(200)]
total = fv_sum(data) # $300
STRICT_RAISE
Fail-fast on any None (binary: RAISE, reduction: RAISE):
from metricengine import STRICT_RAISE
with use_nulls(STRICT_RAISE):
# Both binary ops and reductions raise on None
try:
result = money(100) + money(None) # Raises
except CalculationError:
print("No None values allowed anywhere")
Specialized Reduction Behaviors
from metricengine import SUM_ZERO, SUM_PROPAGATE, SUM_RAISE
# Treat None as zero in reductions
with use_nulls(SUM_ZERO):
total = fv_sum([money(100), money(None), money(200)]) # $300
# Propagate None in reductions
with use_nulls(SUM_PROPAGATE):
total = fv_sum([money(100), money(None), money(200)]) # None
# Raise on None in reductions
with use_nulls(SUM_RAISE):
try:
total = fv_sum([money(100), money(None), money(200)]) # Raises
except CalculationError:
print("Reduction failed due to None")
Real-World Applications
Data Cleaning Pipeline
from metricengine import use_reduction, NullReductionMode, fv_sum, fv_mean
# Raw data with missing values
quarterly_revenues = [
money(250_000), # Q1
money(None), # Q2 - missing data
money(280_000), # Q3
money(310_000) # Q4
]
# Skip missing data for summary statistics
with use_reduction(NullReductionMode.SKIP):
total_revenue = fv_sum(quarterly_revenues) # $840,000
average_revenue = fv_mean(quarterly_revenues) # $280,000 (3 quarters)
print(f"Total revenue (3 quarters): {total_revenue}")
print(f"Average revenue: {average_revenue}")
# Conservative analysis: treat missing as zero revenue
with use_reduction(NullReductionMode.ZERO):
conservative_total = fv_sum(quarterly_revenues) # $840,000
conservative_average = fv_mean(quarterly_revenues) # $210,000 (4 quarters)
print(f"Conservative average: {conservative_average}")
Financial Validation
def validate_financial_data(revenues, expenses):
"""Strict validation requiring complete data."""
with use_nulls(STRICT_RAISE):
try:
# All operations will fail if any None present
total_revenue = fv_sum(revenues)
total_expenses = fv_sum(expenses)
profit = total_revenue - total_expenses
margin = (profit / total_revenue).as_percentage()
return {
'revenue': total_revenue,
'expenses': total_expenses,
'profit': profit,
'margin': margin
}
except CalculationError as e:
raise ValueError(f"Data validation failed: {e}")
# Example usage
try:
results = validate_financial_data(
revenues=[money(100_000), money(120_000), money(None)],
expenses=[money(80_000), money(90_000), money(85_000)]
)
except ValueError as e:
print(f"Validation error: {e}") # Fails due to None revenue
Scenario Analysis
def analyze_scenarios(base_revenue, growth_rates):
"""Analyze revenue scenarios, handling invalid growth rates gracefully."""
scenarios = []
# Use SKIP to ignore invalid scenarios
with use_reduction(NullReductionMode.SKIP):
for i, rate in enumerate(growth_rates):
if rate is None:
continue
# Binary operations propagate None safely
projected_revenue = base_revenue * (1 + rate)
if not projected_revenue.is_none():
scenarios.append({
'scenario': i + 1,
'growth_rate': rate.as_percentage() if hasattr(rate, 'as_percentage') else f"{rate*100}%",
'projected_revenue': projected_revenue
})
if scenarios:
revenues = [s['projected_revenue'] for s in scenarios]
avg_projection = fv_mean(revenues)
return {
'scenarios': scenarios,
'average_projection': avg_projection
}
return {'scenarios': [], 'average_projection': None}
# Example with mixed valid/invalid data
from metricengine import percent
base = money(1_000_000)
growth_scenarios = [
percent(5, input="percent"), # 5% growth
None, # Invalid scenario
percent(10, input="percent"), # 10% growth
percent(-2, input="percent") # -2% decline
]
results = analyze_scenarios(base, growth_scenarios)
print(f"Average projection: {results['average_projection']}")
Portfolio Risk Analysis
def calculate_portfolio_metrics(returns_data):
"""Calculate portfolio metrics with different null handling for different purposes."""
# For mean return: skip missing data points
with use_reduction(NullReductionMode.SKIP):
mean_return = fv_mean(returns_data)
# For risk assessment: missing data means unknown risk (propagate None)
with use_reduction(NullReductionMode.PROPAGATE):
# This would return None if any returns are missing
risk_assessment = fv_mean(returns_data) if not any(r.is_none() for r in returns_data if hasattr(r, 'is_none')) else None
# For conservative total: treat missing as zero return
with use_reduction(NullReductionMode.ZERO):
conservative_total = fv_sum(returns_data)
return {
'mean_return': mean_return,
'risk_assessment': risk_assessment,
'conservative_total': conservative_total
}
# Portfolio with some missing data
monthly_returns = [
percent(2.5, input="percent"), # Jan: +2.5%
percent(None), # Feb: missing data
percent(-1.2, input="percent"), # Mar: -1.2%
percent(3.8, input="percent") # Apr: +3.8%
]
metrics = calculate_portfolio_metrics(monthly_returns)
print(f"Mean return (excluding missing): {metrics['mean_return']}")
print(f"Conservative total return: {metrics['conservative_total']}")
Advanced Patterns
Decorator-Based Null Handling
from metricengine import with_nulls
@with_nulls(STRICT_RAISE)
def calculate_roi(investment, returns):
"""Calculate ROI with strict validation."""
total_return = fv_sum(returns)
roi = (total_return / investment).as_percentage()
return roi
# Function will raise on any None values
try:
roi = calculate_roi(
money(100_000),
[money(15_000), money(None), money(20_000)] # Contains None
)
except CalculationError:
print("ROI calculation requires complete data")
Conditional Null Handling
def adaptive_calculation(data, strict_mode=False):
"""Adapt null handling based on context."""
behavior = STRICT_RAISE if strict_mode else DEFAULT_NULLS
with use_nulls(behavior):
try:
return fv_sum(data)
except CalculationError:
if strict_mode:
raise
# Fallback to lenient mode
with use_nulls(DEFAULT_NULLS):
return fv_sum(data)
# Strict mode
try:
result = adaptive_calculation([money(100), money(None)], strict_mode=True)
except CalculationError:
print("Strict mode failed")
# Lenient mode
result = adaptive_calculation([money(100), money(None)], strict_mode=False)
print(f"Lenient result: {result}") # $100
Best Practices
1. Choose Appropriate Modes for Your Use Case
# Data analysis: Skip missing values
with use_reduction(NullReductionMode.SKIP):
clean_average = fv_mean(noisy_data)
# Risk assessment: Propagate uncertainty
with use_reduction(NullReductionMode.PROPAGATE):
risk_metric = calculate_risk(uncertain_data)
# Validation: Fail on incomplete data
with use_nulls(STRICT_RAISE):
validated_result = process_critical_data(complete_data)
2. Document Null Handling Decisions
class FinancialCalculator:
"""Financial calculator with explicit null handling policies."""
def calculate_revenue_metrics(self, revenue_data):
"""
Calculate revenue metrics.
Null handling: Skips missing data points for summary statistics,
as partial data is still meaningful for trend analysis.
"""
with use_reduction(NullReductionMode.SKIP):
return {
'total': fv_sum(revenue_data),
'average': fv_mean(revenue_data),
'count': len([x for x in revenue_data if not x.is_none()])
}
def validate_regulatory_report(self, financial_data):
"""
Validate data for regulatory reporting.
Null handling: Strict mode - all data must be complete
for regulatory compliance.
"""
with use_nulls(STRICT_RAISE):
return self._process_regulatory_data(financial_data)
3. Handle Edge Cases
def safe_division_with_null_handling(numerator_data, denominator_data):
"""Safely divide datasets with proper null handling."""
with use_reduction(NullReductionMode.SKIP):
numerator_sum = fv_sum(numerator_data)
denominator_sum = fv_sum(denominator_data)
# Check for division by zero or None
if denominator_sum.is_none() or denominator_sum.as_decimal() == 0:
return money(None) # Return None for invalid division
if numerator_sum.is_none():
return money(None)
return numerator_sum / denominator_sum
# Example usage
ratios = safe_division_with_null_handling(
[money(100), money(None), money(200)], # Numerator: partial data
[money(50), money(75), money(25)] # Denominator: complete data
)
4. Thread Safety Considerations
import threading
from metricengine import use_nulls, DEFAULT_NULLS
def worker_function(data, thread_id):
"""Each thread can have its own null handling context."""
# Each thread gets its own context
behavior = DEFAULT_NULLS if thread_id % 2 == 0 else STRICT_RAISE
with use_nulls(behavior):
return fv_sum(data)
# Multiple threads with different null handling
threads = []
for i in range(4):
thread = threading.Thread(
target=worker_function,
args=([money(100), money(None)], i)
)
threads.append(thread)
thread.start()
Metric Engine’s null behaviour system provides the flexibility to handle missing and invalid data appropriately for your specific use case, whether you need strict validation, graceful error handling, or flexible data analysis capabilities.