"""Tests for Australian CGT Calculator. Issue #32: [PORT-31] Australian CGT calculator - 50% discount, tax reports Tests cover: - CGT enums and dataclasses - Acquisition and disposal recording - FIFO cost basis matching - 50% CGT discount for holdings >12 months - Tax year calculations (July-June) - Capital loss tracking and carry-forward - Tax report generation - Foreign currency conversions - Edge cases and validation """ import pytest from datetime import date, timedelta from decimal import Decimal from tradingagents.portfolio.tax_calculator import ( CGTMethod, AssetType, CGTAssetAcquisition, CGTDisposal, CGTEvent, TaxYearSummary, AustralianCGTCalculator, ) # ============================================================================== # CGTMethod Enum Tests # ============================================================================== class TestCGTMethod: """Tests for CGTMethod enum.""" def test_discount_value(self): """Test DISCOUNT method value.""" assert CGTMethod.DISCOUNT.value == "discount" def test_indexation_value(self): """Test INDEXATION method value.""" assert CGTMethod.INDEXATION.value == "indexation" def test_other_value(self): """Test OTHER method value.""" assert CGTMethod.OTHER.value == "other" def test_all_methods_exist(self): """Test all expected methods exist.""" methods = [m for m in CGTMethod] assert len(methods) == 3 assert CGTMethod.DISCOUNT in methods assert CGTMethod.INDEXATION in methods assert CGTMethod.OTHER in methods # ============================================================================== # AssetType Enum Tests # ============================================================================== class TestAssetType: """Tests for AssetType enum.""" def test_shares_value(self): """Test SHARES type value.""" assert AssetType.SHARES.value == "shares" def test_foreign_shares_value(self): """Test FOREIGN_SHARES type value.""" assert AssetType.FOREIGN_SHARES.value == "foreign_shares" def test_etf_value(self): """Test ETF type value.""" assert AssetType.ETF.value == "etf" def test_cryptocurrency_value(self): """Test CRYPTOCURRENCY type value.""" assert AssetType.CRYPTOCURRENCY.value == "cryptocurrency" def test_property_value(self): """Test PROPERTY type value.""" assert AssetType.PROPERTY.value == "property" def test_collectables_value(self): """Test COLLECTABLES type value.""" assert AssetType.COLLECTABLES.value == "collectables" def test_other_value(self): """Test OTHER type value.""" assert AssetType.OTHER.value == "other" def test_all_types_exist(self): """Test all expected asset types exist.""" types = [t for t in AssetType] assert len(types) == 7 # ============================================================================== # CGTAssetAcquisition Tests # ============================================================================== class TestCGTAssetAcquisition: """Tests for CGTAssetAcquisition dataclass.""" def test_create_basic_acquisition(self): """Test creating a basic AUD acquisition.""" acq = CGTAssetAcquisition( acquisition_date=date(2023, 1, 15), quantity=Decimal("100"), cost_per_unit=Decimal("95.50"), total_cost_aud=Decimal("9550"), ) assert acq.acquisition_date == date(2023, 1, 15) assert acq.quantity == Decimal("100") assert acq.cost_per_unit == Decimal("95.50") assert acq.total_cost_aud == Decimal("9550") assert acq.currency == "AUD" assert acq.exchange_rate is None assert acq.incidental_costs == Decimal("0") def test_acquisition_with_incidental_costs(self): """Test acquisition with brokerage fees.""" acq = CGTAssetAcquisition( acquisition_date=date(2023, 1, 15), quantity=Decimal("100"), cost_per_unit=Decimal("95.50"), total_cost_aud=Decimal("9550"), incidental_costs=Decimal("19.95"), ) assert acq.incidental_costs == Decimal("19.95") assert acq.total_cost_base == Decimal("9569.95") def test_cost_base_per_unit(self): """Test cost base per unit calculation.""" acq = CGTAssetAcquisition( acquisition_date=date(2023, 1, 15), quantity=Decimal("100"), cost_per_unit=Decimal("95.50"), total_cost_aud=Decimal("9550"), incidental_costs=Decimal("50"), ) # (9550 + 50) / 100 = 96.00 assert acq.cost_base_per_unit == Decimal("96") def test_cost_base_per_unit_zero_quantity(self): """Test cost base per unit with zero quantity.""" acq = CGTAssetAcquisition( acquisition_date=date(2023, 1, 15), quantity=Decimal("0"), cost_per_unit=Decimal("95.50"), total_cost_aud=Decimal("0"), ) assert acq.cost_base_per_unit == Decimal("0") def test_foreign_acquisition(self): """Test foreign currency acquisition.""" acq = CGTAssetAcquisition( acquisition_date=date(2023, 1, 15), quantity=Decimal("50"), cost_per_unit=Decimal("150"), # USD total_cost_aud=Decimal("11250"), # After AUD conversion currency="USD", exchange_rate=Decimal("1.50"), ) assert acq.currency == "USD" assert acq.exchange_rate == Decimal("1.50") assert acq.total_cost_aud == Decimal("11250") # ============================================================================== # CGTDisposal Tests # ============================================================================== class TestCGTDisposal: """Tests for CGTDisposal dataclass.""" def test_create_basic_disposal(self): """Test creating a basic disposal.""" disposal = CGTDisposal( disposal_date=date(2024, 3, 20), symbol="CBA.AX", quantity=Decimal("50"), proceeds_per_unit=Decimal("110.25"), total_proceeds_aud=Decimal("5512.50"), ) assert disposal.disposal_date == date(2024, 3, 20) assert disposal.symbol == "CBA.AX" assert disposal.quantity == Decimal("50") assert disposal.total_proceeds_aud == Decimal("5512.50") def test_net_proceeds_calculation(self): """Test net proceeds after selling costs.""" disposal = CGTDisposal( disposal_date=date(2024, 3, 20), symbol="CBA.AX", quantity=Decimal("50"), proceeds_per_unit=Decimal("110.25"), total_proceeds_aud=Decimal("5512.50"), incidental_costs=Decimal("19.95"), ) assert disposal.net_proceeds == Decimal("5492.55") # ============================================================================== # CGTEvent Tests # ============================================================================== class TestCGTEvent: """Tests for CGTEvent dataclass.""" def test_is_gain(self): """Test capital gain detection.""" disposal = CGTDisposal( disposal_date=date(2024, 3, 20), symbol="CBA.AX", quantity=Decimal("50"), proceeds_per_unit=Decimal("110.25"), total_proceeds_aud=Decimal("5512.50"), ) event = CGTEvent( event_date=date(2024, 3, 20), symbol="CBA.AX", asset_type=AssetType.SHARES, disposal=disposal, gross_gain=Decimal("737.50"), discount_eligible=True, discount_amount=Decimal("368.75"), net_gain=Decimal("368.75"), holding_period_days=430, cgt_method=CGTMethod.DISCOUNT, tax_year=2024, ) assert event.is_gain is True assert event.is_loss is False def test_is_loss(self): """Test capital loss detection.""" disposal = CGTDisposal( disposal_date=date(2024, 3, 20), symbol="CBA.AX", quantity=Decimal("50"), proceeds_per_unit=Decimal("80"), total_proceeds_aud=Decimal("4000"), ) event = CGTEvent( event_date=date(2024, 3, 20), symbol="CBA.AX", asset_type=AssetType.SHARES, disposal=disposal, gross_gain=Decimal("-775"), discount_eligible=False, discount_amount=Decimal("0"), net_gain=Decimal("-775"), holding_period_days=430, cgt_method=CGTMethod.OTHER, tax_year=2024, ) assert event.is_gain is False assert event.is_loss is True # ============================================================================== # TaxYearSummary Tests # ============================================================================== class TestTaxYearSummary: """Tests for TaxYearSummary dataclass.""" def test_num_events(self): """Test event count property.""" summary = TaxYearSummary( tax_year=2024, start_date=date(2023, 7, 1), end_date=date(2024, 6, 30), total_gains=Decimal("1000"), total_losses=Decimal("200"), losses_applied=Decimal("200"), carried_forward_losses=Decimal("0"), losses_to_carry=Decimal("0"), net_capital_gain=Decimal("800"), discounted_gains=Decimal("1000"), discount_applied=Decimal("400"), taxable_gain=Decimal("400"), events=[], ) assert summary.num_events == 0 def test_num_gains_and_losses(self): """Test gain and loss count properties.""" disposal1 = CGTDisposal( disposal_date=date(2024, 1, 15), symbol="CBA.AX", quantity=Decimal("50"), proceeds_per_unit=Decimal("110"), total_proceeds_aud=Decimal("5500"), ) event1 = CGTEvent( event_date=date(2024, 1, 15), symbol="CBA.AX", asset_type=AssetType.SHARES, disposal=disposal1, gross_gain=Decimal("500"), discount_eligible=True, discount_amount=Decimal("250"), net_gain=Decimal("250"), holding_period_days=400, cgt_method=CGTMethod.DISCOUNT, tax_year=2024, ) disposal2 = CGTDisposal( disposal_date=date(2024, 2, 15), symbol="NAB.AX", quantity=Decimal("50"), proceeds_per_unit=Decimal("25"), total_proceeds_aud=Decimal("1250"), ) event2 = CGTEvent( event_date=date(2024, 2, 15), symbol="NAB.AX", asset_type=AssetType.SHARES, disposal=disposal2, gross_gain=Decimal("-250"), discount_eligible=False, discount_amount=Decimal("0"), net_gain=Decimal("-250"), holding_period_days=100, cgt_method=CGTMethod.OTHER, tax_year=2024, ) summary = TaxYearSummary( tax_year=2024, start_date=date(2023, 7, 1), end_date=date(2024, 6, 30), total_gains=Decimal("500"), total_losses=Decimal("250"), losses_applied=Decimal("250"), carried_forward_losses=Decimal("0"), losses_to_carry=Decimal("0"), net_capital_gain=Decimal("250"), discounted_gains=Decimal("500"), discount_applied=Decimal("125"), taxable_gain=Decimal("125"), events=[event1, event2], ) assert summary.num_events == 2 assert summary.num_gains == 1 assert summary.num_losses == 1 # ============================================================================== # AustralianCGTCalculator - Tax Year Tests # ============================================================================== class TestTaxYearCalculations: """Tests for Australian tax year calculations.""" def test_tax_year_july_date(self): """Test tax year for July date (start of new FY).""" # July 1, 2023 is in FY2023-24 (tax year 2024) assert AustralianCGTCalculator.get_tax_year(date(2023, 7, 1)) == 2024 def test_tax_year_june_date(self): """Test tax year for June date (end of FY).""" # June 30, 2024 is in FY2023-24 (tax year 2024) assert AustralianCGTCalculator.get_tax_year(date(2024, 6, 30)) == 2024 def test_tax_year_january_date(self): """Test tax year for January date.""" # January 15, 2024 is in FY2023-24 (tax year 2024) assert AustralianCGTCalculator.get_tax_year(date(2024, 1, 15)) == 2024 def test_tax_year_december_date(self): """Test tax year for December date.""" # December 15, 2023 is in FY2023-24 (tax year 2024) assert AustralianCGTCalculator.get_tax_year(date(2023, 12, 15)) == 2024 def test_tax_year_dates(self): """Test getting tax year start and end dates.""" start, end = AustralianCGTCalculator.get_tax_year_dates(2024) assert start == date(2023, 7, 1) assert end == date(2024, 6, 30) # ============================================================================== # AustralianCGTCalculator - Acquisition Tests # ============================================================================== class TestAcquisitions: """Tests for asset acquisition recording.""" def test_add_basic_acquisition(self): """Test adding a basic AUD acquisition.""" calculator = AustralianCGTCalculator() acq = calculator.add_acquisition( symbol="CBA.AX", acquisition_date=date(2023, 1, 15), quantity=Decimal("100"), cost_per_unit=Decimal("95.50"), ) assert acq.quantity == Decimal("100") assert acq.cost_per_unit == Decimal("95.50") assert acq.total_cost_aud == Decimal("9550") assert calculator.get_holding_quantity("CBA.AX") == Decimal("100") def test_add_acquisition_with_brokerage(self): """Test adding acquisition with incidental costs.""" calculator = AustralianCGTCalculator() acq = calculator.add_acquisition( symbol="CBA.AX", acquisition_date=date(2023, 1, 15), quantity=Decimal("100"), cost_per_unit=Decimal("95.50"), incidental_costs=Decimal("19.95"), ) assert acq.incidental_costs == Decimal("19.95") assert acq.total_cost_base == Decimal("9569.95") def test_add_foreign_acquisition(self): """Test adding a foreign currency acquisition.""" calculator = AustralianCGTCalculator() acq = calculator.add_acquisition( symbol="AAPL", acquisition_date=date(2023, 1, 15), quantity=Decimal("50"), cost_per_unit=Decimal("150"), # USD currency="USD", exchange_rate=Decimal("1.50"), # AUD per USD ) assert acq.currency == "USD" assert acq.exchange_rate == Decimal("1.50") # 50 * 150 * 1.50 = 11250 assert acq.total_cost_aud == Decimal("11250") def test_add_multiple_acquisitions_same_symbol(self): """Test adding multiple parcels of same asset.""" calculator = AustralianCGTCalculator() calculator.add_acquisition( symbol="CBA.AX", acquisition_date=date(2023, 1, 15), quantity=Decimal("100"), cost_per_unit=Decimal("95.50"), ) calculator.add_acquisition( symbol="CBA.AX", acquisition_date=date(2023, 6, 20), quantity=Decimal("50"), cost_per_unit=Decimal("100"), ) assert calculator.get_holding_quantity("CBA.AX") == Decimal("150") holdings = calculator.get_holdings("CBA.AX") assert len(holdings) == 2 # Should be sorted by acquisition date (FIFO) assert holdings[0].acquisition_date == date(2023, 1, 15) assert holdings[1].acquisition_date == date(2023, 6, 20) def test_acquisition_with_asset_type(self): """Test setting asset type on acquisition.""" calculator = AustralianCGTCalculator() calculator.add_acquisition( symbol="BTC", acquisition_date=date(2023, 1, 15), quantity=Decimal("0.5"), cost_per_unit=Decimal("30000"), asset_type=AssetType.CRYPTOCURRENCY, ) assert calculator.get_asset_type("BTC") == AssetType.CRYPTOCURRENCY def test_acquisition_invalid_quantity(self): """Test acquisition with invalid quantity.""" calculator = AustralianCGTCalculator() with pytest.raises(ValueError, match="Quantity must be positive"): calculator.add_acquisition( symbol="CBA.AX", acquisition_date=date(2023, 1, 15), quantity=Decimal("0"), cost_per_unit=Decimal("95.50"), ) def test_acquisition_negative_quantity(self): """Test acquisition with negative quantity.""" calculator = AustralianCGTCalculator() with pytest.raises(ValueError, match="Quantity must be positive"): calculator.add_acquisition( symbol="CBA.AX", acquisition_date=date(2023, 1, 15), quantity=Decimal("-10"), cost_per_unit=Decimal("95.50"), ) def test_foreign_acquisition_missing_exchange_rate(self): """Test foreign acquisition without exchange rate.""" calculator = AustralianCGTCalculator() with pytest.raises(ValueError, match="Exchange rate required"): calculator.add_acquisition( symbol="AAPL", acquisition_date=date(2023, 1, 15), quantity=Decimal("50"), cost_per_unit=Decimal("150"), currency="USD", ) # ============================================================================== # AustralianCGTCalculator - Disposal Tests # ============================================================================== class TestDisposals: """Tests for asset disposal and CGT calculation.""" def test_basic_disposal_with_gain(self): """Test basic disposal with capital gain.""" calculator = AustralianCGTCalculator() calculator.add_acquisition( symbol="CBA.AX", acquisition_date=date(2023, 1, 15), quantity=Decimal("100"), cost_per_unit=Decimal("95.50"), ) event = calculator.dispose( symbol="CBA.AX", disposal_date=date(2023, 6, 20), # Less than 12 months quantity=Decimal("50"), proceeds_per_unit=Decimal("110.25"), ) assert event.symbol == "CBA.AX" assert event.disposal.quantity == Decimal("50") # Proceeds: 50 * 110.25 = 5512.50 # Cost: 50 * 95.50 = 4775 # Gain: 5512.50 - 4775 = 737.50 assert event.gross_gain == Decimal("737.50") assert event.discount_eligible is False # Less than 12 months assert event.net_gain == Decimal("737.50") assert event.cgt_method == CGTMethod.OTHER def test_disposal_with_discount(self): """Test disposal eligible for 50% CGT discount.""" calculator = AustralianCGTCalculator() calculator.add_acquisition( symbol="CBA.AX", acquisition_date=date(2023, 1, 15), quantity=Decimal("100"), cost_per_unit=Decimal("95.50"), ) event = calculator.dispose( symbol="CBA.AX", disposal_date=date(2024, 3, 20), # More than 12 months quantity=Decimal("50"), proceeds_per_unit=Decimal("110.25"), ) assert event.discount_eligible is True assert event.gross_gain == Decimal("737.50") assert event.discount_amount == Decimal("368.75") assert event.net_gain == Decimal("368.75") assert event.cgt_method == CGTMethod.DISCOUNT def test_disposal_with_loss_no_discount(self): """Test disposal with capital loss (no discount on losses).""" calculator = AustralianCGTCalculator() calculator.add_acquisition( symbol="CBA.AX", acquisition_date=date(2023, 1, 15), quantity=Decimal("100"), cost_per_unit=Decimal("95.50"), ) event = calculator.dispose( symbol="CBA.AX", disposal_date=date(2024, 3, 20), # More than 12 months quantity=Decimal("50"), proceeds_per_unit=Decimal("80"), # Selling at loss ) # Proceeds: 50 * 80 = 4000 # Cost: 50 * 95.50 = 4775 # Loss: 4000 - 4775 = -775 assert event.gross_gain == Decimal("-775.00") assert event.discount_eligible is False # No discount on losses assert event.discount_amount == Decimal("0.00") assert event.net_gain == Decimal("-775.00") assert event.is_loss is True def test_disposal_fifo_matching(self): """Test FIFO cost basis matching.""" calculator = AustralianCGTCalculator() # First parcel at $90 calculator.add_acquisition( symbol="CBA.AX", acquisition_date=date(2023, 1, 15), quantity=Decimal("100"), cost_per_unit=Decimal("90"), ) # Second parcel at $100 calculator.add_acquisition( symbol="CBA.AX", acquisition_date=date(2023, 6, 20), quantity=Decimal("100"), cost_per_unit=Decimal("100"), ) # Dispose 150 shares - should use first 100 at $90, then 50 at $100 event = calculator.dispose( symbol="CBA.AX", disposal_date=date(2023, 9, 15), quantity=Decimal("150"), proceeds_per_unit=Decimal("105"), ) # Proceeds: 150 * 105 = 15750 # Cost: 100 * 90 + 50 * 100 = 9000 + 5000 = 14000 # Gain: 15750 - 14000 = 1750 assert event.gross_gain == Decimal("1750.00") # Remaining should be 50 shares at $100 assert calculator.get_holding_quantity("CBA.AX") == Decimal("50") def test_disposal_partial_parcel(self): """Test partial disposal of a parcel.""" calculator = AustralianCGTCalculator() calculator.add_acquisition( symbol="CBA.AX", acquisition_date=date(2023, 1, 15), quantity=Decimal("100"), cost_per_unit=Decimal("95.50"), ) calculator.dispose( symbol="CBA.AX", disposal_date=date(2023, 6, 20), quantity=Decimal("30"), proceeds_per_unit=Decimal("100"), ) # Remaining should be 70 shares assert calculator.get_holding_quantity("CBA.AX") == Decimal("70") holdings = calculator.get_holdings("CBA.AX") assert len(holdings) == 1 assert holdings[0].quantity == Decimal("70") def test_disposal_insufficient_holdings(self): """Test disposal with insufficient holdings.""" calculator = AustralianCGTCalculator() calculator.add_acquisition( symbol="CBA.AX", acquisition_date=date(2023, 1, 15), quantity=Decimal("100"), cost_per_unit=Decimal("95.50"), ) with pytest.raises(ValueError, match="Insufficient holdings"): calculator.dispose( symbol="CBA.AX", disposal_date=date(2023, 6, 20), quantity=Decimal("150"), proceeds_per_unit=Decimal("100"), ) def test_disposal_no_holdings(self): """Test disposal with no holdings.""" calculator = AustralianCGTCalculator() with pytest.raises(ValueError, match="Insufficient holdings"): calculator.dispose( symbol="CBA.AX", disposal_date=date(2023, 6, 20), quantity=Decimal("50"), proceeds_per_unit=Decimal("100"), ) def test_disposal_with_selling_costs(self): """Test disposal with incidental (selling) costs.""" calculator = AustralianCGTCalculator() calculator.add_acquisition( symbol="CBA.AX", acquisition_date=date(2023, 1, 15), quantity=Decimal("100"), cost_per_unit=Decimal("95.50"), ) event = calculator.dispose( symbol="CBA.AX", disposal_date=date(2023, 6, 20), quantity=Decimal("50"), proceeds_per_unit=Decimal("110"), incidental_costs=Decimal("19.95"), ) # Net proceeds: 50 * 110 - 19.95 = 5480.05 # Cost: 50 * 95.50 = 4775 # Gain: 5480.05 - 4775 = 705.05 assert event.gross_gain == Decimal("705.05") # ============================================================================== # AustralianCGTCalculator - Tax Year Summary Tests # ============================================================================== class TestTaxYearSummary: """Tests for tax year summary calculations.""" def test_simple_summary_with_gains(self): """Test summary with only gains.""" calculator = AustralianCGTCalculator() calculator.add_acquisition( symbol="CBA.AX", acquisition_date=date(2022, 7, 1), quantity=Decimal("100"), cost_per_unit=Decimal("90"), ) calculator.dispose( symbol="CBA.AX", disposal_date=date(2023, 10, 15), # FY2024, >12 months quantity=Decimal("100"), proceeds_per_unit=Decimal("110"), ) summary = calculator.calculate_tax_year_summary(2024) # Gross gain: 100 * (110 - 90) = 2000 assert summary.total_gains == Decimal("2000.00") assert summary.total_losses == Decimal("0.00") assert summary.discounted_gains == Decimal("2000.00") # Discount: 2000 * 0.50 = 1000 assert summary.discount_applied == Decimal("1000.00") assert summary.taxable_gain == Decimal("1000.00") def test_summary_with_losses_applied(self): """Test summary with losses applied to gains.""" calculator = AustralianCGTCalculator() # Gain asset calculator.add_acquisition( symbol="CBA.AX", acquisition_date=date(2022, 7, 1), quantity=Decimal("100"), cost_per_unit=Decimal("90"), ) # Loss asset calculator.add_acquisition( symbol="NAB.AX", acquisition_date=date(2022, 7, 1), quantity=Decimal("100"), cost_per_unit=Decimal("35"), ) # Dispose with gain calculator.dispose( symbol="CBA.AX", disposal_date=date(2023, 10, 15), quantity=Decimal("100"), proceeds_per_unit=Decimal("110"), ) # Dispose with loss calculator.dispose( symbol="NAB.AX", disposal_date=date(2023, 11, 15), quantity=Decimal("100"), proceeds_per_unit=Decimal("25"), ) summary = calculator.calculate_tax_year_summary(2024) assert summary.total_gains == Decimal("2000.00") assert summary.total_losses == Decimal("1000.00") assert summary.losses_applied == Decimal("1000.00") def test_summary_with_carried_forward_losses(self): """Test summary with carried forward losses.""" calculator = AustralianCGTCalculator() calculator.set_carried_forward_losses(Decimal("500")) calculator.add_acquisition( symbol="CBA.AX", acquisition_date=date(2022, 7, 1), quantity=Decimal("100"), cost_per_unit=Decimal("90"), ) calculator.dispose( symbol="CBA.AX", disposal_date=date(2023, 10, 15), quantity=Decimal("100"), proceeds_per_unit=Decimal("110"), ) summary = calculator.calculate_tax_year_summary(2024) assert summary.carried_forward_losses == Decimal("500.00") # Gross gain 2000 - 500 carried loss = 1500 # Then 50% discount = 750 taxable assert summary.taxable_gain == Decimal("750.00") def test_summary_excess_losses_carry_forward(self): """Test excess losses are carried forward.""" calculator = AustralianCGTCalculator() calculator.add_acquisition( symbol="CBA.AX", acquisition_date=date(2022, 7, 1), quantity=Decimal("100"), cost_per_unit=Decimal("90"), ) calculator.add_acquisition( symbol="NAB.AX", acquisition_date=date(2022, 7, 1), quantity=Decimal("100"), cost_per_unit=Decimal("50"), ) # Small gain calculator.dispose( symbol="CBA.AX", disposal_date=date(2023, 10, 15), quantity=Decimal("100"), proceeds_per_unit=Decimal("95"), ) # Large loss calculator.dispose( symbol="NAB.AX", disposal_date=date(2023, 11, 15), quantity=Decimal("100"), proceeds_per_unit=Decimal("20"), ) summary = calculator.calculate_tax_year_summary(2024) assert summary.total_gains == Decimal("500.00") # 100 * (95-90) assert summary.total_losses == Decimal("3000.00") # 100 * (50-20) assert summary.taxable_gain == Decimal("0.00") assert summary.losses_to_carry == Decimal("2500.00") # 3000 - 500 def test_summary_no_events(self): """Test summary with no events for tax year.""" calculator = AustralianCGTCalculator() summary = calculator.calculate_tax_year_summary(2024) assert summary.num_events == 0 assert summary.total_gains == Decimal("0.00") assert summary.total_losses == Decimal("0.00") assert summary.taxable_gain == Decimal("0.00") # ============================================================================== # AustralianCGTCalculator - Tax Report Tests # ============================================================================== class TestTaxReport: """Tests for tax report generation.""" def test_generate_basic_report(self): """Test generating a basic tax report.""" calculator = AustralianCGTCalculator() calculator.add_acquisition( symbol="CBA.AX", acquisition_date=date(2022, 7, 1), quantity=Decimal("100"), cost_per_unit=Decimal("90"), ) calculator.dispose( symbol="CBA.AX", disposal_date=date(2023, 10, 15), quantity=Decimal("100"), proceeds_per_unit=Decimal("110"), ) report = calculator.generate_tax_report(2024) assert report["tax_year"] == "FY2023-24" assert report["period"]["start"] == "2023-07-01" assert report["period"]["end"] == "2024-06-30" assert "summary" in report assert "statistics" in report assert "transactions" in report def test_report_summary_values(self): """Test report summary values.""" calculator = AustralianCGTCalculator() calculator.add_acquisition( symbol="CBA.AX", acquisition_date=date(2022, 7, 1), quantity=Decimal("100"), cost_per_unit=Decimal("90"), ) calculator.dispose( symbol="CBA.AX", disposal_date=date(2023, 10, 15), quantity=Decimal("100"), proceeds_per_unit=Decimal("110"), ) report = calculator.generate_tax_report(2024) summary = report["summary"] assert summary["total_capital_gains"] == "2000.00" assert summary["total_capital_losses"] == "0.00" assert summary["discount_method_gains"] == "2000.00" assert summary["cgt_discount_applied"] == "1000.00" assert summary["taxable_capital_gain"] == "1000.00" def test_report_without_details(self): """Test report without transaction details.""" calculator = AustralianCGTCalculator() calculator.add_acquisition( symbol="CBA.AX", acquisition_date=date(2022, 7, 1), quantity=Decimal("100"), cost_per_unit=Decimal("90"), ) calculator.dispose( symbol="CBA.AX", disposal_date=date(2023, 10, 15), quantity=Decimal("100"), proceeds_per_unit=Decimal("110"), ) report = calculator.generate_tax_report(2024, include_details=False) assert "transactions" not in report def test_report_statistics(self): """Test report statistics.""" calculator = AustralianCGTCalculator() # Two gains, one loss calculator.add_acquisition( symbol="CBA.AX", acquisition_date=date(2022, 7, 1), quantity=Decimal("100"), cost_per_unit=Decimal("90"), ) calculator.add_acquisition( symbol="BHP.AX", acquisition_date=date(2022, 7, 1), quantity=Decimal("100"), cost_per_unit=Decimal("40"), ) calculator.add_acquisition( symbol="NAB.AX", acquisition_date=date(2022, 7, 1), quantity=Decimal("100"), cost_per_unit=Decimal("35"), ) calculator.dispose( symbol="CBA.AX", disposal_date=date(2023, 10, 15), quantity=Decimal("100"), proceeds_per_unit=Decimal("110"), ) calculator.dispose( symbol="BHP.AX", disposal_date=date(2023, 10, 15), quantity=Decimal("100"), proceeds_per_unit=Decimal("50"), ) calculator.dispose( symbol="NAB.AX", disposal_date=date(2023, 10, 15), quantity=Decimal("100"), proceeds_per_unit=Decimal("25"), ) report = calculator.generate_tax_report(2024) stats = report["statistics"] assert stats["number_of_disposals"] == 3 assert stats["number_of_gains"] == 2 assert stats["number_of_losses"] == 1 # ============================================================================== # AustralianCGTCalculator - Unrealised Gains Tests # ============================================================================== class TestUnrealisedGains: """Tests for unrealised gains calculation.""" def test_unrealised_gains_calculation(self): """Test calculating unrealised gains.""" calculator = AustralianCGTCalculator() calculator.add_acquisition( symbol="CBA.AX", acquisition_date=date(2023, 1, 15), quantity=Decimal("100"), cost_per_unit=Decimal("90"), ) result = calculator.get_unrealised_gains({"CBA.AX": Decimal("110")}) assert result["CBA.AX"]["quantity"] == Decimal("100") assert result["CBA.AX"]["cost_base"] == Decimal("9000.00") assert result["CBA.AX"]["market_value"] == Decimal("11000.00") assert result["CBA.AX"]["unrealised_gain"] == Decimal("2000.00") def test_unrealised_loss_calculation(self): """Test calculating unrealised losses.""" calculator = AustralianCGTCalculator() calculator.add_acquisition( symbol="CBA.AX", acquisition_date=date(2023, 1, 15), quantity=Decimal("100"), cost_per_unit=Decimal("90"), ) result = calculator.get_unrealised_gains({"CBA.AX": Decimal("80")}) assert result["CBA.AX"]["unrealised_gain"] == Decimal("-1000.00") def test_unrealised_gains_missing_price(self): """Test unrealised gains with missing price.""" calculator = AustralianCGTCalculator() calculator.add_acquisition( symbol="CBA.AX", acquisition_date=date(2023, 1, 15), quantity=Decimal("100"), cost_per_unit=Decimal("90"), ) result = calculator.get_unrealised_gains({}) assert result["CBA.AX"]["market_value"] == Decimal("0.00") assert result["CBA.AX"]["unrealised_gain"] == Decimal("0.00") def test_unrealised_gains_multiple_parcels(self): """Test unrealised gains with multiple acquisition parcels.""" calculator = AustralianCGTCalculator() calculator.add_acquisition( symbol="CBA.AX", acquisition_date=date(2023, 1, 15), quantity=Decimal("100"), cost_per_unit=Decimal("90"), ) calculator.add_acquisition( symbol="CBA.AX", acquisition_date=date(2023, 6, 15), quantity=Decimal("50"), cost_per_unit=Decimal("100"), ) result = calculator.get_unrealised_gains({"CBA.AX": Decimal("110")}) assert result["CBA.AX"]["quantity"] == Decimal("150") # Cost: 100 * 90 + 50 * 100 = 14000 assert result["CBA.AX"]["cost_base"] == Decimal("14000.00") # Value: 150 * 110 = 16500 assert result["CBA.AX"]["market_value"] == Decimal("16500.00") assert result["CBA.AX"]["unrealised_gain"] == Decimal("2500.00") # ============================================================================== # AustralianCGTCalculator - Edge Cases Tests # ============================================================================== class TestEdgeCases: """Tests for edge cases and special scenarios.""" def test_exactly_365_days_no_discount(self): """Test holding exactly 365 days (not eligible for discount).""" calculator = AustralianCGTCalculator() acq_date = date(2023, 1, 15) calculator.add_acquisition( symbol="CBA.AX", acquisition_date=acq_date, quantity=Decimal("100"), cost_per_unit=Decimal("90"), ) # Exactly 365 days later disposal_date = acq_date + timedelta(days=365) event = calculator.dispose( symbol="CBA.AX", disposal_date=disposal_date, quantity=Decimal("100"), proceeds_per_unit=Decimal("110"), ) # Must be MORE than 365 days for discount assert event.discount_eligible is False def test_366_days_eligible_for_discount(self): """Test holding 366 days (eligible for discount).""" calculator = AustralianCGTCalculator() acq_date = date(2023, 1, 15) calculator.add_acquisition( symbol="CBA.AX", acquisition_date=acq_date, quantity=Decimal("100"), cost_per_unit=Decimal("90"), ) disposal_date = acq_date + timedelta(days=366) event = calculator.dispose( symbol="CBA.AX", disposal_date=disposal_date, quantity=Decimal("100"), proceeds_per_unit=Decimal("110"), ) assert event.discount_eligible is True def test_zero_cost_acquisition(self): """Test acquisition with zero cost (e.g., inheritance).""" calculator = AustralianCGTCalculator() acq = calculator.add_acquisition( symbol="CBA.AX", acquisition_date=date(2023, 1, 15), quantity=Decimal("100"), cost_per_unit=Decimal("0"), ) assert acq.total_cost_aud == Decimal("0") event = calculator.dispose( symbol="CBA.AX", disposal_date=date(2024, 3, 20), quantity=Decimal("100"), proceeds_per_unit=Decimal("100"), ) # All proceeds are gain assert event.gross_gain == Decimal("10000.00") def test_very_small_quantities(self): """Test with very small quantities (fractional shares).""" calculator = AustralianCGTCalculator() calculator.add_acquisition( symbol="BRK.A", acquisition_date=date(2023, 1, 15), quantity=Decimal("0.001"), cost_per_unit=Decimal("500000"), ) event = calculator.dispose( symbol="BRK.A", disposal_date=date(2023, 6, 20), quantity=Decimal("0.001"), proceeds_per_unit=Decimal("510000"), ) assert event.gross_gain == Decimal("10.00") def test_clear_calculator(self): """Test clearing all calculator data.""" calculator = AustralianCGTCalculator() calculator.add_acquisition( symbol="CBA.AX", acquisition_date=date(2023, 1, 15), quantity=Decimal("100"), cost_per_unit=Decimal("90"), ) calculator.set_carried_forward_losses(Decimal("1000")) calculator.clear() assert calculator.get_holding_quantity("CBA.AX") == Decimal("0") assert calculator.get_carried_forward_losses() == Decimal("0") assert len(calculator.get_events()) == 0 def test_set_and_get_asset_type(self): """Test setting and getting asset type.""" calculator = AustralianCGTCalculator() calculator.set_asset_type("BTC", AssetType.CRYPTOCURRENCY) assert calculator.get_asset_type("BTC") == AssetType.CRYPTOCURRENCY # Default type for unknown asset assert calculator.get_asset_type("UNKNOWN") == AssetType.SHARES def test_invalid_carried_forward_losses(self): """Test setting invalid carried forward losses.""" calculator = AustralianCGTCalculator() with pytest.raises(ValueError, match="non-negative"): calculator.set_carried_forward_losses(Decimal("-100")) def test_get_events_all(self): """Test getting all events.""" calculator = AustralianCGTCalculator() calculator.add_acquisition( symbol="CBA.AX", acquisition_date=date(2022, 7, 1), quantity=Decimal("100"), cost_per_unit=Decimal("90"), ) calculator.dispose( symbol="CBA.AX", disposal_date=date(2023, 10, 15), quantity=Decimal("50"), proceeds_per_unit=Decimal("110"), ) calculator.dispose( symbol="CBA.AX", disposal_date=date(2024, 10, 15), quantity=Decimal("50"), proceeds_per_unit=Decimal("120"), ) all_events = calculator.get_events() assert len(all_events) == 2 def test_get_events_filtered_by_year(self): """Test getting events filtered by tax year.""" calculator = AustralianCGTCalculator() calculator.add_acquisition( symbol="CBA.AX", acquisition_date=date(2022, 7, 1), quantity=Decimal("100"), cost_per_unit=Decimal("90"), ) calculator.dispose( symbol="CBA.AX", disposal_date=date(2023, 10, 15), # FY2024 quantity=Decimal("50"), proceeds_per_unit=Decimal("110"), ) calculator.dispose( symbol="CBA.AX", disposal_date=date(2024, 10, 15), # FY2025 quantity=Decimal("50"), proceeds_per_unit=Decimal("120"), ) fy2024_events = calculator.get_events(tax_year=2024) assert len(fy2024_events) == 1 fy2025_events = calculator.get_events(tax_year=2025) assert len(fy2025_events) == 1 # ============================================================================== # Module Import Tests # ============================================================================== class TestModuleImports: """Tests for module imports.""" def test_import_from_portfolio_module(self): """Test importing from portfolio module.""" from tradingagents.portfolio import ( CGTMethod, AssetType, CGTAssetAcquisition, CGTDisposal, CGTEvent, TaxYearSummary, AustralianCGTCalculator, ) assert CGTMethod is not None assert AssetType is not None assert CGTAssetAcquisition is not None assert CGTDisposal is not None assert CGTEvent is not None assert TaxYearSummary is not None assert AustralianCGTCalculator is not None def test_calculator_constants(self): """Test calculator constants.""" assert AustralianCGTCalculator.DISCOUNT_RATE == Decimal("0.50") assert AustralianCGTCalculator.DISCOUNT_HOLDING_DAYS == 365