Introduction
In this tutorial, we will build a cryptocurrency address portfolio tracker, as a Shinkai Tool.
This tool can be a core component of a crypto AI Agent capable of discussing real-time blockchain data.
The tool presented here interacts with the TapTools API to analyze and visualize portfolio data for a specified Cardano wallet address.
It retrieves positions for fungible tokens, NFTs, and liquidity provider positions. The tool calculates their values in Ada and a specified quote currency and generates visualizations based on user-defined parameters.
You can find this tool in the Shinkai AI Store. Its name is : Taptools Cardano Portfolio Tracker. You can watch a usage example of this tool here.
While we use a Cardano example, the same tool structure can be applied to any blockchain for which an API provides such data.
Prerequisites
Before you begin, ensure you have :
- the latest version of the Shinkai Desktop App installed,
- the App open at the tool creation UI,
- a Taptools API key.
Step 1 : Choosing a source of data
Common sources of on-chain data are :
- local indexer/node with data fetching capabilities,
- blockchain data provider services,
- decentralized blockchain data protocols,
- crypto wide analytics and market platforms.
Here, we’ll use the Taptools API. Taptools is the dominant platform for data about Cardano tokens and NFTs trading as well as wallet tracking. A quick look at the documentation of the API shows that they offer endpoints with practical aggregated data for wallet portfolio analysis.
Shinkai allows effortless building of tools and AI Agents thanks to its integrated AI assistance. You can build the tool by just prompting a LLM. You don’t have to wrestle with library dependencies or handle manual deployments, everything is automated and user-friendly.
To create the portfolio tracker tool, navigate to the Shinkai Tools tab, select a programming language, and use the AI ‘shinkai_code_gen’, because :
- it can access documentation online,
- it thinks about the requirements and asks for feedback before generating the tool.
You can then prompt the AI to build your tool.
Use a prompt similar to the one below, with clear instructions :
Below is an example of thinking process you might get from Shinkai code generator AI. Review it and follow along.
Also, in this video a working prototype of the Cardano portfolio tracker’s data retrieval feature is built in just a few minutes by simply providing instructions and API documentation snippets.
The ‘shinkai_code_gen’ AI should generate a functional tool based on your instructions. However, you may want to refine it further.
You can for example prompt the AI to :
- rename configs, inputs or outputs keys exactly with precise names,
- improve some number formatting in the output (percentages, rounding in meaningful ways, etc.),
- remove data from the output that comes from the API call but is not of interest,
- modify the layouts of the graphs,
- improve some error messaging,
- etc.
For reference, here is an example of fully annotated code for a Cardano wallet portfolio tracker tool, obtained after prompting a few times for such improvements.
from typing import Any, Optional, Dict, List
from requests import get
import matplotlib.pyplot as plt
from shinkai_local_support import get_home_path
class CONFIG:
taptools_api_key: str
min_quote_value_ft: str = "1"
min_quote_value_nft: str = "1"
min_quote_value_lp: str = "1"
class INPUTS:
address: str
quote_currency: str = "USD"
generate_positions_plots: str = "yes"
generate_portfolio_trended_value_graphs: str = "yes"
portfolio_trended_value_timeframe: str = "30d"
class OUTPUT:
portfolio_positions: Dict[str, Any]
ada_price_in_quote: float
error: Optional[str] = None
async def run(config: CONFIG, inputs: INPUTS) -> OUTPUT:
api_base_url = "https://openapi.taptools.io/api/v1"
portfolio_positions_url = f"{api_base_url}/wallet/portfolio/positions?address={inputs.address}"
token_quote_url = f"{api_base_url}/token/quote?quote={inputs.quote_currency}"
headers = {
"x-api-key": config.taptools_api_key
}
quote_response = get(token_quote_url, headers=headers)
if quote_response.status_code != 200:
output = OUTPUT()
try:
error_details = quote_response.json()
error_message = error_details.get("message", "An error occurred")
except json.JSONDecodeError:
error_message = quote_response.text
output.error = f"Error fetching ADA price: {error_message} (Status Code: {quote_response.status_code})"
return output
ada_price_in_quote = quote_response.json().get("price", 0)
ada_price_attr_name = f"ada_price_in_{inputs.quote_currency}"
response = get(portfolio_positions_url, headers=headers)
output = OUTPUT()
if response.status_code == 200:
data = response.json()
data["Number_of_Fungible_Tokens"] = data.pop("numFTs", 0)
data["Number_of_NFT_collections"] = data.pop("numNFTs", 0)
data["Fungible_Tokens_positions"] = data.pop("positionsFt", [])
data["NFTs_positions"] = data.pop("positionsNft", [])
data["Liquidity_Provider_Positions"] = data.pop("positionsLp", [])
for position in data["Fungible_Tokens_positions"]:
for timeframe in ["24h", "7d", "30d"]:
if timeframe in position:
change_value = position[timeframe]
sign = "+" if change_value >= 0 else ""
position[timeframe] = f"{sign}{round(change_value * 100, 2)} %"
position["Value_in_Ada"] = round(position.pop("adaValue", 0), 2)
position["Liquid_Value_in_Ada"] = round(position.pop("liquidValue", 0), 2)
position["Value_in_" + inputs.quote_currency] = round(position["Value_in_Ada"] * ada_price_in_quote, 2)
position["Liquid_Value_in_" + inputs.quote_currency] = round(position["Liquid_Value_in_Ada"] * ada_price_in_quote, 2)
position["Liquid_Balance_in_Ada"] = position.pop("liquidBalance", 0)
position.pop("unit", None)
position.pop("fingerprint", None)
for position in data["NFTs_positions"]:
for timeframe in ["24h", "7d", "30d"]:
if timeframe in position:
change_value = position[timeframe]
sign = "+" if change_value >= 0 else ""
position[timeframe] = f"{sign}{round(change_value * 100, 2)} %"
position["Value_in_Ada"] = round(position.pop("adaValue", 0), 2)
position["Value_in_" + inputs.quote_currency] = round(position["Value_in_Ada"] * ada_price_in_quote, 2)
position["Floor_Price_in_Ada"] = position.pop("floorPrice", 0)
position["Floor_Price_in_" + inputs.quote_currency] = round(position["Floor_Price_in_Ada"] * ada_price_in_quote, 2)
for position in data["Liquidity_Provider_Positions"]:
position["Value_in_Ada"] = round(position.pop("adaValue", 0), 2)
position["Value_in_" + inputs.quote_currency] = round(position["Value_in_Ada"] * ada_price_in_quote, 2)
total_value_in_ada = data.pop("adaValue", 0)
data["Total_Portfolio_Value_in_Ada"] = round(total_value_in_ada, 2)
data["Total_Portfolio_Value_in_" + inputs.quote_currency] = round(total_value_in_ada * ada_price_in_quote, 2)
setattr(output, ada_price_attr_name, ada_price_in_quote)
output.portfolio_positions = data
if inputs.generate_positions_plots.lower() == "yes":
home_path = await get_home_path()
plt.style.use('dark_background')
min_value_ft = float(config.min_quote_value_ft)
min_value_nft = float(config.min_quote_value_nft)
min_value_lp = float(config.min_quote_value_lp)
ft_names = [p.get("ticker", "") for p in data["Fungible_Tokens_positions"] if p.get("Value_in_" + inputs.quote_currency, 0) >= min_value_ft]
ft_values = [p.get("Value_in_" + inputs.quote_currency, 0) for p in data["Fungible_Tokens_positions"] if p.get("Value_in_" + inputs.quote_currency, 0) >= min_value_ft]
sorted_ft = sorted(zip(ft_values, ft_names), reverse=True)
plt.figure(figsize=(10, 6))
plt.bar([name for _, name in sorted_ft], [value for value, _ in sorted_ft])
plt.title("Fungible Tokens Value")
plt.xlabel("Token")
plt.ylabel(f"Value in {inputs.quote_currency}")
plt.xticks(rotation=45, ha="right")
plt.tight_layout()
plt.savefig(f"{home_path}/fungible_tokens_value.png")
plt.close()
nft_names = [p.get("name", "") for p in data["NFTs_positions"] if p.get("Value_in_" + inputs.quote_currency, 0) >= min_value_nft]
nft_values = [p.get("Value_in_" + inputs.quote_currency, 0) for p in data["NFTs_positions"] if p.get("Value_in_" + inputs.quote_currency, 0) >= min_value_nft]
sorted_nft = sorted(zip(nft_values, nft_names), reverse=True)
plt.figure(figsize=(10, 6))
plt.bar([name for _, name in sorted_nft], [value for value, _ in sorted_nft])
plt.title("NFTs Value")
plt.xlabel("NFT")
plt.ylabel(f"Value in {inputs.quote_currency}")
plt.xticks(rotation=45, ha="right")
plt.tight_layout()
plt.savefig(f"{home_path}/nfts_value.png")
plt.close()
lp_names = [p.get("ticker", "") for p in data["Liquidity_Provider_Positions"] if p.get("Value_in_" + inputs.quote_currency, 0) >= min_value_lp]
lp_values = [p.get("Value_in_" + inputs.quote_currency, 0) for p in data["Liquidity_Provider_Positions"] if p.get("Value_in_" + inputs.quote_currency, 0) >= min_value_lp]
sorted_lp = sorted(zip(lp_values, lp_names), reverse=True)
plt.figure(figsize=(10, 6))
plt.bar([name for _, name in sorted_lp], [value for value, _ in sorted_lp])
plt.title("Liquidity Provider Positions Value")
plt.xlabel("Position")
plt.ylabel(f"Value in {inputs.quote_currency}")
plt.xticks(rotation=45, ha="right")
plt.tight_layout()
plt.savefig(f"{home_path}/liquidity_provider_positions_value.png")
plt.close()
if inputs.generate_portfolio_trended_value_graphs.lower() == "yes":
trend_base_url = f"{api_base_url}/wallet/value/trended"
trend_response_ada = get(f"{trend_base_url}?address={inputs.address}&timeframe={inputs.portfolio_trended_value_timeframe}"e=ADA", headers=headers)
if trend_response_ada.status_code == 200:
trend_data_ada = trend_response_ada.json()
times_ada = [entry["time"] for entry in trend_data_ada]
values_ada = [entry["value"] for entry in trend_data_ada]
plt.figure(figsize=(10, 6))
plt.plot(times_ada, values_ada, marker='o')
title_ada = "Portfolio Value Trend in ADA (all time)" if inputs.portfolio_trended_value_timeframe == "all" else f"Portfolio Value Trend in ADA over {inputs.portfolio_trended_value_timeframe}"
plt.title(title_ada)
plt.ylabel("Value in ADA")
plt.xticks([])
plt.tight_layout()
plt.savefig(f"{home_path}/portfolio_trend_ada.png")
plt.close()
trend_response_quote = get(f"{trend_base_url}?address={inputs.address}&timeframe={inputs.portfolio_trended_value_timeframe}"e={inputs.quote_currency}", headers=headers)
if trend_response_quote.status_code == 200:
trend_data_quote = trend_response_quote.json()
times_quote = [entry["time"] for entry in trend_data_quote]
values_quote = [entry["value"] for entry in trend_data_quote]
plt.figure(figsize=(10, 6))
plt.plot(times_quote, values_quote, marker='o')
title_quote = f"Portfolio Value Trend in {inputs.quote_currency} (all time)" if inputs.portfolio_trended_value_timeframe == "all" else f"Portfolio Value Trend in {inputs.quote_currency} over {inputs.portfolio_trended_value_timeframe}"
plt.title(title_quote)
plt.ylabel(f"Value in {inputs.quote_currency}")
plt.xticks([])
plt.tight_layout()
plt.savefig(f"{home_path}/portfolio_trend_{inputs.quote_currency.lower()}.png")
plt.close()
else:
try:
error_details = response.json()
error_message = error_details.get("message", "An error occurred")
except json.JSONDecodeError:
error_message = response.text
output.error = f"Error: {error_message} (Status Code: {response.status_code})"
return output
Feel free to extend this tool further by adding additional features or integrating with other APIs !
Shinkai automates tool metadata generation, but you can enhance it.
Good tool metadata should include :
- an explicit tool title,
- a thorough description (features, options, requirements, extra information),
- explicit descriptions for configurations and inputs,
- adequate usable keywords to trigger the tool.
Go to the metadata section, and improve the above. Below is a good metadata for the tool.
{
"homePage": "",
"configurations": {
"type": "object",
"properties": {
"min_quote_value_ft": {
"description": "Minimum value in quote currency for fungible tokens to be included in the graph. This is used to filter out dust tokens from the graph. Default is 1 (1 USD or 1 EUR).",
"type": "string"
},
"min_quote_value_lp": {
"description": "Minimum value in quote currency for liquidity positions to be included in the graph. This is used to filter out eventual residual positions. Default is 1 (1 USD or 1 EUR).",
"type": "string"
},
"min_quote_value_nft": {
"description": "Minimum value in quote currency value for NFT collections to be include in the graph. Default is 1 (1 USD or 1 EUR).",
"type": "string"
},
"taptools_api_key": {
"description": "The API key for TapTools",
"type": "string"
}
},
"required": [
"taptools_api_key"
]
},
"parameters": {
"type": "object",
"properties": {
"address": {
"description": "Cardano wallet address to scan.",
"type": "string"
},
"generate_portfolio_trended_value_graphs": {
"description": "Whether to generate portfolio trended value graphs. yes or no. Default is yes.",
"type": "string"
},
"generate_positions_plots": {
"description": "Whether to generate plots for portfolio positions. yes or no. Default is yes.",
"type": "string"
},
"portfolio_trended_value_timeframe": {
"description": "The timeframe to use for portfolio trended value graphs. Options are 24h, 7d, 30d, 90d, 180d, 1y, all. Default is 30d.",
"type": "string"
},
"quote_currency": {
"description": "The quote currency to use. USD or EUR. Default is USD.",
"type": "string"
}
},
"required": [
"address"
]
},
"result": {
"type": "object",
"properties": {
"ada_price_in_quote": {
"description": "The price of ADA in the quote currency",
"type": "number"
},
"error": {
"description": "Error message if any",
"nullable": true,
"type": "string"
},
"portfolio_positions": {
"description": "The portfolio positions data",
"type": "object"
}
},
"required": [
"portfolio_positions",
"ada_price_in_quote"
]
},
"sqlTables": [],
"sqlQueries": [],
"oauth": [],
"runner": "any",
"operating_system": [
"linux",
"macos",
"windows"
],
"tool_set": "",
"keywords": [
"portfolio",
"taptools",
"cardano",
"address",
"tracker"
],
"version": "1.0.0"
}
Screenshots
Below are 4 screenshots of our portfolio tracker tool being used in Shinkai.