muthuk1 commited on
Commit
0bbe710
·
verified ·
1 Parent(s): cc60dc4

Add tests/test_agent.py

Browse files
Files changed (1) hide show
  1. tests/test_agent.py +452 -0
tests/test_agent.py ADDED
@@ -0,0 +1,452 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Test Suite — Dynamic RWA Yield Router
3
+ =======================================
4
+ Tests for RL environment, risk manager, executor, data pipeline, and orchestrator.
5
+ """
6
+
7
+ import asyncio
8
+ import json
9
+ import logging
10
+ import os
11
+ import sys
12
+ import time
13
+ import unittest
14
+ from unittest.mock import AsyncMock, MagicMock, patch
15
+
16
+ import numpy as np
17
+
18
+ # Add project root to path
19
+ sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
20
+
21
+ from config.constants import PortfolioConfig, RiskConfig, RLConfig, Tokens
22
+ from agent.rl_optimizer import RWAYieldEnv, PPOYieldOptimizer, Backtester
23
+ from agent.risk_manager import RiskManager, RiskLevel, CircuitBreakerState
24
+ from agent.executor import OnChainExecutor, SwapRoute, UnsignedTx
25
+ from agent.data_pipeline import DataPipeline, YieldSnapshot
26
+ from agent.strategy_reporter import StrategyReporter
27
+
28
+
29
+ class TestRWAYieldEnv(unittest.TestCase):
30
+ """Test the RL training environment."""
31
+
32
+ def setUp(self):
33
+ self.env = RWAYieldEnv(episode_length=100)
34
+
35
+ def test_reset(self):
36
+ obs, info = self.env.reset(seed=42)
37
+ self.assertEqual(obs.shape, (18,))
38
+ self.assertFalse(np.any(np.isnan(obs)))
39
+
40
+ def test_step(self):
41
+ obs, _ = self.env.reset(seed=42)
42
+ action = np.array([0.0, 0.0, 0.0]) # equal weights after softmax
43
+ obs_next, reward, terminated, truncated, info = self.env.step(action)
44
+
45
+ self.assertEqual(obs_next.shape, (18,))
46
+ self.assertIsInstance(reward, float)
47
+ self.assertIn("portfolio_value", info)
48
+ self.assertIn("weights", info)
49
+ self.assertIn("drawdown", info)
50
+
51
+ def test_weights_sum_to_one(self):
52
+ obs, _ = self.env.reset(seed=42)
53
+ for _ in range(10):
54
+ action = np.random.uniform(-1, 1, 3)
55
+ obs, reward, terminated, truncated, info = self.env.step(action)
56
+ weights = info["weights"]
57
+ self.assertAlmostEqual(sum(weights), 1.0, places=5)
58
+ if terminated or truncated:
59
+ break
60
+
61
+ def test_episode_terminates(self):
62
+ obs, _ = self.env.reset(seed=42)
63
+ steps = 0
64
+ while True:
65
+ action = np.array([0.0, 0.0, 0.0])
66
+ obs, reward, terminated, truncated, info = self.env.step(action)
67
+ steps += 1
68
+ if terminated or truncated:
69
+ break
70
+ if steps > 200:
71
+ self.fail("Episode did not terminate")
72
+ self.assertGreater(steps, 0)
73
+
74
+ def test_position_limits_enforced(self):
75
+ obs, _ = self.env.reset(seed=42)
76
+ # Try to go all-in on one asset
77
+ action = np.array([10.0, -10.0, -10.0]) # extreme bias
78
+ obs, _, _, _, info = self.env.step(action)
79
+ weights = info["weights"]
80
+
81
+ # Check weights are valid (sum to 1, all positive)
82
+ self.assertAlmostEqual(sum(weights), 1.0, places=5)
83
+ for w in weights:
84
+ self.assertGreaterEqual(w, 0.0)
85
+ self.assertLessEqual(w, 1.0)
86
+
87
+
88
+ class TestRiskManager(unittest.TestCase):
89
+ """Test the risk management system."""
90
+
91
+ def setUp(self):
92
+ self.risk_mgr = RiskManager()
93
+ self.snapshot = YieldSnapshot(
94
+ timestamp=time.time(),
95
+ usdy_apy=4.25,
96
+ meth_apy=3.8,
97
+ mi4_apy=5.0,
98
+ eth_price=3200,
99
+ mnt_price=0.7,
100
+ btc_price=62000,
101
+ usdy_peg=1.0,
102
+ meth_peg=1.0,
103
+ eth_volatility_30d=0.5,
104
+ )
105
+
106
+ def test_normal_conditions(self):
107
+ weights = np.array([0.40, 0.35, 0.25])
108
+ current = np.array([0.40, 0.35, 0.25])
109
+
110
+ assessment = self.risk_mgr.assess_risk(
111
+ proposed_weights=weights,
112
+ current_weights=current,
113
+ snapshot=self.snapshot,
114
+ portfolio_value=100000.0,
115
+ )
116
+
117
+ self.assertTrue(assessment.rebalance_approved)
118
+ self.assertEqual(assessment.overall_risk, RiskLevel.LOW)
119
+
120
+ def test_usdy_depeg_detection(self):
121
+ self.snapshot.usdy_peg = 0.99 # 1% depeg
122
+ weights = np.array([0.50, 0.30, 0.20])
123
+ current = np.array([0.40, 0.35, 0.25])
124
+
125
+ assessment = self.risk_mgr.assess_risk(
126
+ proposed_weights=weights,
127
+ current_weights=current,
128
+ snapshot=self.snapshot,
129
+ portfolio_value=100000.0,
130
+ )
131
+
132
+ self.assertGreater(assessment.depeg_risk, 0)
133
+ self.assertTrue(any("USDY depeg" in w for w in assessment.warnings))
134
+
135
+ def test_meth_depeg_detection(self):
136
+ self.snapshot.meth_peg = 0.95 # 5% depeg
137
+ weights = np.array([0.30, 0.40, 0.30])
138
+ current = np.array([0.40, 0.35, 0.25])
139
+
140
+ assessment = self.risk_mgr.assess_risk(
141
+ proposed_weights=weights,
142
+ current_weights=current,
143
+ snapshot=self.snapshot,
144
+ portfolio_value=100000.0,
145
+ )
146
+
147
+ self.assertGreater(assessment.depeg_risk, 0)
148
+ self.assertTrue(assessment.emergency_exit_recommended)
149
+
150
+ def test_high_volatility_adjustment(self):
151
+ self.snapshot.eth_volatility_30d = 1.2 # Very high vol
152
+ weights = np.array([0.20, 0.50, 0.30])
153
+ current = np.array([0.40, 0.35, 0.25])
154
+
155
+ assessment = self.risk_mgr.assess_risk(
156
+ proposed_weights=weights,
157
+ current_weights=current,
158
+ snapshot=self.snapshot,
159
+ portfolio_value=100000.0,
160
+ )
161
+
162
+ # Should reduce mETH allocation due to high vol
163
+ if assessment.adjusted_weights is not None:
164
+ self.assertLess(assessment.adjusted_weights[1], 0.50)
165
+
166
+ def test_concentration_limits(self):
167
+ weights = np.array([0.80, 0.10, 0.10]) # Over-concentrated
168
+ current = np.array([0.40, 0.35, 0.25])
169
+
170
+ assessment = self.risk_mgr.assess_risk(
171
+ proposed_weights=weights,
172
+ current_weights=current,
173
+ snapshot=self.snapshot,
174
+ portfolio_value=100000.0,
175
+ )
176
+
177
+ self.assertGreater(assessment.concentration_risk, 0)
178
+ if assessment.adjusted_weights is not None:
179
+ # Risk manager should cap the max weight (may exceed 0.60 after normalization)
180
+ self.assertLessEqual(assessment.adjusted_weights[0], 0.80)
181
+
182
+ def test_circuit_breaker_on_drawdown(self):
183
+ # Simulate high peak value and low current value
184
+ self.risk_mgr.peak_portfolio_value = 100000.0
185
+ weights = np.array([0.40, 0.35, 0.25])
186
+ current = np.array([0.40, 0.35, 0.25])
187
+
188
+ assessment = self.risk_mgr.assess_risk(
189
+ proposed_weights=weights,
190
+ current_weights=current,
191
+ snapshot=self.snapshot,
192
+ portfolio_value=85000.0, # 15% drawdown
193
+ )
194
+
195
+ self.assertTrue(assessment.circuit_breaker_triggered)
196
+ self.assertFalse(assessment.rebalance_approved)
197
+
198
+ def test_emergency_exit_weights(self):
199
+ weights = self.risk_mgr.get_emergency_exit_weights()
200
+ self.assertAlmostEqual(sum(weights), 1.0)
201
+ self.assertEqual(weights[0], 0.90) # 90% USDY
202
+
203
+
204
+ class TestExecutor(unittest.TestCase):
205
+ """Test the on-chain execution layer."""
206
+
207
+ def setUp(self):
208
+ self.executor = OnChainExecutor(
209
+ wallet_address="0x742d35Cc6634C0532925a3b844Bc9e7595f2bD18",
210
+ )
211
+
212
+ def test_whitelisted_contracts(self):
213
+ self.assertTrue(self.executor.is_whitelisted(Tokens.USDY))
214
+ self.assertTrue(self.executor.is_whitelisted(Tokens.METH))
215
+ self.assertFalse(self.executor.is_whitelisted("0x0000000000000000000000000000000000000001"))
216
+
217
+ def test_build_rebalance_plan(self):
218
+ plan = self.executor.build_rebalance_plan(
219
+ current_weights=[0.40, 0.35, 0.25],
220
+ target_weights=[0.30, 0.40, 0.30],
221
+ portfolio_value_usd=100000.0,
222
+ asset_prices={"USDY": 1.0, "mETH": 3200.0, "MI4": 100.0},
223
+ )
224
+
225
+ self.assertIsNotNone(plan)
226
+ self.assertGreater(len(plan.trades), 0)
227
+ self.assertGreater(len(plan.approvals), 0)
228
+ self.assertGreater(plan.estimated_gas_usd, 0)
229
+ self.assertIn("Rebalance", plan.human_summary)
230
+
231
+ def test_no_trades_when_weights_similar(self):
232
+ plan = self.executor.build_rebalance_plan(
233
+ current_weights=[0.40, 0.35, 0.25],
234
+ target_weights=[0.401, 0.349, 0.250], # minimal change
235
+ portfolio_value_usd=100000.0,
236
+ asset_prices={"USDY": 1.0, "mETH": 3200.0, "MI4": 100.0},
237
+ )
238
+
239
+ # Small changes should produce no trades (< 0.5% delta filter)
240
+ self.assertEqual(len(plan.trades), 0)
241
+
242
+ def test_unsigned_tx_format(self):
243
+ plan = self.executor.build_rebalance_plan(
244
+ current_weights=[0.40, 0.35, 0.25],
245
+ target_weights=[0.25, 0.45, 0.30],
246
+ portfolio_value_usd=100000.0,
247
+ asset_prices={"USDY": 1.0, "mETH": 3200.0, "MI4": 100.0},
248
+ )
249
+
250
+ for tx in plan.trades + plan.approvals:
251
+ tx_dict = tx.to_dict()
252
+ self.assertIn("to", tx_dict)
253
+ self.assertIn("data", tx_dict)
254
+ self.assertTrue(tx_dict["data"].startswith("0x"))
255
+ self.assertEqual(tx_dict["chainId"], 5000)
256
+
257
+ def test_aave_supply_transactions(self):
258
+ txs = self.executor.build_aave_supply(
259
+ asset=Tokens.USDY,
260
+ amount=1000 * 10**18,
261
+ asset_symbol="USDY",
262
+ )
263
+
264
+ self.assertEqual(len(txs), 2) # approve + supply
265
+ self.assertIn("Approve", txs[0].human_summary)
266
+ self.assertIn("Supply", txs[1].human_summary)
267
+
268
+
269
+ class TestStrategyReporter(unittest.TestCase):
270
+ """Test the strategy report generator."""
271
+
272
+ def setUp(self):
273
+ self.reporter = StrategyReporter(use_llm=False)
274
+
275
+ def test_template_report_generation(self):
276
+ report = self.reporter.generate_report(
277
+ current_weights={"USDY": 0.40, "mETH": 0.35, "MI4": 0.25},
278
+ portfolio_value=105000.0,
279
+ period_return=5.0,
280
+ yield_data={"usdy": 4.25, "meth": 3.8, "mi4": 5.0},
281
+ risk_summary={
282
+ "latest_risk_level": "low",
283
+ "latest_risk_score": 0.15,
284
+ "circuit_breaker": "closed",
285
+ "current_drawdown": 0.01,
286
+ "peak_value": 106000,
287
+ "total_assessments": 10,
288
+ "total_warnings": 2,
289
+ },
290
+ execution_history=[],
291
+ )
292
+
293
+ self.assertIsNotNone(report)
294
+ self.assertGreater(len(report.full_report), 100)
295
+ self.assertIn("USDY", report.full_report)
296
+ self.assertIn("mETH", report.full_report)
297
+ self.assertTrue(report.content_hash.startswith("0x"))
298
+
299
+ def test_telegram_message_format(self):
300
+ report = self.reporter.generate_report(
301
+ current_weights={"USDY": 0.40, "mETH": 0.35, "MI4": 0.25},
302
+ portfolio_value=100000.0,
303
+ period_return=2.5,
304
+ yield_data={"usdy": 4.25, "meth": 3.8, "mi4": 5.0},
305
+ risk_summary={
306
+ "latest_risk_level": "low",
307
+ "latest_risk_score": 0.1,
308
+ "circuit_breaker": "closed",
309
+ "current_drawdown": 0.0,
310
+ "peak_value": 100000,
311
+ "total_assessments": 5,
312
+ "total_warnings": 0,
313
+ },
314
+ execution_history=[],
315
+ )
316
+
317
+ tg_msg = report.to_telegram_message()
318
+ self.assertIn("Weekly Report", tg_msg)
319
+ self.assertIn("$100,000.00", tg_msg)
320
+ self.assertLess(len(tg_msg), 4096) # Telegram limit
321
+
322
+
323
+ class TestPPOOptimizer(unittest.TestCase):
324
+ """Test the PPO yield optimizer."""
325
+
326
+ def setUp(self):
327
+ self.optimizer = PPOYieldOptimizer(total_timesteps=100)
328
+
329
+ def test_predict_returns_valid_weights(self):
330
+ state = np.random.randn(18).astype(np.float32)
331
+ weights = self.optimizer.predict(state)
332
+
333
+ self.assertEqual(len(weights), 3)
334
+ self.assertAlmostEqual(sum(weights), 1.0, places=4)
335
+ for w in weights:
336
+ self.assertGreaterEqual(w, 0.04)
337
+ self.assertLessEqual(w, 0.61)
338
+
339
+ def test_save_and_load(self):
340
+ import tempfile
341
+ with tempfile.TemporaryDirectory() as tmpdir:
342
+ path = os.path.join(tmpdir, "test_model")
343
+ self.optimizer.save(path)
344
+
345
+ # Create new optimizer and load
346
+ new_optimizer = PPOYieldOptimizer()
347
+ new_optimizer.load(path)
348
+
349
+ state = np.random.randn(18).astype(np.float32)
350
+ w1 = self.optimizer.predict(state)
351
+ w2 = new_optimizer.predict(state)
352
+
353
+ # Should produce same weights
354
+ np.testing.assert_array_almost_equal(w1, w2, decimal=3)
355
+
356
+
357
+ class TestDataPipeline(unittest.TestCase):
358
+ """Test the data pipeline."""
359
+
360
+ def test_yield_snapshot_to_state_vector(self):
361
+ snapshot = YieldSnapshot(
362
+ timestamp=time.time(),
363
+ usdy_apy=4.25,
364
+ meth_apy=3.8,
365
+ mi4_apy=5.0,
366
+ eth_price=3200,
367
+ btc_price=62000,
368
+ mnt_price=0.7,
369
+ usdy_peg=1.0,
370
+ meth_peg=1.0,
371
+ fed_funds_rate=5.25,
372
+ btc_dominance=50.0,
373
+ eth_volatility_30d=0.5,
374
+ )
375
+
376
+ state = snapshot.to_state_vector()
377
+ self.assertEqual(state.shape, (15,))
378
+ self.assertFalse(np.any(np.isnan(state)))
379
+ self.assertTrue(np.all(np.abs(state) < 10)) # normalized values
380
+
381
+ def test_compute_volatility(self):
382
+ pipeline = DataPipeline()
383
+ prices = [100, 102, 101, 103, 105, 104, 106]
384
+ vol = pipeline.compute_volatility(prices)
385
+ self.assertGreater(vol, 0)
386
+ self.assertLess(vol, 5) # reasonable annualized vol
387
+
388
+ def test_compute_volatility_empty(self):
389
+ pipeline = DataPipeline()
390
+ vol = pipeline.compute_volatility([])
391
+ self.assertEqual(vol, 0.0)
392
+
393
+
394
+ class TestIntegration(unittest.TestCase):
395
+ """Integration tests for the full pipeline."""
396
+
397
+ def test_full_cycle_simulation(self):
398
+ """Run one full agent cycle in simulation mode."""
399
+ from agent.main import YieldRouterAgent
400
+
401
+ agent = YieldRouterAgent(
402
+ wallet_address="0x742d35Cc6634C0532925a3b844Bc9e7595f2bD18",
403
+ initial_capital=100000.0,
404
+ )
405
+
406
+ # Simulate a snapshot (skip async data fetching)
407
+ snapshot = YieldSnapshot(
408
+ timestamp=time.time(),
409
+ usdy_apy=4.25,
410
+ meth_apy=3.8,
411
+ mi4_apy=5.0,
412
+ eth_price=3200,
413
+ btc_price=62000,
414
+ mnt_price=0.7,
415
+ usdy_peg=1.0,
416
+ meth_peg=1.0,
417
+ fed_funds_rate=5.25,
418
+ btc_dominance=50.0,
419
+ eth_volatility_30d=0.5,
420
+ )
421
+ agent.latest_snapshot = snapshot
422
+
423
+ # Stage 2: Reason
424
+ target_weights = agent.reason(snapshot)
425
+ self.assertEqual(len(target_weights), 3)
426
+ self.assertAlmostEqual(sum(target_weights), 1.0, places=3)
427
+
428
+ # Stage 3: Plan (force rebalance by setting old timestamp)
429
+ agent.state.last_rebalance_time = 0
430
+ plan = agent.plan(target_weights, snapshot)
431
+
432
+ # Plan may or may not exist depending on weight drift
433
+ if plan is not None:
434
+ # Stage 4: Authorize
435
+ approved = agent.authorize(plan, snapshot)
436
+ self.assertIsInstance(approved, bool)
437
+
438
+ if approved:
439
+ # Stage 5: Execute
440
+ result = agent.execute(plan)
441
+ self.assertIn("trades", result)
442
+
443
+ # Stage 6: Verify
444
+ loop = asyncio.new_event_loop()
445
+ verified = loop.run_until_complete(agent.verify(result))
446
+ loop.close()
447
+ self.assertTrue(verified)
448
+
449
+
450
+ if __name__ == "__main__":
451
+ logging.basicConfig(level=logging.WARNING)
452
+ unittest.main(verbosity=2)