web3-agent-kit 0.3.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- src/__init__.py +33 -0
- src/agent.py +239 -0
- src/bridge.py +504 -0
- src/chain.py +115 -0
- src/defi/__init__.py +476 -0
- src/llm.py +272 -0
- src/portfolio.py +326 -0
- src/sniper.py +511 -0
- src/utils/__init__.py +140 -0
- src/wallet.py +128 -0
- web3_agent_kit-0.3.0.dist-info/METADATA +333 -0
- web3_agent_kit-0.3.0.dist-info/RECORD +15 -0
- web3_agent_kit-0.3.0.dist-info/WHEEL +5 -0
- web3_agent_kit-0.3.0.dist-info/licenses/LICENSE +21 -0
- web3_agent_kit-0.3.0.dist-info/top_level.txt +1 -0
src/llm.py
ADDED
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
"""LLM integration — multi-provider cascade for agent reasoning.
|
|
2
|
+
|
|
3
|
+
Supports: OpenAI, Anthropic, Groq, DeepSeek, OpenRouter.
|
|
4
|
+
Falls back through providers on 429/5xx/timeout.
|
|
5
|
+
|
|
6
|
+
Usage:
|
|
7
|
+
from web3_agent_kit.llm import LLM
|
|
8
|
+
|
|
9
|
+
llm = LLM() # auto-detect from env vars
|
|
10
|
+
response = llm.chat("What is the best yield on Base?")
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import json
|
|
16
|
+
import logging
|
|
17
|
+
import os
|
|
18
|
+
from dataclasses import dataclass
|
|
19
|
+
from typing import Any, Optional
|
|
20
|
+
|
|
21
|
+
logger = logging.getLogger(__name__)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass
|
|
25
|
+
class LLMConfig:
|
|
26
|
+
"""Configuration for LLM providers."""
|
|
27
|
+
|
|
28
|
+
providers: Optional[list[dict]] = None # ordered list of providers to try
|
|
29
|
+
temperature: float = 0.1
|
|
30
|
+
max_tokens: int = 2048
|
|
31
|
+
timeout: int = 30
|
|
32
|
+
|
|
33
|
+
def __post_init__(self):
|
|
34
|
+
if not self.providers:
|
|
35
|
+
self.providers = self._auto_detect()
|
|
36
|
+
|
|
37
|
+
def _auto_detect(self) -> list[dict]:
|
|
38
|
+
"""Auto-detect available providers from environment variables."""
|
|
39
|
+
providers = []
|
|
40
|
+
|
|
41
|
+
# Cascade order: Anthropic → Kimi → OpenRouter → DeepSeek → Groq
|
|
42
|
+
if os.environ.get("ANTHROPIC_API_KEY"):
|
|
43
|
+
providers.append({
|
|
44
|
+
"name": "anthropic",
|
|
45
|
+
"api_key": os.environ["ANTHROPIC_API_KEY"],
|
|
46
|
+
"model": os.environ.get("ANTHROPIC_MODEL", "claude-sonnet-4-20250514"),
|
|
47
|
+
"base_url": "https://api.anthropic.com/v1",
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
if os.environ.get("KIMI_API_KEY"):
|
|
51
|
+
providers.append({
|
|
52
|
+
"name": "openai",
|
|
53
|
+
"api_key": os.environ["KIMI_API_KEY"],
|
|
54
|
+
"model": os.environ.get("KIMI_MODEL", "moonshot-v1-8k"),
|
|
55
|
+
"base_url": os.environ.get("KIMI_BASE_URL", "https://api.moonshot.cn/v1"),
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
if os.environ.get("OPENROUTER_API_KEY"):
|
|
59
|
+
providers.append({
|
|
60
|
+
"name": "openai",
|
|
61
|
+
"api_key": os.environ["OPENROUTER_API_KEY"],
|
|
62
|
+
"model": os.environ.get("OPENROUTER_MODEL", "anthropic/claude-sonnet-4"),
|
|
63
|
+
"base_url": "https://openrouter.ai/api/v1",
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
if os.environ.get("DEEPSEEK_API_KEY"):
|
|
67
|
+
providers.append({
|
|
68
|
+
"name": "openai",
|
|
69
|
+
"api_key": os.environ["DEEPSEEK_API_KEY"],
|
|
70
|
+
"model": os.environ.get("DEEPSEEK_MODEL", "deepseek-chat"),
|
|
71
|
+
"base_url": "https://api.deepseek.com/v1",
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
if os.environ.get("GROQ_API_KEY"):
|
|
75
|
+
providers.append({
|
|
76
|
+
"name": "openai",
|
|
77
|
+
"api_key": os.environ["GROQ_API_KEY"],
|
|
78
|
+
"model": os.environ.get("GROQ_MODEL", "llama-3.3-70b-versatile"),
|
|
79
|
+
"base_url": "https://api.groq.com/openai/v1",
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
if os.environ.get("OPENAI_API_KEY"):
|
|
83
|
+
providers.append({
|
|
84
|
+
"name": "openai",
|
|
85
|
+
"api_key": os.environ["OPENAI_API_KEY"],
|
|
86
|
+
"model": os.environ.get("OPENAI_MODEL", "gpt-4o-mini"),
|
|
87
|
+
"base_url": "https://api.openai.com/v1",
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
return providers
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
class LLM:
|
|
94
|
+
"""
|
|
95
|
+
Multi-provider LLM client with cascade fallback.
|
|
96
|
+
|
|
97
|
+
Tries providers in order. On 429/5xx/timeout, moves to next provider.
|
|
98
|
+
|
|
99
|
+
Example:
|
|
100
|
+
llm = LLM()
|
|
101
|
+
response = llm.chat("Analyze this swap: 0.1 ETH to USDC")
|
|
102
|
+
"""
|
|
103
|
+
|
|
104
|
+
def __init__(self, config: Optional[LLMConfig] = None, **kwargs):
|
|
105
|
+
self.config = config or LLMConfig(**kwargs)
|
|
106
|
+
if not self.config.providers:
|
|
107
|
+
raise ValueError(
|
|
108
|
+
"No LLM providers configured. Set one of: "
|
|
109
|
+
"OPENAI_API_KEY, ANTHROPIC_API_KEY, GROQ_API_KEY, "
|
|
110
|
+
"DEEPSEEK_API_KEY, OPENROUTER_API_KEY, KIMI_API_KEY"
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
def chat(
|
|
114
|
+
self,
|
|
115
|
+
prompt: str,
|
|
116
|
+
system: Optional[str] = None,
|
|
117
|
+
messages: Optional[list[dict]] = None,
|
|
118
|
+
response_format: Optional[str] = None,
|
|
119
|
+
) -> str:
|
|
120
|
+
"""
|
|
121
|
+
Send a chat completion request with cascade fallback.
|
|
122
|
+
|
|
123
|
+
Args:
|
|
124
|
+
prompt: User message
|
|
125
|
+
system: System prompt
|
|
126
|
+
messages: Full message list (overrides prompt/system if provided)
|
|
127
|
+
response_format: "json" for JSON mode
|
|
128
|
+
|
|
129
|
+
Returns:
|
|
130
|
+
Assistant response text
|
|
131
|
+
"""
|
|
132
|
+
if messages is None:
|
|
133
|
+
messages = []
|
|
134
|
+
if system:
|
|
135
|
+
messages.append({"role": "system", "content": system})
|
|
136
|
+
messages.append({"role": "user", "content": prompt})
|
|
137
|
+
|
|
138
|
+
last_error = None
|
|
139
|
+
for provider in self.config.providers:
|
|
140
|
+
try:
|
|
141
|
+
return self._call_provider(provider, messages, response_format)
|
|
142
|
+
except Exception as e:
|
|
143
|
+
last_error = e
|
|
144
|
+
logger.warning(f"Provider {provider['name']} failed: {e}")
|
|
145
|
+
continue
|
|
146
|
+
|
|
147
|
+
raise RuntimeError(f"All LLM providers failed. Last error: {last_error}")
|
|
148
|
+
|
|
149
|
+
def chat_json(self, prompt: str, system: Optional[str] = None) -> dict:
|
|
150
|
+
"""
|
|
151
|
+
Chat with JSON response parsing.
|
|
152
|
+
|
|
153
|
+
Returns parsed JSON dict.
|
|
154
|
+
"""
|
|
155
|
+
response = self.chat(prompt, system=system, response_format="json")
|
|
156
|
+
# Try to extract JSON from response
|
|
157
|
+
try:
|
|
158
|
+
return json.loads(response)
|
|
159
|
+
except json.JSONDecodeError:
|
|
160
|
+
# Try to find JSON in response
|
|
161
|
+
start = response.find("{")
|
|
162
|
+
end = response.rfind("}") + 1
|
|
163
|
+
if start >= 0 and end > start:
|
|
164
|
+
return json.loads(response[start:end])
|
|
165
|
+
raise ValueError(f"Could not parse JSON from response: {response[:200]}")
|
|
166
|
+
|
|
167
|
+
def _call_provider(
|
|
168
|
+
self, provider: dict, messages: list[dict], response_format: Optional[str]
|
|
169
|
+
) -> str:
|
|
170
|
+
"""Call a specific LLM provider."""
|
|
171
|
+
import requests
|
|
172
|
+
|
|
173
|
+
headers = {
|
|
174
|
+
"Content-Type": "application/json",
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
payload = {
|
|
178
|
+
"model": provider["model"],
|
|
179
|
+
"messages": messages,
|
|
180
|
+
"temperature": self.config.temperature,
|
|
181
|
+
"max_tokens": self.config.max_tokens,
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
if response_format == "json":
|
|
185
|
+
if provider["name"] == "openai":
|
|
186
|
+
payload["response_format"] = {"type": "json_object"}
|
|
187
|
+
elif provider["name"] == "anthropic":
|
|
188
|
+
# Anthropic doesn't have native JSON mode, use system prompt
|
|
189
|
+
pass
|
|
190
|
+
|
|
191
|
+
if provider["name"] == "anthropic":
|
|
192
|
+
return self._call_anthropic(provider, messages, payload)
|
|
193
|
+
else:
|
|
194
|
+
return self._call_openai_compatible(provider, headers, payload)
|
|
195
|
+
|
|
196
|
+
def _call_openai_compatible(
|
|
197
|
+
self, provider: dict, headers: dict, payload: dict
|
|
198
|
+
) -> str:
|
|
199
|
+
"""Call OpenAI-compatible API."""
|
|
200
|
+
import requests
|
|
201
|
+
|
|
202
|
+
headers["Authorization"] = f"Bearer {provider['api_key']}"
|
|
203
|
+
|
|
204
|
+
# Add provider-specific headers
|
|
205
|
+
if provider.get("base_url", "").startswith("https://openrouter.ai"):
|
|
206
|
+
headers["HTTP-Referer"] = "https://github.com/ulsreall/web3-agent-kit"
|
|
207
|
+
headers["X-Title"] = "web3-agent-kit"
|
|
208
|
+
|
|
209
|
+
url = f"{provider['base_url']}/chat/completions"
|
|
210
|
+
|
|
211
|
+
resp = requests.post(
|
|
212
|
+
url, headers=headers, json=payload, timeout=self.config.timeout
|
|
213
|
+
)
|
|
214
|
+
|
|
215
|
+
if resp.status_code == 429:
|
|
216
|
+
raise RuntimeError(f"Rate limited by {provider['name']}")
|
|
217
|
+
if resp.status_code >= 500:
|
|
218
|
+
raise RuntimeError(f"Server error from {provider['name']}: {resp.status_code}")
|
|
219
|
+
|
|
220
|
+
resp.raise_for_status()
|
|
221
|
+
data = resp.json()
|
|
222
|
+
|
|
223
|
+
return data["choices"][0]["message"]["content"]
|
|
224
|
+
|
|
225
|
+
def _call_anthropic(
|
|
226
|
+
self, provider: dict, messages: list[dict], payload: dict
|
|
227
|
+
) -> str:
|
|
228
|
+
"""Call Anthropic API."""
|
|
229
|
+
import requests
|
|
230
|
+
|
|
231
|
+
headers = {
|
|
232
|
+
"Content-Type": "application/json",
|
|
233
|
+
"x-api-key": provider["api_key"],
|
|
234
|
+
"anthropic-version": "2023-06-01",
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
# Convert messages format
|
|
238
|
+
system_msg = None
|
|
239
|
+
api_messages = []
|
|
240
|
+
for msg in messages:
|
|
241
|
+
if msg["role"] == "system":
|
|
242
|
+
system_msg = msg["content"]
|
|
243
|
+
else:
|
|
244
|
+
api_messages.append(msg)
|
|
245
|
+
|
|
246
|
+
payload = {
|
|
247
|
+
"model": provider["model"],
|
|
248
|
+
"max_tokens": self.config.max_tokens,
|
|
249
|
+
"messages": api_messages,
|
|
250
|
+
}
|
|
251
|
+
if system_msg:
|
|
252
|
+
payload["system"] = system_msg
|
|
253
|
+
|
|
254
|
+
url = f"{provider['base_url']}/messages"
|
|
255
|
+
|
|
256
|
+
resp = requests.post(
|
|
257
|
+
url, headers=headers, json=payload, timeout=self.config.timeout
|
|
258
|
+
)
|
|
259
|
+
|
|
260
|
+
if resp.status_code == 429:
|
|
261
|
+
raise RuntimeError(f"Rate limited by Anthropic")
|
|
262
|
+
if resp.status_code >= 500:
|
|
263
|
+
raise RuntimeError(f"Server error from Anthropic: {resp.status_code}")
|
|
264
|
+
|
|
265
|
+
resp.raise_for_status()
|
|
266
|
+
data = resp.json()
|
|
267
|
+
|
|
268
|
+
return data["content"][0]["text"]
|
|
269
|
+
|
|
270
|
+
def __repr__(self) -> str:
|
|
271
|
+
names = [p["name"] for p in self.config.providers]
|
|
272
|
+
return f"LLM(providers={names})"
|
src/portfolio.py
ADDED
|
@@ -0,0 +1,326 @@
|
|
|
1
|
+
"""Portfolio dashboard — real-time balance, P&L, positions across chains.
|
|
2
|
+
|
|
3
|
+
Tracks wallet balances, token holdings, and calculates total portfolio value.
|
|
4
|
+
|
|
5
|
+
Usage:
|
|
6
|
+
from web3_agent_kit.portfolio import PortfolioTracker
|
|
7
|
+
|
|
8
|
+
tracker = PortfolioTracker(chain_manager, wallet)
|
|
9
|
+
summary = tracker.get_summary()
|
|
10
|
+
print(summary)
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import json
|
|
16
|
+
import logging
|
|
17
|
+
import time
|
|
18
|
+
from dataclasses import dataclass, field
|
|
19
|
+
from typing import Optional
|
|
20
|
+
|
|
21
|
+
from .wallet import Wallet
|
|
22
|
+
from .chain import Chain, ChainManager
|
|
23
|
+
|
|
24
|
+
logger = logging.getLogger(__name__)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
# ERC20 ABI (minimal)
|
|
28
|
+
ERC20_ABI = json.loads("""[
|
|
29
|
+
{
|
|
30
|
+
"inputs": [{"internalType": "address", "name": "account", "type": "address"}],
|
|
31
|
+
"name": "balanceOf",
|
|
32
|
+
"outputs": [{"internalType": "uint256", "name": "", "type": "uint256"}],
|
|
33
|
+
"stateMutability": "view",
|
|
34
|
+
"type": "function"
|
|
35
|
+
},
|
|
36
|
+
{
|
|
37
|
+
"inputs": [],
|
|
38
|
+
"name": "decimals",
|
|
39
|
+
"outputs": [{"internalType": "uint8", "name": "", "type": "uint8"}],
|
|
40
|
+
"stateMutability": "view",
|
|
41
|
+
"type": "function"
|
|
42
|
+
},
|
|
43
|
+
{
|
|
44
|
+
"inputs": [],
|
|
45
|
+
"name": "symbol",
|
|
46
|
+
"outputs": [{"internalType": "string", "name": "", "type": "string"}],
|
|
47
|
+
"stateMutability": "view",
|
|
48
|
+
"type": "function"
|
|
49
|
+
},
|
|
50
|
+
{
|
|
51
|
+
"inputs": [],
|
|
52
|
+
"name": "name",
|
|
53
|
+
"outputs": [{"internalType": "string", "name": "", "type": "string"}],
|
|
54
|
+
"stateMutability": "view",
|
|
55
|
+
"type": "function"
|
|
56
|
+
}
|
|
57
|
+
]""")
|
|
58
|
+
|
|
59
|
+
# Common token addresses per chain
|
|
60
|
+
KNOWN_TOKENS = {
|
|
61
|
+
Chain.ETHEREUM: {
|
|
62
|
+
"WETH": "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2",
|
|
63
|
+
"USDC": "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48",
|
|
64
|
+
"USDT": "0xdAC17F958D2ee523a2206206994597C13D831ec7",
|
|
65
|
+
"DAI": "0x6B175474E89094C44Da98b954EedeAC495271d0F",
|
|
66
|
+
"WBTC": "0x2260FAC5E5542a773Aa44fBCfeDf7C193bc2C599",
|
|
67
|
+
"LINK": "0x514910771AF9Ca656af840dff83E8264EcF986CA",
|
|
68
|
+
"UNI": "0x1f9840a85d5aF5bf1D1762F925BDADdC4201F984",
|
|
69
|
+
},
|
|
70
|
+
Chain.BASE: {
|
|
71
|
+
"WETH": "0x4200000000000000000000000000000000000006",
|
|
72
|
+
"USDC": "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913",
|
|
73
|
+
"USDbC": "0xd9aAEc86B65D86f6A7B5B1b0c42FFA531710b6CA",
|
|
74
|
+
"DAI": "0x50c5725949A6F0c72E6C4a641F24049A917DB0Cb",
|
|
75
|
+
"AERO": "0x940181a94A35A4569E4529A3CDfB74e38FD98631",
|
|
76
|
+
},
|
|
77
|
+
Chain.ARBITRUM: {
|
|
78
|
+
"WETH": "0x82aF49447D8a07e3bd95BD0d56f35241523fBab1",
|
|
79
|
+
"USDC": "0xaf88d065e77c8cC2239327C5EDb3A432268e5831",
|
|
80
|
+
"USDT": "0xFd086bC7CD5C481DCC9C85ebE478A1C0b69FCbb9",
|
|
81
|
+
"ARB": "0x912CE59144191C1204E64559FE8253a0e49E6548",
|
|
82
|
+
"GMX": "0xfc5A1A6EB076a2C7aD06eD22C90d7E710E35ad0a",
|
|
83
|
+
},
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
# Approximate USD prices (would need oracle in production)
|
|
87
|
+
ETH_PRICE_USD = 3500.0
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
@dataclass
|
|
91
|
+
class TokenBalance:
|
|
92
|
+
"""Balance of a single token."""
|
|
93
|
+
|
|
94
|
+
symbol: str
|
|
95
|
+
address: str
|
|
96
|
+
balance: float
|
|
97
|
+
decimals: int
|
|
98
|
+
chain: Chain
|
|
99
|
+
price_usd: float = 0.0
|
|
100
|
+
value_usd: float = 0.0
|
|
101
|
+
|
|
102
|
+
def to_dict(self) -> dict:
|
|
103
|
+
return {
|
|
104
|
+
"symbol": self.symbol,
|
|
105
|
+
"address": self.address,
|
|
106
|
+
"balance": self.balance,
|
|
107
|
+
"chain": self.chain.value,
|
|
108
|
+
"price_usd": self.price_usd,
|
|
109
|
+
"value_usd": self.value_usd,
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
@dataclass
|
|
114
|
+
class ChainPortfolio:
|
|
115
|
+
"""Portfolio summary for a single chain."""
|
|
116
|
+
|
|
117
|
+
chain: Chain
|
|
118
|
+
native_balance: float
|
|
119
|
+
native_value_usd: float
|
|
120
|
+
tokens: list[TokenBalance]
|
|
121
|
+
total_value_usd: float
|
|
122
|
+
|
|
123
|
+
def to_dict(self) -> dict:
|
|
124
|
+
return {
|
|
125
|
+
"chain": self.chain.value,
|
|
126
|
+
"native_balance": self.native_balance,
|
|
127
|
+
"native_value_usd": self.native_value_usd,
|
|
128
|
+
"tokens": [t.to_dict() for t in self.tokens],
|
|
129
|
+
"total_value_usd": self.total_value_usd,
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
@dataclass
|
|
134
|
+
class PortfolioSummary:
|
|
135
|
+
"""Full portfolio summary across all chains."""
|
|
136
|
+
|
|
137
|
+
address: str
|
|
138
|
+
timestamp: float
|
|
139
|
+
chains: list[ChainPortfolio]
|
|
140
|
+
total_value_usd: float
|
|
141
|
+
total_native_balances: dict[str, float] # chain -> ETH balance
|
|
142
|
+
|
|
143
|
+
def to_dict(self) -> dict:
|
|
144
|
+
return {
|
|
145
|
+
"address": self.address,
|
|
146
|
+
"timestamp": self.timestamp,
|
|
147
|
+
"chains": [c.to_dict() for c in self.chains],
|
|
148
|
+
"total_value_usd": self.total_value_usd,
|
|
149
|
+
"total_native_balances": self.total_native_balances,
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
def __str__(self) -> str:
|
|
153
|
+
lines = [
|
|
154
|
+
f"📊 Portfolio: {self.address[:10]}...",
|
|
155
|
+
f"💰 Total Value: ${self.total_value_usd:,.2f}",
|
|
156
|
+
"",
|
|
157
|
+
]
|
|
158
|
+
for cp in self.chains:
|
|
159
|
+
lines.append(f" 🔗 {cp.chain.value.upper()}: ${cp.total_value_usd:,.2f}")
|
|
160
|
+
lines.append(f" Native: {cp.native_balance:.4f} ETH (${cp.native_value_usd:,.2f})")
|
|
161
|
+
for token in cp.tokens:
|
|
162
|
+
if token.balance > 0:
|
|
163
|
+
lines.append(f" {token.symbol}: {token.balance:.4f} (${token.value_usd:,.2f})")
|
|
164
|
+
return "\n".join(lines)
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
class PortfolioTracker:
|
|
168
|
+
"""
|
|
169
|
+
Track wallet portfolio across multiple chains.
|
|
170
|
+
|
|
171
|
+
Example:
|
|
172
|
+
tracker = PortfolioTracker(chain_manager, wallet)
|
|
173
|
+
summary = tracker.get_summary()
|
|
174
|
+
print(summary)
|
|
175
|
+
"""
|
|
176
|
+
|
|
177
|
+
def __init__(
|
|
178
|
+
self,
|
|
179
|
+
chain_manager: ChainManager,
|
|
180
|
+
wallet: Wallet,
|
|
181
|
+
eth_price: float = ETH_PRICE_USD,
|
|
182
|
+
):
|
|
183
|
+
self.chain_manager = chain_manager
|
|
184
|
+
self.wallet = wallet
|
|
185
|
+
self.eth_price = eth_price
|
|
186
|
+
self._history: list[PortfolioSummary] = []
|
|
187
|
+
|
|
188
|
+
def get_summary(self, chains: Optional[list[Chain]] = None) -> PortfolioSummary:
|
|
189
|
+
"""
|
|
190
|
+
Get full portfolio summary.
|
|
191
|
+
|
|
192
|
+
Args:
|
|
193
|
+
chains: Chains to check (defaults to all configured chains)
|
|
194
|
+
|
|
195
|
+
Returns:
|
|
196
|
+
PortfolioSummary with all balances and values
|
|
197
|
+
"""
|
|
198
|
+
if chains is None:
|
|
199
|
+
chains = self.chain_manager.list_chains()
|
|
200
|
+
|
|
201
|
+
chain_portfolios = []
|
|
202
|
+
total_value = 0.0
|
|
203
|
+
native_balances = {}
|
|
204
|
+
|
|
205
|
+
for chain in chains:
|
|
206
|
+
try:
|
|
207
|
+
cp = self._get_chain_portfolio(chain)
|
|
208
|
+
chain_portfolios.append(cp)
|
|
209
|
+
total_value += cp.total_value_usd
|
|
210
|
+
native_balances[chain.value] = cp.native_balance
|
|
211
|
+
except Exception as e:
|
|
212
|
+
logger.warning(f"Failed to get portfolio for {chain.value}: {e}")
|
|
213
|
+
|
|
214
|
+
summary = PortfolioSummary(
|
|
215
|
+
address=self.wallet.address,
|
|
216
|
+
timestamp=time.time(),
|
|
217
|
+
chains=chain_portfolios,
|
|
218
|
+
total_value_usd=total_value,
|
|
219
|
+
total_native_balances=native_balances,
|
|
220
|
+
)
|
|
221
|
+
|
|
222
|
+
self._history.append(summary)
|
|
223
|
+
return summary
|
|
224
|
+
|
|
225
|
+
def _get_chain_portfolio(self, chain: Chain) -> ChainPortfolio:
|
|
226
|
+
"""Get portfolio for a single chain."""
|
|
227
|
+
# Get native balance
|
|
228
|
+
native_balance = self.wallet.get_balance(chain)
|
|
229
|
+
native_value = native_balance * self.eth_price
|
|
230
|
+
|
|
231
|
+
# Get token balances
|
|
232
|
+
tokens = []
|
|
233
|
+
tokens_value = 0.0
|
|
234
|
+
|
|
235
|
+
known = KNOWN_TOKENS.get(chain, {})
|
|
236
|
+
for symbol, address in known.items():
|
|
237
|
+
try:
|
|
238
|
+
token = self._get_token_balance(address, chain)
|
|
239
|
+
if token and token.balance > 0:
|
|
240
|
+
# Estimate value (simplified — would need oracle in production)
|
|
241
|
+
if symbol in ("USDC", "USDT", "DAI", "USDbC"):
|
|
242
|
+
token.price_usd = 1.0
|
|
243
|
+
token.value_usd = token.balance
|
|
244
|
+
elif symbol == "WETH":
|
|
245
|
+
token.price_usd = self.eth_price
|
|
246
|
+
token.value_usd = token.balance * self.eth_price
|
|
247
|
+
elif symbol == "WBTC":
|
|
248
|
+
token.price_usd = 60000.0
|
|
249
|
+
token.value_usd = token.balance * 60000.0
|
|
250
|
+
else:
|
|
251
|
+
# Unknown price — would need oracle
|
|
252
|
+
token.price_usd = 0.0
|
|
253
|
+
token.value_usd = 0.0
|
|
254
|
+
|
|
255
|
+
tokens.append(token)
|
|
256
|
+
tokens_value += token.value_usd
|
|
257
|
+
except Exception as e:
|
|
258
|
+
logger.debug(f"Failed to get {symbol} balance: {e}")
|
|
259
|
+
|
|
260
|
+
return ChainPortfolio(
|
|
261
|
+
chain=chain,
|
|
262
|
+
native_balance=native_balance,
|
|
263
|
+
native_value_usd=native_value,
|
|
264
|
+
tokens=tokens,
|
|
265
|
+
total_value_usd=native_value + tokens_value,
|
|
266
|
+
)
|
|
267
|
+
|
|
268
|
+
def _get_token_balance(self, token_address: str, chain: Chain) -> Optional[TokenBalance]:
|
|
269
|
+
"""Get balance of a specific token."""
|
|
270
|
+
w3 = self.chain_manager.get_web3(chain)
|
|
271
|
+
token = w3.eth.contract(
|
|
272
|
+
address=w3.to_checksum_address(token_address),
|
|
273
|
+
abi=ERC20_ABI,
|
|
274
|
+
)
|
|
275
|
+
|
|
276
|
+
try:
|
|
277
|
+
balance_raw = token.functions.balanceOf(
|
|
278
|
+
w3.to_checksum_address(self.wallet.address)
|
|
279
|
+
).call()
|
|
280
|
+
decimals = token.functions.decimals().call()
|
|
281
|
+
symbol = token.functions.symbol().call()
|
|
282
|
+
|
|
283
|
+
balance = balance_raw / (10 ** decimals)
|
|
284
|
+
|
|
285
|
+
return TokenBalance(
|
|
286
|
+
symbol=symbol,
|
|
287
|
+
address=token_address,
|
|
288
|
+
balance=balance,
|
|
289
|
+
decimals=decimals,
|
|
290
|
+
chain=chain,
|
|
291
|
+
)
|
|
292
|
+
except Exception as e:
|
|
293
|
+
logger.debug(f"Failed to get token balance: {e}")
|
|
294
|
+
return None
|
|
295
|
+
|
|
296
|
+
def get_history(self) -> list[PortfolioSummary]:
|
|
297
|
+
"""Get portfolio history."""
|
|
298
|
+
return self._history
|
|
299
|
+
|
|
300
|
+
def get_pnl(self) -> dict:
|
|
301
|
+
"""
|
|
302
|
+
Calculate P&L between first and last snapshot.
|
|
303
|
+
|
|
304
|
+
Returns:
|
|
305
|
+
Dict with pnl_absolute and pnl_percent
|
|
306
|
+
"""
|
|
307
|
+
if len(self._history) < 2:
|
|
308
|
+
return {"pnl_absolute": 0.0, "pnl_percent": 0.0}
|
|
309
|
+
|
|
310
|
+
first = self._history[0]
|
|
311
|
+
last = self._history[-1]
|
|
312
|
+
|
|
313
|
+
pnl = last.total_value_usd - first.total_value_usd
|
|
314
|
+
pnl_pct = (pnl / first.total_value_usd * 100) if first.total_value_usd > 0 else 0
|
|
315
|
+
|
|
316
|
+
return {
|
|
317
|
+
"initial_value": first.total_value_usd,
|
|
318
|
+
"current_value": last.total_value_usd,
|
|
319
|
+
"pnl_absolute": pnl,
|
|
320
|
+
"pnl_percent": pnl_pct,
|
|
321
|
+
"timestamp_start": first.timestamp,
|
|
322
|
+
"timestamp_end": last.timestamp,
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
def __repr__(self) -> str:
|
|
326
|
+
return f"PortfolioTracker(wallet={self.wallet.address[:10]}...)"
|