muthuk1 commited on
Commit
eb1eb1b
Β·
verified Β·
1 Parent(s): 513f2e0

Add agent/main.py

Browse files
Files changed (1) hide show
  1. agent/main.py +658 -0
agent/main.py ADDED
@@ -0,0 +1,658 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Agent Orchestrator β€” 6-Stage Pipeline: Observe β†’ Reason β†’ Plan β†’ Authorize β†’ Execute β†’ Verify
3
+ ===============================================================================================
4
+ Main entry point for the Dynamic RWA Yield Router. Orchestrates the full
5
+ autonomous agent loop with configurable intervals and safety guardrails.
6
+ """
7
+
8
+ import asyncio
9
+ import json
10
+ import logging
11
+ import os
12
+ import signal
13
+ import sys
14
+ import time
15
+ from datetime import datetime, timezone
16
+ from typing import Dict, List, Optional
17
+
18
+ import numpy as np
19
+
20
+ from config.constants import (
21
+ MANTLE_CHAIN_ID,
22
+ MANTLE_RPC_URL,
23
+ PortfolioConfig,
24
+ RiskConfig,
25
+ RLConfig,
26
+ AGENT_METADATA,
27
+ LOG_FORMAT,
28
+ LOG_DATE_FORMAT,
29
+ LOG_LEVEL,
30
+ )
31
+ from agent.data_pipeline import DataPipeline, YieldSnapshot
32
+ from agent.rl_optimizer import PPOYieldOptimizer, RWAYieldEnv, Backtester
33
+ from agent.risk_manager import RiskManager, RiskLevel
34
+ from agent.executor import OnChainExecutor, RebalancePlan
35
+ from agent.strategy_reporter import StrategyReporter
36
+
37
+ logger = logging.getLogger("orchestrator")
38
+
39
+
40
+ class AgentState:
41
+ """Mutable agent state tracking."""
42
+
43
+ def __init__(self, initial_capital: float = 100_000.0):
44
+ self.portfolio_value: float = initial_capital
45
+ self.current_weights: np.ndarray = np.array([0.40, 0.35, 0.25]) # USDY, mETH, MI4
46
+ self.target_weights: Optional[np.ndarray] = None
47
+
48
+ self.last_rebalance_time: float = 0.0
49
+ self.total_rebalances: int = 0
50
+ self.total_gas_spent_usd: float = 0.0
51
+
52
+ self.pending_plans: List[RebalancePlan] = []
53
+ self.executed_plans: List[RebalancePlan] = []
54
+
55
+ # Performance tracking
56
+ self.initial_capital = initial_capital
57
+ self.peak_value = initial_capital
58
+ self.cumulative_yield_pct: float = 0.0
59
+ self.start_time: float = time.time()
60
+
61
+ def to_dict(self) -> Dict:
62
+ return {
63
+ "portfolio_value": self.portfolio_value,
64
+ "current_weights": {
65
+ "USDY": float(self.current_weights[0]),
66
+ "mETH": float(self.current_weights[1]),
67
+ "MI4": float(self.current_weights[2]),
68
+ },
69
+ "total_return_pct": (self.portfolio_value / self.initial_capital - 1) * 100,
70
+ "total_rebalances": self.total_rebalances,
71
+ "total_gas_spent": self.total_gas_spent_usd,
72
+ "uptime_hours": (time.time() - self.start_time) / 3600,
73
+ }
74
+
75
+
76
+ class YieldRouterAgent:
77
+ """
78
+ Autonomous AI agent for RWA yield optimization on Mantle L2.
79
+
80
+ Pipeline Stages:
81
+ 1. OBSERVE β€” Fetch real-time yield, price, and macro data
82
+ 2. REASON β€” RL policy predicts optimal allocation weights
83
+ 3. PLAN β€” Compute rebalancing trades if weights deviate
84
+ 4. AUTHORIZE β€” Risk manager validates the plan
85
+ 5. EXECUTE β€” Construct unsigned transactions
86
+ 6. VERIFY β€” Confirm execution and update state
87
+
88
+ Safety:
89
+ - Circuit breaker halts trading on excessive drawdown or depeg
90
+ - Position limits enforce diversification (5-60% per asset)
91
+ - Rebalancing cooldown prevents excessive trading
92
+ - All transactions are unsigned β€” requires external signing
93
+ """
94
+
95
+ def __init__(
96
+ self,
97
+ wallet_address: str = "0x0000000000000000000000000000000000000000",
98
+ initial_capital: float = 100_000.0,
99
+ portfolio_config: Optional[PortfolioConfig] = None,
100
+ risk_config: Optional[RiskConfig] = None,
101
+ rl_config: Optional[RLConfig] = None,
102
+ rpc_url: str = MANTLE_RPC_URL,
103
+ model_path: Optional[str] = None,
104
+ ):
105
+ self.wallet = wallet_address
106
+ self.portfolio_cfg = portfolio_config or PortfolioConfig()
107
+ self.risk_cfg = risk_config or RiskConfig()
108
+ self.rl_cfg = rl_config or RLConfig()
109
+
110
+ # Initialize components
111
+ self.data_pipeline = DataPipeline(rpc_url=rpc_url)
112
+ self.rl_optimizer = PPOYieldOptimizer(
113
+ model_path=model_path or "models/ppo_yield_router",
114
+ learning_rate=self.rl_cfg.learning_rate,
115
+ total_timesteps=self.rl_cfg.total_timesteps,
116
+ )
117
+ self.risk_manager = RiskManager(
118
+ risk_config=self.risk_cfg,
119
+ portfolio_config=self.portfolio_cfg,
120
+ )
121
+ self.executor = OnChainExecutor(
122
+ wallet_address=wallet_address,
123
+ portfolio_config=self.portfolio_cfg,
124
+ )
125
+ self.strategy_reporter = StrategyReporter(use_llm=bool(os.getenv("OPENAI_API_KEY")))
126
+
127
+ # State
128
+ self.state = AgentState(initial_capital=initial_capital)
129
+
130
+ # Latest market data
131
+ self.latest_snapshot: Optional[YieldSnapshot] = None
132
+
133
+ # Running flag
134
+ self._running = False
135
+
136
+ logger.info(
137
+ f"YieldRouterAgent initialized | wallet={wallet_address[:10]}... | "
138
+ f"capital=${initial_capital:,.0f} | chain={MANTLE_CHAIN_ID}"
139
+ )
140
+
141
+ # ────────────────────── Stage 1: OBSERVE ──────────────────────
142
+
143
+ async def observe(self) -> YieldSnapshot:
144
+ """
145
+ Stage 1: Fetch all market data and construct observation state.
146
+
147
+ Data sources: DeFiLlama, CoinGecko/Bybit, Mantle RPC, FRED
148
+ Output: YieldSnapshot with normalized state vector
149
+ """
150
+ logger.info("πŸ“‘ Stage 1: OBSERVE β€” fetching market data...")
151
+
152
+ snapshot = await self.data_pipeline.get_snapshot()
153
+ self.latest_snapshot = snapshot
154
+
155
+ logger.info(
156
+ f" Yields: USDY={snapshot.usdy_apy:.2f}% mETH={snapshot.meth_apy:.2f}% MI4={snapshot.mi4_apy:.2f}%"
157
+ )
158
+ logger.info(
159
+ f" Prices: ETH=${snapshot.eth_price:.0f} BTC=${snapshot.btc_price:.0f} MNT=${snapshot.mnt_price:.3f}"
160
+ )
161
+ logger.info(
162
+ f" Health: USDY_peg={snapshot.usdy_peg:.4f} mETH_peg={snapshot.meth_peg:.4f}"
163
+ )
164
+
165
+ return snapshot
166
+
167
+ # ────────────────────── Stage 2: REASON ──────────────────────
168
+
169
+ def reason(self, snapshot: YieldSnapshot) -> np.ndarray:
170
+ """
171
+ Stage 2: RL policy predicts optimal portfolio weights.
172
+
173
+ Input: Normalized state vector from snapshot + current weights
174
+ Output: Target weights [USDY, mETH, MI4]
175
+ """
176
+ logger.info("🧠 Stage 2: REASON β€” RL policy inference...")
177
+
178
+ # Construct full state: market features + current portfolio weights
179
+ market_state = snapshot.to_state_vector()
180
+ portfolio_state = self.state.current_weights
181
+ full_state = np.concatenate([market_state, portfolio_state])
182
+
183
+ # Predict optimal weights
184
+ target_weights = self.rl_optimizer.predict(full_state)
185
+
186
+ logger.info(
187
+ f" Target weights: USDY={target_weights[0]:.3f} "
188
+ f"mETH={target_weights[1]:.3f} MI4={target_weights[2]:.3f}"
189
+ )
190
+
191
+ return target_weights
192
+
193
+ # ────────────────────── Stage 3: PLAN ──────────────────────
194
+
195
+ def plan(
196
+ self, target_weights: np.ndarray, snapshot: YieldSnapshot
197
+ ) -> Optional[RebalancePlan]:
198
+ """
199
+ Stage 3: Compute rebalancing plan if weights deviate enough.
200
+
201
+ Checks:
202
+ - Weight drift exceeds rebalance threshold
203
+ - Minimum time since last rebalance
204
+ - Gas cost vs. expected benefit
205
+
206
+ Output: RebalancePlan or None (if no rebalance needed)
207
+ """
208
+ logger.info("πŸ“‹ Stage 3: PLAN β€” computing rebalancing trades...")
209
+
210
+ # Check rebalance cooldown
211
+ hours_since_rebalance = (time.time() - self.state.last_rebalance_time) / 3600
212
+ if hours_since_rebalance < self.portfolio_cfg.min_rebalance_interval_hours:
213
+ logger.info(
214
+ f" ⏱️ Cooldown: {hours_since_rebalance:.1f}h since last rebalance "
215
+ f"(min: {self.portfolio_cfg.min_rebalance_interval_hours}h)"
216
+ )
217
+ return None
218
+
219
+ # Check weight drift
220
+ drift = np.sum(np.abs(target_weights - self.state.current_weights))
221
+ if drift < self.portfolio_cfg.rebalance_threshold_pct:
222
+ logger.info(f" βœ… Weights within threshold (drift={drift:.4f})")
223
+ return None
224
+
225
+ # Estimate asset prices (for trade sizing)
226
+ asset_prices = {
227
+ "USDY": 1.0, # pegged to USD
228
+ "mETH": snapshot.eth_price * snapshot.meth_peg,
229
+ "MI4": 100.0, # approximate NAV
230
+ }
231
+
232
+ # Build rebalance plan
233
+ plan = self.executor.build_rebalance_plan(
234
+ current_weights=self.state.current_weights.tolist(),
235
+ target_weights=target_weights.tolist(),
236
+ portfolio_value_usd=self.state.portfolio_value,
237
+ asset_prices=asset_prices,
238
+ )
239
+
240
+ logger.info(f" Plan: {plan.human_summary}")
241
+ return plan
242
+
243
+ # ────────────────────── Stage 4: AUTHORIZE ──────────────────────
244
+
245
+ def authorize(
246
+ self, plan: RebalancePlan, snapshot: YieldSnapshot
247
+ ) -> bool:
248
+ """
249
+ Stage 4: Risk manager validates the rebalancing plan.
250
+
251
+ Checks:
252
+ - Depeg detection
253
+ - Volatility regime
254
+ - Position limits
255
+ - Drawdown protection
256
+ - Circuit breaker state
257
+
258
+ Output: True if plan is approved, False if blocked
259
+ """
260
+ logger.info("πŸ›‘οΈ Stage 4: AUTHORIZE β€” risk assessment...")
261
+
262
+ target_weights = np.array(plan.target_weights)
263
+
264
+ assessment = self.risk_manager.assess_risk(
265
+ proposed_weights=target_weights,
266
+ current_weights=self.state.current_weights,
267
+ snapshot=snapshot,
268
+ portfolio_value=self.state.portfolio_value,
269
+ )
270
+
271
+ if assessment.warnings:
272
+ for warning in assessment.warnings:
273
+ logger.warning(f" {warning}")
274
+
275
+ if assessment.emergency_exit_recommended:
276
+ logger.critical(" 🚨 EMERGENCY EXIT recommended!")
277
+ # Override with safe-haven weights
278
+ safe_weights = self.risk_manager.get_emergency_exit_weights()
279
+ self.state.target_weights = safe_weights
280
+
281
+ if assessment.adjusted_weights is not None and not np.allclose(
282
+ assessment.adjusted_weights, target_weights
283
+ ):
284
+ logger.info(
285
+ f" Weights adjusted by risk manager: "
286
+ f"{assessment.adjusted_weights.tolist()}"
287
+ )
288
+ self.state.target_weights = assessment.adjusted_weights
289
+
290
+ logger.info(
291
+ f" Risk: {assessment.overall_risk.value} "
292
+ f"(score={assessment.risk_score:.3f}) "
293
+ f"Approved: {assessment.rebalance_approved}"
294
+ )
295
+
296
+ return assessment.rebalance_approved
297
+
298
+ # ────────────────────── Stage 5: EXECUTE ──────────────────────
299
+
300
+ def execute(self, plan: RebalancePlan) -> Dict:
301
+ """
302
+ Stage 5: Output unsigned transactions for external signing.
303
+
304
+ In production, these transactions would be submitted to an
305
+ ERC-4337 bundler or multisig for execution. This agent only
306
+ constructs the payloads β€” it never holds private keys.
307
+
308
+ Output: Dict with all transaction payloads
309
+ """
310
+ logger.info("⚑ Stage 5: EXECUTE β€” constructing transactions...")
311
+
312
+ result = {
313
+ "plan": plan.to_dict(),
314
+ "approvals": [tx.to_dict() for tx in plan.approvals],
315
+ "trades": [tx.to_dict() for tx in plan.trades],
316
+ "total_transactions": len(plan.approvals) + len(plan.trades),
317
+ "estimated_gas_usd": plan.estimated_gas_usd,
318
+ }
319
+
320
+ logger.info(
321
+ f" Transactions ready: {result['total_transactions']} "
322
+ f"({len(plan.approvals)} approvals + {len(plan.trades)} swaps)"
323
+ )
324
+
325
+ # In simulation mode, we update state directly
326
+ self.state.current_weights = np.array(plan.target_weights)
327
+ self.state.last_rebalance_time = time.time()
328
+ self.state.total_rebalances += 1
329
+ self.state.total_gas_spent_usd += plan.estimated_gas_usd
330
+ self.state.executed_plans.append(plan)
331
+
332
+ return result
333
+
334
+ # ────────────────────── Stage 6: VERIFY ──────────────────────
335
+
336
+ async def verify(self, execution_result: Dict) -> bool:
337
+ """
338
+ Stage 6: Verify execution and update portfolio state.
339
+
340
+ In production, this would check on-chain transaction receipts.
341
+ In simulation, we verify state consistency.
342
+ """
343
+ logger.info("βœ… Stage 6: VERIFY β€” confirming execution...")
344
+
345
+ # Verify weights sum to 1
346
+ weight_sum = self.state.current_weights.sum()
347
+ if abs(weight_sum - 1.0) > 0.001:
348
+ logger.error(f" ❌ Weight sum error: {weight_sum:.4f}")
349
+ self.state.current_weights /= weight_sum
350
+ return False
351
+
352
+ # Verify no negative weights
353
+ if np.any(self.state.current_weights < 0):
354
+ logger.error(" ❌ Negative weights detected!")
355
+ return False
356
+
357
+ # Update portfolio value (simulate yield accrual)
358
+ if self.latest_snapshot:
359
+ yields = np.array([
360
+ self.latest_snapshot.usdy_apy,
361
+ self.latest_snapshot.meth_apy,
362
+ self.latest_snapshot.mi4_apy,
363
+ ])
364
+ # Hourly yield accrual
365
+ hourly_yield = np.dot(self.state.current_weights, yields / 100 / 365 / 24)
366
+ self.state.portfolio_value *= (1 + hourly_yield)
367
+ self.state.peak_value = max(self.state.peak_value, self.state.portfolio_value)
368
+
369
+ logger.info(
370
+ f" Portfolio: ${self.state.portfolio_value:,.2f} | "
371
+ f"Weights: {self.state.current_weights.tolist()} | "
372
+ f"Rebalances: {self.state.total_rebalances}"
373
+ )
374
+
375
+ return True
376
+
377
+ # ────────────────────── Full Pipeline ──────────────────────
378
+
379
+ async def run_cycle(self) -> Dict:
380
+ """
381
+ Execute one full observation-to-verification cycle.
382
+
383
+ Returns a summary dict of the cycle results.
384
+ """
385
+ cycle_start = time.time()
386
+
387
+ try:
388
+ # Stage 1: Observe
389
+ snapshot = await self.observe()
390
+
391
+ # Stage 2: Reason
392
+ target_weights = self.reason(snapshot)
393
+
394
+ # Stage 3: Plan
395
+ plan = self.plan(target_weights, snapshot)
396
+
397
+ if plan is None:
398
+ logger.info("πŸ”„ Cycle complete: no rebalance needed")
399
+ # Still verify state
400
+ await self.verify({})
401
+ return {
402
+ "status": "no_rebalance",
403
+ "cycle_time_s": time.time() - cycle_start,
404
+ "state": self.state.to_dict(),
405
+ }
406
+
407
+ # Stage 4: Authorize
408
+ approved = self.authorize(plan, snapshot)
409
+
410
+ if not approved:
411
+ logger.warning("🚫 Cycle complete: rebalance blocked by risk manager")
412
+ return {
413
+ "status": "blocked",
414
+ "cycle_time_s": time.time() - cycle_start,
415
+ "state": self.state.to_dict(),
416
+ "risk": self.risk_manager.get_risk_summary(),
417
+ }
418
+
419
+ # Stage 5: Execute
420
+ execution_result = self.execute(plan)
421
+
422
+ # Stage 6: Verify
423
+ verified = await self.verify(execution_result)
424
+
425
+ status = "success" if verified else "verification_failed"
426
+ logger.info(f"🏁 Cycle complete: {status} in {time.time() - cycle_start:.1f}s")
427
+
428
+ return {
429
+ "status": status,
430
+ "cycle_time_s": time.time() - cycle_start,
431
+ "execution": execution_result,
432
+ "state": self.state.to_dict(),
433
+ }
434
+
435
+ except Exception as e:
436
+ logger.error(f"❌ Cycle error: {e}", exc_info=True)
437
+ return {
438
+ "status": "error",
439
+ "error": str(e),
440
+ "cycle_time_s": time.time() - cycle_start,
441
+ "state": self.state.to_dict(),
442
+ }
443
+
444
+ # ────────────────────── Continuous Operation ──────────────────
445
+
446
+ async def run(
447
+ self,
448
+ interval_seconds: int = 3600, # default: every hour
449
+ max_cycles: Optional[int] = None,
450
+ generate_reports: bool = True,
451
+ report_interval_cycles: int = 168, # weekly at hourly intervals
452
+ ):
453
+ """
454
+ Run the agent in continuous loop.
455
+
456
+ Args:
457
+ interval_seconds: Time between cycles
458
+ max_cycles: Stop after N cycles (None = run forever)
459
+ generate_reports: Whether to generate strategy reports
460
+ report_interval_cycles: How often to generate reports
461
+ """
462
+ self._running = True
463
+ cycle_count = 0
464
+
465
+ logger.info(
466
+ f"πŸš€ Agent starting | interval={interval_seconds}s | "
467
+ f"max_cycles={'∞' if max_cycles is None else max_cycles}"
468
+ )
469
+
470
+ while self._running:
471
+ cycle_count += 1
472
+
473
+ if max_cycles and cycle_count > max_cycles:
474
+ logger.info(f"Reached max cycles ({max_cycles}), stopping.")
475
+ break
476
+
477
+ logger.info(f"\n{'='*60}")
478
+ logger.info(f"Cycle #{cycle_count} | {datetime.now(timezone.utc).isoformat()}")
479
+ logger.info(f"{'='*60}")
480
+
481
+ result = await self.run_cycle()
482
+
483
+ # Generate strategy report periodically
484
+ if generate_reports and cycle_count % report_interval_cycles == 0:
485
+ await self._generate_report()
486
+
487
+ # Log state
488
+ state = self.state.to_dict()
489
+ logger.info(
490
+ f"State: value=${state['portfolio_value']:,.2f} | "
491
+ f"return={state['total_return_pct']:+.2f}% | "
492
+ f"rebalances={state['total_rebalances']} | "
493
+ f"gas=${state['total_gas_spent']:,.4f}"
494
+ )
495
+
496
+ # Save state checkpoint
497
+ self._save_checkpoint()
498
+
499
+ if self._running and (max_cycles is None or cycle_count < max_cycles):
500
+ logger.info(f"πŸ’€ Sleeping {interval_seconds}s until next cycle...")
501
+ await asyncio.sleep(interval_seconds)
502
+
503
+ logger.info("πŸ›‘ Agent stopped.")
504
+ await self.data_pipeline.close()
505
+
506
+ async def _generate_report(self):
507
+ """Generate and log a strategy report."""
508
+ try:
509
+ report = self.strategy_reporter.generate_report(
510
+ current_weights={
511
+ "USDY": float(self.state.current_weights[0]),
512
+ "mETH": float(self.state.current_weights[1]),
513
+ "MI4": float(self.state.current_weights[2]),
514
+ },
515
+ portfolio_value=self.state.portfolio_value,
516
+ period_return=(self.state.portfolio_value / self.state.initial_capital - 1) * 100,
517
+ yield_data={
518
+ "usdy": self.latest_snapshot.usdy_apy if self.latest_snapshot else 4.25,
519
+ "meth": self.latest_snapshot.meth_apy if self.latest_snapshot else 3.5,
520
+ "mi4": self.latest_snapshot.mi4_apy if self.latest_snapshot else 5.0,
521
+ },
522
+ risk_summary=self.risk_manager.get_risk_summary(),
523
+ execution_history=self.executor.get_execution_history(),
524
+ )
525
+
526
+ logger.info(f"\nπŸ“Š Strategy Report Generated:\n{report.full_report[:500]}...")
527
+
528
+ # Save report
529
+ os.makedirs("reports", exist_ok=True)
530
+ with open(f"reports/{report.report_id}.json", "w") as f:
531
+ json.dump(report.to_dict(), f, indent=2, default=str)
532
+
533
+ except Exception as e:
534
+ logger.error(f"Report generation failed: {e}")
535
+
536
+ def _save_checkpoint(self):
537
+ """Save agent state checkpoint."""
538
+ os.makedirs("checkpoints", exist_ok=True)
539
+ checkpoint = {
540
+ "timestamp": time.time(),
541
+ "state": self.state.to_dict(),
542
+ "risk": self.risk_manager.get_risk_summary(),
543
+ "agent_metadata": AGENT_METADATA,
544
+ }
545
+ with open("checkpoints/latest.json", "w") as f:
546
+ json.dump(checkpoint, f, indent=2, default=str)
547
+
548
+ def stop(self):
549
+ """Signal the agent to stop after current cycle."""
550
+ self._running = False
551
+ logger.info("Stop signal received.")
552
+
553
+ # ────────────────────── Training ──────────────────────
554
+
555
+ def train_rl_agent(self, total_timesteps: Optional[int] = None):
556
+ """Train or retrain the RL policy."""
557
+ logger.info("πŸŽ“ Training RL agent...")
558
+ self.rl_optimizer.train(total_timesteps=total_timesteps)
559
+ logger.info("βœ… RL training complete.")
560
+
561
+ def backtest(self, n_episodes: int = 10) -> Dict:
562
+ """Run backtesting simulation."""
563
+ logger.info(f"πŸ“Š Running backtest ({n_episodes} episodes)...")
564
+ env = RWAYieldEnv()
565
+ backtester = Backtester(self.rl_optimizer, env)
566
+ results = backtester.run_backtest(n_episodes)
567
+
568
+ if results.get("rl_agent"):
569
+ avg_return = np.mean([r["total_return"] for r in results["rl_agent"]])
570
+ avg_sharpe = np.mean([r["sharpe"] for r in results["rl_agent"]])
571
+ avg_dd = np.mean([r["max_drawdown"] for r in results["rl_agent"]])
572
+
573
+ logger.info(
574
+ f"Backtest results: "
575
+ f"Avg Return={avg_return:.2f}% | "
576
+ f"Avg Sharpe={avg_sharpe:.2f} | "
577
+ f"Avg MaxDD={avg_dd:.4f}"
578
+ )
579
+
580
+ return results
581
+
582
+
583
+ # ─────────────────────── CLI Entry Point ────────────────────────────
584
+
585
+ def setup_logging():
586
+ """Configure logging."""
587
+ logging.basicConfig(
588
+ level=getattr(logging, LOG_LEVEL),
589
+ format=LOG_FORMAT,
590
+ datefmt=LOG_DATE_FORMAT,
591
+ handlers=[
592
+ logging.StreamHandler(sys.stdout),
593
+ logging.FileHandler("agent.log", mode="a"),
594
+ ],
595
+ )
596
+
597
+
598
+ async def main():
599
+ """CLI entry point."""
600
+ import argparse
601
+
602
+ parser = argparse.ArgumentParser(description="Dynamic RWA Yield Router Agent")
603
+ parser.add_argument("--mode", choices=["run", "train", "backtest", "demo"], default="demo")
604
+ parser.add_argument("--wallet", type=str, default="0x" + "0" * 40)
605
+ parser.add_argument("--capital", type=float, default=100_000.0)
606
+ parser.add_argument("--interval", type=int, default=3600, help="Seconds between cycles")
607
+ parser.add_argument("--cycles", type=int, default=None, help="Max cycles (None=infinite)")
608
+ parser.add_argument("--train-steps", type=int, default=100_000)
609
+ parser.add_argument("--backtest-episodes", type=int, default=10)
610
+ parser.add_argument("--rpc", type=str, default=MANTLE_RPC_URL)
611
+
612
+ args = parser.parse_args()
613
+
614
+ setup_logging()
615
+
616
+ agent = YieldRouterAgent(
617
+ wallet_address=args.wallet,
618
+ initial_capital=args.capital,
619
+ rpc_url=args.rpc,
620
+ )
621
+
622
+ # Handle shutdown signals
623
+ def signal_handler(sig, frame):
624
+ logger.info("Shutdown signal received...")
625
+ agent.stop()
626
+
627
+ signal.signal(signal.SIGINT, signal_handler)
628
+ signal.signal(signal.SIGTERM, signal_handler)
629
+
630
+ if args.mode == "train":
631
+ agent.train_rl_agent(total_timesteps=args.train_steps)
632
+
633
+ elif args.mode == "backtest":
634
+ results = agent.backtest(n_episodes=args.backtest_episodes)
635
+ print(json.dumps(results, indent=2, default=str))
636
+
637
+ elif args.mode == "run":
638
+ await agent.run(
639
+ interval_seconds=args.interval,
640
+ max_cycles=args.cycles,
641
+ )
642
+
643
+ elif args.mode == "demo":
644
+ # Run 3 demo cycles with short interval
645
+ logger.info("🎭 Running demo mode (3 cycles, 5s interval)...")
646
+ await agent.run(
647
+ interval_seconds=5,
648
+ max_cycles=3,
649
+ generate_reports=True,
650
+ report_interval_cycles=3,
651
+ )
652
+ print("\n" + "=" * 60)
653
+ print("Demo complete! Final state:")
654
+ print(json.dumps(agent.state.to_dict(), indent=2))
655
+
656
+
657
+ if __name__ == "__main__":
658
+ asyncio.run(main())