| """ |
| Test Suite — Dynamic RWA Yield Router |
| ======================================= |
| Tests for RL environment, risk manager, executor, data pipeline, and orchestrator. |
| """ |
|
|
| import asyncio |
| import json |
| import logging |
| import os |
| import sys |
| import time |
| import unittest |
| from unittest.mock import AsyncMock, MagicMock, patch |
|
|
| import numpy as np |
|
|
| |
| sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) |
|
|
| from config.constants import PortfolioConfig, RiskConfig, RLConfig, Tokens |
| from agent.rl_optimizer import RWAYieldEnv, PPOYieldOptimizer, Backtester |
| from agent.risk_manager import RiskManager, RiskLevel, CircuitBreakerState |
| from agent.executor import OnChainExecutor, SwapRoute, UnsignedTx |
| from agent.data_pipeline import DataPipeline, YieldSnapshot |
| from agent.strategy_reporter import StrategyReporter |
|
|
|
|
| class TestRWAYieldEnv(unittest.TestCase): |
| """Test the RL training environment.""" |
| |
| def setUp(self): |
| self.env = RWAYieldEnv(episode_length=100) |
| |
| def test_reset(self): |
| obs, info = self.env.reset(seed=42) |
| self.assertEqual(obs.shape, (18,)) |
| self.assertFalse(np.any(np.isnan(obs))) |
| |
| def test_step(self): |
| obs, _ = self.env.reset(seed=42) |
| action = np.array([0.0, 0.0, 0.0]) |
| obs_next, reward, terminated, truncated, info = self.env.step(action) |
| |
| self.assertEqual(obs_next.shape, (18,)) |
| self.assertIsInstance(reward, float) |
| self.assertIn("portfolio_value", info) |
| self.assertIn("weights", info) |
| self.assertIn("drawdown", info) |
| |
| def test_weights_sum_to_one(self): |
| obs, _ = self.env.reset(seed=42) |
| for _ in range(10): |
| action = np.random.uniform(-1, 1, 3) |
| obs, reward, terminated, truncated, info = self.env.step(action) |
| weights = info["weights"] |
| self.assertAlmostEqual(sum(weights), 1.0, places=5) |
| if terminated or truncated: |
| break |
| |
| def test_episode_terminates(self): |
| obs, _ = self.env.reset(seed=42) |
| steps = 0 |
| while True: |
| action = np.array([0.0, 0.0, 0.0]) |
| obs, reward, terminated, truncated, info = self.env.step(action) |
| steps += 1 |
| if terminated or truncated: |
| break |
| if steps > 200: |
| self.fail("Episode did not terminate") |
| self.assertGreater(steps, 0) |
| |
| def test_position_limits_enforced(self): |
| obs, _ = self.env.reset(seed=42) |
| |
| action = np.array([10.0, -10.0, -10.0]) |
| obs, _, _, _, info = self.env.step(action) |
| weights = info["weights"] |
| |
| |
| self.assertAlmostEqual(sum(weights), 1.0, places=5) |
| for w in weights: |
| self.assertGreaterEqual(w, 0.0) |
| self.assertLessEqual(w, 1.0) |
|
|
|
|
| class TestRiskManager(unittest.TestCase): |
| """Test the risk management system.""" |
| |
| def setUp(self): |
| self.risk_mgr = RiskManager() |
| self.snapshot = YieldSnapshot( |
| timestamp=time.time(), |
| usdy_apy=4.25, |
| meth_apy=3.8, |
| mi4_apy=5.0, |
| eth_price=3200, |
| mnt_price=0.7, |
| btc_price=62000, |
| usdy_peg=1.0, |
| meth_peg=1.0, |
| eth_volatility_30d=0.5, |
| ) |
| |
| def test_normal_conditions(self): |
| weights = np.array([0.40, 0.35, 0.25]) |
| current = np.array([0.40, 0.35, 0.25]) |
| |
| assessment = self.risk_mgr.assess_risk( |
| proposed_weights=weights, |
| current_weights=current, |
| snapshot=self.snapshot, |
| portfolio_value=100000.0, |
| ) |
| |
| self.assertTrue(assessment.rebalance_approved) |
| self.assertEqual(assessment.overall_risk, RiskLevel.LOW) |
| |
| def test_usdy_depeg_detection(self): |
| self.snapshot.usdy_peg = 0.99 |
| weights = np.array([0.50, 0.30, 0.20]) |
| current = np.array([0.40, 0.35, 0.25]) |
| |
| assessment = self.risk_mgr.assess_risk( |
| proposed_weights=weights, |
| current_weights=current, |
| snapshot=self.snapshot, |
| portfolio_value=100000.0, |
| ) |
| |
| self.assertGreater(assessment.depeg_risk, 0) |
| self.assertTrue(any("USDY depeg" in w for w in assessment.warnings)) |
| |
| def test_meth_depeg_detection(self): |
| self.snapshot.meth_peg = 0.95 |
| weights = np.array([0.30, 0.40, 0.30]) |
| current = np.array([0.40, 0.35, 0.25]) |
| |
| assessment = self.risk_mgr.assess_risk( |
| proposed_weights=weights, |
| current_weights=current, |
| snapshot=self.snapshot, |
| portfolio_value=100000.0, |
| ) |
| |
| self.assertGreater(assessment.depeg_risk, 0) |
| self.assertTrue(assessment.emergency_exit_recommended) |
| |
| def test_high_volatility_adjustment(self): |
| self.snapshot.eth_volatility_30d = 1.2 |
| weights = np.array([0.20, 0.50, 0.30]) |
| current = np.array([0.40, 0.35, 0.25]) |
| |
| assessment = self.risk_mgr.assess_risk( |
| proposed_weights=weights, |
| current_weights=current, |
| snapshot=self.snapshot, |
| portfolio_value=100000.0, |
| ) |
| |
| |
| if assessment.adjusted_weights is not None: |
| self.assertLess(assessment.adjusted_weights[1], 0.50) |
| |
| def test_concentration_limits(self): |
| weights = np.array([0.80, 0.10, 0.10]) |
| current = np.array([0.40, 0.35, 0.25]) |
| |
| assessment = self.risk_mgr.assess_risk( |
| proposed_weights=weights, |
| current_weights=current, |
| snapshot=self.snapshot, |
| portfolio_value=100000.0, |
| ) |
| |
| self.assertGreater(assessment.concentration_risk, 0) |
| if assessment.adjusted_weights is not None: |
| |
| self.assertLessEqual(assessment.adjusted_weights[0], 0.80) |
| |
| def test_circuit_breaker_on_drawdown(self): |
| |
| self.risk_mgr.peak_portfolio_value = 100000.0 |
| weights = np.array([0.40, 0.35, 0.25]) |
| current = np.array([0.40, 0.35, 0.25]) |
| |
| assessment = self.risk_mgr.assess_risk( |
| proposed_weights=weights, |
| current_weights=current, |
| snapshot=self.snapshot, |
| portfolio_value=85000.0, |
| ) |
| |
| self.assertTrue(assessment.circuit_breaker_triggered) |
| self.assertFalse(assessment.rebalance_approved) |
| |
| def test_emergency_exit_weights(self): |
| weights = self.risk_mgr.get_emergency_exit_weights() |
| self.assertAlmostEqual(sum(weights), 1.0) |
| self.assertEqual(weights[0], 0.90) |
|
|
|
|
| class TestExecutor(unittest.TestCase): |
| """Test the on-chain execution layer.""" |
| |
| def setUp(self): |
| self.executor = OnChainExecutor( |
| wallet_address="0x742d35Cc6634C0532925a3b844Bc9e7595f2bD18", |
| ) |
| |
| def test_whitelisted_contracts(self): |
| self.assertTrue(self.executor.is_whitelisted(Tokens.USDY)) |
| self.assertTrue(self.executor.is_whitelisted(Tokens.METH)) |
| self.assertFalse(self.executor.is_whitelisted("0x0000000000000000000000000000000000000001")) |
| |
| def test_build_rebalance_plan(self): |
| plan = self.executor.build_rebalance_plan( |
| current_weights=[0.40, 0.35, 0.25], |
| target_weights=[0.30, 0.40, 0.30], |
| portfolio_value_usd=100000.0, |
| asset_prices={"USDY": 1.0, "mETH": 3200.0, "MI4": 100.0}, |
| ) |
| |
| self.assertIsNotNone(plan) |
| self.assertGreater(len(plan.trades), 0) |
| self.assertGreater(len(plan.approvals), 0) |
| self.assertGreater(plan.estimated_gas_usd, 0) |
| self.assertIn("Rebalance", plan.human_summary) |
| |
| def test_no_trades_when_weights_similar(self): |
| plan = self.executor.build_rebalance_plan( |
| current_weights=[0.40, 0.35, 0.25], |
| target_weights=[0.401, 0.349, 0.250], |
| portfolio_value_usd=100000.0, |
| asset_prices={"USDY": 1.0, "mETH": 3200.0, "MI4": 100.0}, |
| ) |
| |
| |
| self.assertEqual(len(plan.trades), 0) |
| |
| def test_unsigned_tx_format(self): |
| plan = self.executor.build_rebalance_plan( |
| current_weights=[0.40, 0.35, 0.25], |
| target_weights=[0.25, 0.45, 0.30], |
| portfolio_value_usd=100000.0, |
| asset_prices={"USDY": 1.0, "mETH": 3200.0, "MI4": 100.0}, |
| ) |
| |
| for tx in plan.trades + plan.approvals: |
| tx_dict = tx.to_dict() |
| self.assertIn("to", tx_dict) |
| self.assertIn("data", tx_dict) |
| self.assertTrue(tx_dict["data"].startswith("0x")) |
| self.assertEqual(tx_dict["chainId"], 5000) |
| |
| def test_aave_supply_transactions(self): |
| txs = self.executor.build_aave_supply( |
| asset=Tokens.USDY, |
| amount=1000 * 10**18, |
| asset_symbol="USDY", |
| ) |
| |
| self.assertEqual(len(txs), 2) |
| self.assertIn("Approve", txs[0].human_summary) |
| self.assertIn("Supply", txs[1].human_summary) |
|
|
|
|
| class TestStrategyReporter(unittest.TestCase): |
| """Test the strategy report generator.""" |
| |
| def setUp(self): |
| self.reporter = StrategyReporter(use_llm=False) |
| |
| def test_template_report_generation(self): |
| report = self.reporter.generate_report( |
| current_weights={"USDY": 0.40, "mETH": 0.35, "MI4": 0.25}, |
| portfolio_value=105000.0, |
| period_return=5.0, |
| yield_data={"usdy": 4.25, "meth": 3.8, "mi4": 5.0}, |
| risk_summary={ |
| "latest_risk_level": "low", |
| "latest_risk_score": 0.15, |
| "circuit_breaker": "closed", |
| "current_drawdown": 0.01, |
| "peak_value": 106000, |
| "total_assessments": 10, |
| "total_warnings": 2, |
| }, |
| execution_history=[], |
| ) |
| |
| self.assertIsNotNone(report) |
| self.assertGreater(len(report.full_report), 100) |
| self.assertIn("USDY", report.full_report) |
| self.assertIn("mETH", report.full_report) |
| self.assertTrue(report.content_hash.startswith("0x")) |
| |
| def test_telegram_message_format(self): |
| report = self.reporter.generate_report( |
| current_weights={"USDY": 0.40, "mETH": 0.35, "MI4": 0.25}, |
| portfolio_value=100000.0, |
| period_return=2.5, |
| yield_data={"usdy": 4.25, "meth": 3.8, "mi4": 5.0}, |
| risk_summary={ |
| "latest_risk_level": "low", |
| "latest_risk_score": 0.1, |
| "circuit_breaker": "closed", |
| "current_drawdown": 0.0, |
| "peak_value": 100000, |
| "total_assessments": 5, |
| "total_warnings": 0, |
| }, |
| execution_history=[], |
| ) |
| |
| tg_msg = report.to_telegram_message() |
| self.assertIn("Weekly Report", tg_msg) |
| self.assertIn("$100,000.00", tg_msg) |
| self.assertLess(len(tg_msg), 4096) |
|
|
|
|
| class TestPPOOptimizer(unittest.TestCase): |
| """Test the PPO yield optimizer.""" |
| |
| def setUp(self): |
| self.optimizer = PPOYieldOptimizer(total_timesteps=100) |
| |
| def test_predict_returns_valid_weights(self): |
| state = np.random.randn(18).astype(np.float32) |
| weights = self.optimizer.predict(state) |
| |
| self.assertEqual(len(weights), 3) |
| self.assertAlmostEqual(sum(weights), 1.0, places=4) |
| for w in weights: |
| self.assertGreaterEqual(w, 0.04) |
| self.assertLessEqual(w, 0.61) |
| |
| def test_save_and_load(self): |
| import tempfile |
| with tempfile.TemporaryDirectory() as tmpdir: |
| path = os.path.join(tmpdir, "test_model") |
| self.optimizer.save(path) |
| |
| |
| new_optimizer = PPOYieldOptimizer() |
| new_optimizer.load(path) |
| |
| state = np.random.randn(18).astype(np.float32) |
| w1 = self.optimizer.predict(state) |
| w2 = new_optimizer.predict(state) |
| |
| |
| np.testing.assert_array_almost_equal(w1, w2, decimal=3) |
|
|
|
|
| class TestDataPipeline(unittest.TestCase): |
| """Test the data pipeline.""" |
| |
| def test_yield_snapshot_to_state_vector(self): |
| snapshot = YieldSnapshot( |
| timestamp=time.time(), |
| usdy_apy=4.25, |
| meth_apy=3.8, |
| mi4_apy=5.0, |
| eth_price=3200, |
| btc_price=62000, |
| mnt_price=0.7, |
| usdy_peg=1.0, |
| meth_peg=1.0, |
| fed_funds_rate=5.25, |
| btc_dominance=50.0, |
| eth_volatility_30d=0.5, |
| ) |
| |
| state = snapshot.to_state_vector() |
| self.assertEqual(state.shape, (15,)) |
| self.assertFalse(np.any(np.isnan(state))) |
| self.assertTrue(np.all(np.abs(state) < 10)) |
| |
| def test_compute_volatility(self): |
| pipeline = DataPipeline() |
| prices = [100, 102, 101, 103, 105, 104, 106] |
| vol = pipeline.compute_volatility(prices) |
| self.assertGreater(vol, 0) |
| self.assertLess(vol, 5) |
| |
| def test_compute_volatility_empty(self): |
| pipeline = DataPipeline() |
| vol = pipeline.compute_volatility([]) |
| self.assertEqual(vol, 0.0) |
|
|
|
|
| class TestIntegration(unittest.TestCase): |
| """Integration tests for the full pipeline.""" |
| |
| def test_full_cycle_simulation(self): |
| """Run one full agent cycle in simulation mode.""" |
| from agent.main import YieldRouterAgent |
| |
| agent = YieldRouterAgent( |
| wallet_address="0x742d35Cc6634C0532925a3b844Bc9e7595f2bD18", |
| initial_capital=100000.0, |
| ) |
| |
| |
| snapshot = YieldSnapshot( |
| timestamp=time.time(), |
| usdy_apy=4.25, |
| meth_apy=3.8, |
| mi4_apy=5.0, |
| eth_price=3200, |
| btc_price=62000, |
| mnt_price=0.7, |
| usdy_peg=1.0, |
| meth_peg=1.0, |
| fed_funds_rate=5.25, |
| btc_dominance=50.0, |
| eth_volatility_30d=0.5, |
| ) |
| agent.latest_snapshot = snapshot |
| |
| |
| target_weights = agent.reason(snapshot) |
| self.assertEqual(len(target_weights), 3) |
| self.assertAlmostEqual(sum(target_weights), 1.0, places=3) |
| |
| |
| agent.state.last_rebalance_time = 0 |
| plan = agent.plan(target_weights, snapshot) |
| |
| |
| if plan is not None: |
| |
| approved = agent.authorize(plan, snapshot) |
| self.assertIsInstance(approved, bool) |
| |
| if approved: |
| |
| result = agent.execute(plan) |
| self.assertIn("trades", result) |
| |
| |
| loop = asyncio.new_event_loop() |
| verified = loop.run_until_complete(agent.verify(result)) |
| loop.close() |
| self.assertTrue(verified) |
|
|
|
|
| if __name__ == "__main__": |
| logging.basicConfig(level=logging.WARNING) |
| unittest.main(verbosity=2) |
|
|