alloc-context 0.2.4__tar.gz → 0.2.6__tar.gz
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.
- {alloc_context-0.2.4 → alloc_context-0.2.6}/PKG-INFO +11 -10
- {alloc_context-0.2.4 → alloc_context-0.2.6}/README.md +8 -7
- {alloc_context-0.2.4 → alloc_context-0.2.6}/alloc_context.egg-info/PKG-INFO +11 -10
- {alloc_context-0.2.4 → alloc_context-0.2.6}/alloc_context.egg-info/SOURCES.txt +9 -0
- {alloc_context-0.2.4 → alloc_context-0.2.6}/alloccontext/__init__.py +1 -1
- {alloc_context-0.2.4 → alloc_context-0.2.6}/alloccontext/config.py +36 -0
- {alloc_context-0.2.4 → alloc_context-0.2.6}/alloccontext/ingest/asset_registry.py +2 -0
- {alloc_context-0.2.4 → alloc_context-0.2.6}/alloccontext/ingest/exchange/live.py +26 -1
- {alloc_context-0.2.4 → alloc_context-0.2.6}/alloccontext/ingest/exchange/types.py +1 -1
- alloc_context-0.2.6/alloccontext/ingest/wallet/__init__.py +6 -0
- alloc_context-0.2.6/alloccontext/ingest/wallet/alchemy.py +191 -0
- alloc_context-0.2.6/alloccontext/ingest/wallet/chains.py +46 -0
- alloc_context-0.2.6/alloccontext/ingest/wallet/curated_tokens.py +66 -0
- alloc_context-0.2.6/alloccontext/ingest/wallet/etherscan.py +131 -0
- alloc_context-0.2.6/alloccontext/ingest/wallet/portfolio.py +315 -0
- {alloc_context-0.2.4 → alloc_context-0.2.6}/alloccontext/mcp/bazaar.py +8 -5
- {alloc_context-0.2.4 → alloc_context-0.2.6}/alloccontext/mcp/handlers.py +17 -12
- {alloc_context-0.2.4 → alloc_context-0.2.6}/alloccontext/mcp/http.py +2 -0
- {alloc_context-0.2.4 → alloc_context-0.2.6}/alloccontext/mcp/instructions.py +3 -2
- {alloc_context-0.2.4 → alloc_context-0.2.6}/alloccontext/mcp/server.py +6 -3
- {alloc_context-0.2.4 → alloc_context-0.2.6}/alloccontext/mcp/tool_catalog.py +18 -13
- {alloc_context-0.2.4 → alloc_context-0.2.6}/alloccontext/mcp/tool_fields.py +17 -3
- {alloc_context-0.2.4 → alloc_context-0.2.6}/pyproject.toml +4 -2
- {alloc_context-0.2.4 → alloc_context-0.2.6}/tests/test_deploy.py +2 -0
- {alloc_context-0.2.4 → alloc_context-0.2.6}/tests/test_mcp_x402.py +28 -0
- alloc_context-0.2.6/tests/test_wallet_alchemy.py +98 -0
- alloc_context-0.2.6/tests/test_wallet_etherscan.py +47 -0
- alloc_context-0.2.6/tests/test_wallet_portfolio.py +139 -0
- {alloc_context-0.2.4 → alloc_context-0.2.6}/tests/test_x402_bazaar_dynamic.py +8 -1
- {alloc_context-0.2.4 → alloc_context-0.2.6}/LICENSE +0 -0
- {alloc_context-0.2.4 → alloc_context-0.2.6}/alloc_context.egg-info/dependency_links.txt +0 -0
- {alloc_context-0.2.4 → alloc_context-0.2.6}/alloc_context.egg-info/entry_points.txt +0 -0
- {alloc_context-0.2.4 → alloc_context-0.2.6}/alloc_context.egg-info/requires.txt +0 -0
- {alloc_context-0.2.4 → alloc_context-0.2.6}/alloc_context.egg-info/top_level.txt +0 -0
- {alloc_context-0.2.4 → alloc_context-0.2.6}/alloccontext/__main__.py +0 -0
- {alloc_context-0.2.4 → alloc_context-0.2.6}/alloccontext/constants.py +0 -0
- {alloc_context-0.2.4 → alloc_context-0.2.6}/alloccontext/horizon.py +0 -0
- {alloc_context-0.2.4 → alloc_context-0.2.6}/alloccontext/ingest/__init__.py +0 -0
- {alloc_context-0.2.4 → alloc_context-0.2.6}/alloccontext/ingest/alt_quote_registry.py +0 -0
- {alloc_context-0.2.4 → alloc_context-0.2.6}/alloccontext/ingest/alt_quote_store.py +0 -0
- {alloc_context-0.2.4 → alloc_context-0.2.6}/alloccontext/ingest/alt_quotes.py +0 -0
- {alloc_context-0.2.4 → alloc_context-0.2.6}/alloccontext/ingest/cf_benchmarks.py +0 -0
- {alloc_context-0.2.4 → alloc_context-0.2.6}/alloccontext/ingest/cf_history.py +0 -0
- {alloc_context-0.2.4 → alloc_context-0.2.6}/alloccontext/ingest/coinbase_client.py +0 -0
- {alloc_context-0.2.4 → alloc_context-0.2.6}/alloccontext/ingest/coinbase_portfolio.py +0 -0
- {alloc_context-0.2.4 → alloc_context-0.2.6}/alloccontext/ingest/coingecko.py +0 -0
- {alloc_context-0.2.4 → alloc_context-0.2.6}/alloccontext/ingest/coinmarketcap.py +0 -0
- {alloc_context-0.2.4 → alloc_context-0.2.6}/alloccontext/ingest/env_keys.py +0 -0
- {alloc_context-0.2.4 → alloc_context-0.2.6}/alloccontext/ingest/etf_flows.py +0 -0
- {alloc_context-0.2.4 → alloc_context-0.2.6}/alloccontext/ingest/exchange/__init__.py +0 -0
- {alloc_context-0.2.4 → alloc_context-0.2.6}/alloccontext/ingest/exchange/coinbase_adapter.py +0 -0
- {alloc_context-0.2.4 → alloc_context-0.2.6}/alloccontext/ingest/exchange/kraken_adapter.py +0 -0
- {alloc_context-0.2.4 → alloc_context-0.2.6}/alloccontext/ingest/exchange/portfolio.py +0 -0
- {alloc_context-0.2.4 → alloc_context-0.2.6}/alloccontext/ingest/exchange/registry.py +0 -0
- {alloc_context-0.2.4 → alloc_context-0.2.6}/alloccontext/ingest/exchange_http.py +0 -0
- {alloc_context-0.2.4 → alloc_context-0.2.6}/alloccontext/ingest/fear_greed.py +0 -0
- {alloc_context-0.2.4 → alloc_context-0.2.6}/alloccontext/ingest/fred.py +0 -0
- {alloc_context-0.2.4 → alloc_context-0.2.6}/alloccontext/ingest/http_errors.py +0 -0
- {alloc_context-0.2.4 → alloc_context-0.2.6}/alloccontext/ingest/kalshi.py +0 -0
- {alloc_context-0.2.4 → alloc_context-0.2.6}/alloccontext/ingest/kalshi_api.py +0 -0
- {alloc_context-0.2.4 → alloc_context-0.2.6}/alloccontext/ingest/kalshi_client.py +0 -0
- {alloc_context-0.2.4 → alloc_context-0.2.6}/alloccontext/ingest/kalshi_files.py +0 -0
- {alloc_context-0.2.4 → alloc_context-0.2.6}/alloccontext/ingest/kalshi_state.py +0 -0
- {alloc_context-0.2.4 → alloc_context-0.2.6}/alloccontext/ingest/kraken_client.py +0 -0
- {alloc_context-0.2.4 → alloc_context-0.2.6}/alloccontext/ingest/kraken_portfolio.py +0 -0
- {alloc_context-0.2.4 → alloc_context-0.2.6}/alloccontext/ingest/macro_calendar.py +0 -0
- {alloc_context-0.2.4 → alloc_context-0.2.6}/alloccontext/ingest/macro_normalize.py +0 -0
- {alloc_context-0.2.4 → alloc_context-0.2.6}/alloccontext/ingest/market_snapshots.py +0 -0
- {alloc_context-0.2.4 → alloc_context-0.2.6}/alloccontext/ingest/outcome.py +0 -0
- {alloc_context-0.2.4 → alloc_context-0.2.6}/alloccontext/ingest/parse_helpers.py +0 -0
- {alloc_context-0.2.4 → alloc_context-0.2.6}/alloccontext/ingest/portfolio_holdings.py +0 -0
- {alloc_context-0.2.4 → alloc_context-0.2.6}/alloccontext/ingest/quote_resolver.py +0 -0
- {alloc_context-0.2.4 → alloc_context-0.2.6}/alloccontext/ingest/runner.py +0 -0
- {alloc_context-0.2.4 → alloc_context-0.2.6}/alloccontext/integrations/__init__.py +0 -0
- {alloc_context-0.2.4 → alloc_context-0.2.6}/alloccontext/integrations/langchain.py +0 -0
- {alloc_context-0.2.4 → alloc_context-0.2.6}/alloccontext/mcp/__init__.py +0 -0
- {alloc_context-0.2.4 → alloc_context-0.2.6}/alloccontext/mcp/assets.py +0 -0
- {alloc_context-0.2.4 → alloc_context-0.2.6}/alloccontext/mcp/bridge.py +0 -0
- {alloc_context-0.2.4 → alloc_context-0.2.6}/alloccontext/mcp/bridge_portfolio.py +0 -0
- {alloc_context-0.2.4 → alloc_context-0.2.6}/alloccontext/mcp/contracts.py +0 -0
- {alloc_context-0.2.4 → alloc_context-0.2.6}/alloccontext/mcp/glama.py +0 -0
- {alloc_context-0.2.4 → alloc_context-0.2.6}/alloccontext/mcp/payer.py +0 -0
- {alloc_context-0.2.4 → alloc_context-0.2.6}/alloccontext/mcp/payment_middleware.py +0 -0
- {alloc_context-0.2.4 → alloc_context-0.2.6}/alloccontext/mcp/setup.py +0 -0
- {alloc_context-0.2.4 → alloc_context-0.2.6}/alloccontext/mcp/staleness.py +0 -0
- {alloc_context-0.2.4 → alloc_context-0.2.6}/alloccontext/mcp/upstream.py +0 -0
- {alloc_context-0.2.4 → alloc_context-0.2.6}/alloccontext/mcp/validation.py +0 -0
- {alloc_context-0.2.4 → alloc_context-0.2.6}/alloccontext/mcp/x402_bazaar_dynamic.py +0 -0
- {alloc_context-0.2.4 → alloc_context-0.2.6}/alloccontext/mcp/x402_config.py +0 -0
- {alloc_context-0.2.4 → alloc_context-0.2.6}/alloccontext/mcp/x402_pricing.py +0 -0
- {alloc_context-0.2.4 → alloc_context-0.2.6}/alloccontext/mcp/x402_stables.py +0 -0
- {alloc_context-0.2.4 → alloc_context-0.2.6}/alloccontext/rollup/__init__.py +0 -0
- {alloc_context-0.2.4 → alloc_context-0.2.6}/alloccontext/rollup/allocation_analysis.py +0 -0
- {alloc_context-0.2.4 → alloc_context-0.2.6}/alloccontext/rollup/band.py +0 -0
- {alloc_context-0.2.4 → alloc_context-0.2.6}/alloccontext/rollup/breadth.py +0 -0
- {alloc_context-0.2.4 → alloc_context-0.2.6}/alloccontext/rollup/cf_math.py +0 -0
- {alloc_context-0.2.4 → alloc_context-0.2.6}/alloccontext/rollup/cluster.py +0 -0
- {alloc_context-0.2.4 → alloc_context-0.2.6}/alloccontext/rollup/cluster_config.py +0 -0
- {alloc_context-0.2.4 → alloc_context-0.2.6}/alloccontext/rollup/comparison.py +0 -0
- {alloc_context-0.2.4 → alloc_context-0.2.6}/alloccontext/rollup/context.py +0 -0
- {alloc_context-0.2.4 → alloc_context-0.2.6}/alloccontext/rollup/delta.py +0 -0
- {alloc_context-0.2.4 → alloc_context-0.2.6}/alloccontext/rollup/etf.py +0 -0
- {alloc_context-0.2.4 → alloc_context-0.2.6}/alloccontext/rollup/fear_greed.py +0 -0
- {alloc_context-0.2.4 → alloc_context-0.2.6}/alloccontext/rollup/macro.py +0 -0
- {alloc_context-0.2.4 → alloc_context-0.2.6}/alloccontext/rollup/portfolio.py +0 -0
- {alloc_context-0.2.4 → alloc_context-0.2.6}/alloccontext/rollup/portfolio_payload.py +0 -0
- {alloc_context-0.2.4 → alloc_context-0.2.6}/alloccontext/rollup/rebalance.py +0 -0
- {alloc_context-0.2.4 → alloc_context-0.2.6}/alloccontext/rollup/regime.py +0 -0
- {alloc_context-0.2.4 → alloc_context-0.2.6}/alloccontext/rollup/sentiment.py +0 -0
- {alloc_context-0.2.4 → alloc_context-0.2.6}/alloccontext/rollup/snapshots.py +0 -0
- {alloc_context-0.2.4 → alloc_context-0.2.6}/alloccontext/rollup/tape.py +0 -0
- {alloc_context-0.2.4 → alloc_context-0.2.6}/alloccontext/status_report.py +0 -0
- {alloc_context-0.2.4 → alloc_context-0.2.6}/alloccontext/store/__init__.py +0 -0
- {alloc_context-0.2.4 → alloc_context-0.2.6}/alloccontext/store/db.py +0 -0
- {alloc_context-0.2.4 → alloc_context-0.2.6}/alloccontext/store/jsonutil.py +0 -0
- {alloc_context-0.2.4 → alloc_context-0.2.6}/alloccontext/store/meta.py +0 -0
- {alloc_context-0.2.4 → alloc_context-0.2.6}/alloccontext/store/retention.py +0 -0
- {alloc_context-0.2.4 → alloc_context-0.2.6}/alloccontext/store/status.py +0 -0
- {alloc_context-0.2.4 → alloc_context-0.2.6}/alloccontext/timeutil.py +0 -0
- {alloc_context-0.2.4 → alloc_context-0.2.6}/alloccontext/user_config.py +0 -0
- {alloc_context-0.2.4 → alloc_context-0.2.6}/alloccontext/x402_production_check.py +0 -0
- {alloc_context-0.2.4 → alloc_context-0.2.6}/alloccontext/x402_smoke_redact.py +0 -0
- {alloc_context-0.2.4 → alloc_context-0.2.6}/setup.cfg +0 -0
- {alloc_context-0.2.4 → alloc_context-0.2.6}/tests/test_backup_sqlite.py +0 -0
- {alloc_context-0.2.4 → alloc_context-0.2.6}/tests/test_bridge.py +0 -0
- {alloc_context-0.2.4 → alloc_context-0.2.6}/tests/test_bridge_portfolio.py +0 -0
- {alloc_context-0.2.4 → alloc_context-0.2.6}/tests/test_bump_version.py +0 -0
- {alloc_context-0.2.4 → alloc_context-0.2.6}/tests/test_check_pypi_release_json.py +0 -0
- {alloc_context-0.2.4 → alloc_context-0.2.6}/tests/test_coinbase_portfolio.py +0 -0
- {alloc_context-0.2.4 → alloc_context-0.2.6}/tests/test_config_cli.py +0 -0
- {alloc_context-0.2.4 → alloc_context-0.2.6}/tests/test_context_bundle_schema.py +0 -0
- {alloc_context-0.2.4 → alloc_context-0.2.6}/tests/test_context_snapshots.py +0 -0
- {alloc_context-0.2.4 → alloc_context-0.2.6}/tests/test_db_schema.py +0 -0
- {alloc_context-0.2.4 → alloc_context-0.2.6}/tests/test_dev_stack.py +0 -0
- {alloc_context-0.2.4 → alloc_context-0.2.6}/tests/test_etf.py +0 -0
- {alloc_context-0.2.4 → alloc_context-0.2.6}/tests/test_exchanges_config.py +0 -0
- {alloc_context-0.2.4 → alloc_context-0.2.6}/tests/test_fear_greed.py +0 -0
- {alloc_context-0.2.4 → alloc_context-0.2.6}/tests/test_fred.py +0 -0
- {alloc_context-0.2.4 → alloc_context-0.2.6}/tests/test_glama_well_known.py +0 -0
- {alloc_context-0.2.4 → alloc_context-0.2.6}/tests/test_holdings_scoped_delta_regime.py +0 -0
- {alloc_context-0.2.4 → alloc_context-0.2.6}/tests/test_holdings_scoped_market.py +0 -0
- {alloc_context-0.2.4 → alloc_context-0.2.6}/tests/test_horizon.py +0 -0
- {alloc_context-0.2.4 → alloc_context-0.2.6}/tests/test_ingest_outcome.py +0 -0
- {alloc_context-0.2.4 → alloc_context-0.2.6}/tests/test_ingest_runner.py +0 -0
- {alloc_context-0.2.4 → alloc_context-0.2.6}/tests/test_ingest_store_integration.py +0 -0
- {alloc_context-0.2.4 → alloc_context-0.2.6}/tests/test_kalshi_api.py +0 -0
- {alloc_context-0.2.4 → alloc_context-0.2.6}/tests/test_kraken_portfolio.py +0 -0
- {alloc_context-0.2.4 → alloc_context-0.2.6}/tests/test_langchain_integration.py +0 -0
- {alloc_context-0.2.4 → alloc_context-0.2.6}/tests/test_live_ingest_handlers.py +0 -0
- {alloc_context-0.2.4 → alloc_context-0.2.6}/tests/test_macro.py +0 -0
- {alloc_context-0.2.4 → alloc_context-0.2.6}/tests/test_market_breadth.py +0 -0
- {alloc_context-0.2.4 → alloc_context-0.2.6}/tests/test_mcp_assets_regime.py +0 -0
- {alloc_context-0.2.4 → alloc_context-0.2.6}/tests/test_mcp_bazaar.py +0 -0
- {alloc_context-0.2.4 → alloc_context-0.2.6}/tests/test_mcp_contracts.py +0 -0
- {alloc_context-0.2.4 → alloc_context-0.2.6}/tests/test_mcp_data_staleness.py +0 -0
- {alloc_context-0.2.4 → alloc_context-0.2.6}/tests/test_mcp_handlers.py +0 -0
- {alloc_context-0.2.4 → alloc_context-0.2.6}/tests/test_mcp_health.py +0 -0
- {alloc_context-0.2.4 → alloc_context-0.2.6}/tests/test_mcp_http_lifecycle.py +0 -0
- {alloc_context-0.2.4 → alloc_context-0.2.6}/tests/test_mcp_live_portfolio.py +0 -0
- {alloc_context-0.2.4 → alloc_context-0.2.6}/tests/test_mcp_server.py +0 -0
- {alloc_context-0.2.4 → alloc_context-0.2.6}/tests/test_mcp_validation.py +0 -0
- {alloc_context-0.2.4 → alloc_context-0.2.6}/tests/test_mcp_x402_http.py +0 -0
- {alloc_context-0.2.4 → alloc_context-0.2.6}/tests/test_mcp_x402_pricing.py +0 -0
- {alloc_context-0.2.4 → alloc_context-0.2.6}/tests/test_mcp_x402_stables.py +0 -0
- {alloc_context-0.2.4 → alloc_context-0.2.6}/tests/test_payer.py +0 -0
- {alloc_context-0.2.4 → alloc_context-0.2.6}/tests/test_portfolio_holdings.py +0 -0
- {alloc_context-0.2.4 → alloc_context-0.2.6}/tests/test_quote_resolver.py +0 -0
- {alloc_context-0.2.4 → alloc_context-0.2.6}/tests/test_rebalance.py +0 -0
- {alloc_context-0.2.4 → alloc_context-0.2.6}/tests/test_rollup.py +0 -0
- {alloc_context-0.2.4 → alloc_context-0.2.6}/tests/test_script_runtime.py +0 -0
- {alloc_context-0.2.4 → alloc_context-0.2.6}/tests/test_security_hardening.py +0 -0
- {alloc_context-0.2.4 → alloc_context-0.2.6}/tests/test_server_json.py +0 -0
- {alloc_context-0.2.4 → alloc_context-0.2.6}/tests/test_setup.py +0 -0
- {alloc_context-0.2.4 → alloc_context-0.2.6}/tests/test_snapshots_and_delta.py +0 -0
- {alloc_context-0.2.4 → alloc_context-0.2.6}/tests/test_status_report.py +0 -0
- {alloc_context-0.2.4 → alloc_context-0.2.6}/tests/test_tool_catalog.py +0 -0
- {alloc_context-0.2.4 → alloc_context-0.2.6}/tests/test_user_config.py +0 -0
- {alloc_context-0.2.4 → alloc_context-0.2.6}/tests/test_workflows.py +0 -0
- {alloc_context-0.2.4 → alloc_context-0.2.6}/tests/test_x402_production_check.py +0 -0
- {alloc_context-0.2.4 → alloc_context-0.2.6}/tests/test_x402_smoke_redact.py +0 -0
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: alloc-context
|
|
3
|
-
Version: 0.2.
|
|
4
|
-
Summary: Portfolio-aware crypto context
|
|
3
|
+
Version: 0.2.6
|
|
4
|
+
Summary: Portfolio-aware crypto context — CEX or wallet holdings, holdings-scoped market, optional allocation analysis
|
|
5
5
|
License: Elastic-2.0
|
|
6
6
|
Project-URL: Homepage, https://mcp.alloc-context.com/llms.txt
|
|
7
7
|
Project-URL: Documentation, https://github.com/AllocContext/alloc-context/blob/main/docs/agent-integration.md
|
|
@@ -9,7 +9,7 @@ Project-URL: Repository, https://github.com/AllocContext/alloc-context
|
|
|
9
9
|
Project-URL: Issues, https://github.com/AllocContext/alloc-context/issues
|
|
10
10
|
Project-URL: Changelog, https://github.com/AllocContext/alloc-context/releases
|
|
11
11
|
Project-URL: MCP Server, https://mcp.alloc-context.com/mcp
|
|
12
|
-
Keywords: mcp,x402,holdings,portfolio,crypto,cryptocurrency,bitcoin,ethereum,agents,allocation,rebalance,coinbase,kraken
|
|
12
|
+
Keywords: mcp,x402,holdings,portfolio,crypto,cryptocurrency,bitcoin,ethereum,agents,allocation,rebalance,coinbase,kraken,wallet,defi
|
|
13
13
|
Classifier: Development Status :: 4 - Beta
|
|
14
14
|
Classifier: Intended Audience :: Developers
|
|
15
15
|
Classifier: Programming Language :: Python :: 3
|
|
@@ -73,9 +73,10 @@ pip install "alloc-context[mcp,hosted]"
|
|
|
73
73
|
**2. User config**
|
|
74
74
|
|
|
75
75
|
Copy [config/user.example.yaml](config/user.example.yaml) to
|
|
76
|
-
`~/.config/alloc-context/user.yaml`.
|
|
77
|
-
|
|
78
|
-
|
|
76
|
+
`~/.config/alloc-context/user.yaml`. For portfolio discovery (optional): read-only
|
|
77
|
+
CEX keys (e.g. Coinbase, Kraken) in user config, or call hosted
|
|
78
|
+
`get_portfolio_state` with `exchange=wallet` and a public EVM address. Add an x402
|
|
79
|
+
payer for hosted market context. See [user-config.md](docs/user-config.md).
|
|
79
80
|
|
|
80
81
|
**3. MCP config**
|
|
81
82
|
|
|
@@ -101,9 +102,9 @@ Use an absolute path for `--user-config`. Example:
|
|
|
101
102
|
|
|
102
103
|
**4. Ask your agent**
|
|
103
104
|
|
|
104
|
-
Call `get_context_bundle` for a full snapshot (holdings when
|
|
105
|
-
market/sentiment/macro via hosted upstream). Pure math tools
|
|
106
|
-
(`check_allocation_band`, `get_rebalance_plan`) work without
|
|
105
|
+
Call `get_context_bundle` for a full snapshot (holdings when a portfolio source
|
|
106
|
+
is configured, market/sentiment/macro via hosted upstream). Pure math tools
|
|
107
|
+
(`check_allocation_band`, `get_rebalance_plan`) work without portfolio credentials.
|
|
107
108
|
|
|
108
109
|
Full setup guide: [cursor-mcp.md](docs/cursor-mcp.md). Sample responses:
|
|
109
110
|
[examples.md](docs/examples.md).
|
|
@@ -135,7 +136,7 @@ combines local portfolio reads with this upstream for market context.
|
|
|
135
136
|
| `get_rebalance_plan` | USD rebalance moves from allocation, target, and NAV |
|
|
136
137
|
| `check_allocation_band` | Drift vs target and whether allocation is outside the band |
|
|
137
138
|
| `check_allocation_bands` | Batch band checks for multiple target scenarios |
|
|
138
|
-
| `get_portfolio_state` | Live NAV and holdings (
|
|
139
|
+
| `get_portfolio_state` | Live NAV and holdings (CEX keys or public EVM wallet address) |
|
|
139
140
|
|
|
140
141
|
Market context is **holdings-scoped**: band assets (BTC/ETH) use OHLC bars; alt
|
|
141
142
|
holdings (e.g. HYPE) use quote snapshots when cached. The bridge auto-scopes
|
|
@@ -28,9 +28,10 @@ pip install "alloc-context[mcp,hosted]"
|
|
|
28
28
|
**2. User config**
|
|
29
29
|
|
|
30
30
|
Copy [config/user.example.yaml](config/user.example.yaml) to
|
|
31
|
-
`~/.config/alloc-context/user.yaml`.
|
|
32
|
-
|
|
33
|
-
|
|
31
|
+
`~/.config/alloc-context/user.yaml`. For portfolio discovery (optional): read-only
|
|
32
|
+
CEX keys (e.g. Coinbase, Kraken) in user config, or call hosted
|
|
33
|
+
`get_portfolio_state` with `exchange=wallet` and a public EVM address. Add an x402
|
|
34
|
+
payer for hosted market context. See [user-config.md](docs/user-config.md).
|
|
34
35
|
|
|
35
36
|
**3. MCP config**
|
|
36
37
|
|
|
@@ -56,9 +57,9 @@ Use an absolute path for `--user-config`. Example:
|
|
|
56
57
|
|
|
57
58
|
**4. Ask your agent**
|
|
58
59
|
|
|
59
|
-
Call `get_context_bundle` for a full snapshot (holdings when
|
|
60
|
-
market/sentiment/macro via hosted upstream). Pure math tools
|
|
61
|
-
(`check_allocation_band`, `get_rebalance_plan`) work without
|
|
60
|
+
Call `get_context_bundle` for a full snapshot (holdings when a portfolio source
|
|
61
|
+
is configured, market/sentiment/macro via hosted upstream). Pure math tools
|
|
62
|
+
(`check_allocation_band`, `get_rebalance_plan`) work without portfolio credentials.
|
|
62
63
|
|
|
63
64
|
Full setup guide: [cursor-mcp.md](docs/cursor-mcp.md). Sample responses:
|
|
64
65
|
[examples.md](docs/examples.md).
|
|
@@ -90,7 +91,7 @@ combines local portfolio reads with this upstream for market context.
|
|
|
90
91
|
| `get_rebalance_plan` | USD rebalance moves from allocation, target, and NAV |
|
|
91
92
|
| `check_allocation_band` | Drift vs target and whether allocation is outside the band |
|
|
92
93
|
| `check_allocation_bands` | Batch band checks for multiple target scenarios |
|
|
93
|
-
| `get_portfolio_state` | Live NAV and holdings (
|
|
94
|
+
| `get_portfolio_state` | Live NAV and holdings (CEX keys or public EVM wallet address) |
|
|
94
95
|
|
|
95
96
|
Market context is **holdings-scoped**: band assets (BTC/ETH) use OHLC bars; alt
|
|
96
97
|
holdings (e.g. HYPE) use quote snapshots when cached. The bridge auto-scopes
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: alloc-context
|
|
3
|
-
Version: 0.2.
|
|
4
|
-
Summary: Portfolio-aware crypto context
|
|
3
|
+
Version: 0.2.6
|
|
4
|
+
Summary: Portfolio-aware crypto context — CEX or wallet holdings, holdings-scoped market, optional allocation analysis
|
|
5
5
|
License: Elastic-2.0
|
|
6
6
|
Project-URL: Homepage, https://mcp.alloc-context.com/llms.txt
|
|
7
7
|
Project-URL: Documentation, https://github.com/AllocContext/alloc-context/blob/main/docs/agent-integration.md
|
|
@@ -9,7 +9,7 @@ Project-URL: Repository, https://github.com/AllocContext/alloc-context
|
|
|
9
9
|
Project-URL: Issues, https://github.com/AllocContext/alloc-context/issues
|
|
10
10
|
Project-URL: Changelog, https://github.com/AllocContext/alloc-context/releases
|
|
11
11
|
Project-URL: MCP Server, https://mcp.alloc-context.com/mcp
|
|
12
|
-
Keywords: mcp,x402,holdings,portfolio,crypto,cryptocurrency,bitcoin,ethereum,agents,allocation,rebalance,coinbase,kraken
|
|
12
|
+
Keywords: mcp,x402,holdings,portfolio,crypto,cryptocurrency,bitcoin,ethereum,agents,allocation,rebalance,coinbase,kraken,wallet,defi
|
|
13
13
|
Classifier: Development Status :: 4 - Beta
|
|
14
14
|
Classifier: Intended Audience :: Developers
|
|
15
15
|
Classifier: Programming Language :: Python :: 3
|
|
@@ -73,9 +73,10 @@ pip install "alloc-context[mcp,hosted]"
|
|
|
73
73
|
**2. User config**
|
|
74
74
|
|
|
75
75
|
Copy [config/user.example.yaml](config/user.example.yaml) to
|
|
76
|
-
`~/.config/alloc-context/user.yaml`.
|
|
77
|
-
|
|
78
|
-
|
|
76
|
+
`~/.config/alloc-context/user.yaml`. For portfolio discovery (optional): read-only
|
|
77
|
+
CEX keys (e.g. Coinbase, Kraken) in user config, or call hosted
|
|
78
|
+
`get_portfolio_state` with `exchange=wallet` and a public EVM address. Add an x402
|
|
79
|
+
payer for hosted market context. See [user-config.md](docs/user-config.md).
|
|
79
80
|
|
|
80
81
|
**3. MCP config**
|
|
81
82
|
|
|
@@ -101,9 +102,9 @@ Use an absolute path for `--user-config`. Example:
|
|
|
101
102
|
|
|
102
103
|
**4. Ask your agent**
|
|
103
104
|
|
|
104
|
-
Call `get_context_bundle` for a full snapshot (holdings when
|
|
105
|
-
market/sentiment/macro via hosted upstream). Pure math tools
|
|
106
|
-
(`check_allocation_band`, `get_rebalance_plan`) work without
|
|
105
|
+
Call `get_context_bundle` for a full snapshot (holdings when a portfolio source
|
|
106
|
+
is configured, market/sentiment/macro via hosted upstream). Pure math tools
|
|
107
|
+
(`check_allocation_band`, `get_rebalance_plan`) work without portfolio credentials.
|
|
107
108
|
|
|
108
109
|
Full setup guide: [cursor-mcp.md](docs/cursor-mcp.md). Sample responses:
|
|
109
110
|
[examples.md](docs/examples.md).
|
|
@@ -135,7 +136,7 @@ combines local portfolio reads with this upstream for market context.
|
|
|
135
136
|
| `get_rebalance_plan` | USD rebalance moves from allocation, target, and NAV |
|
|
136
137
|
| `check_allocation_band` | Drift vs target and whether allocation is outside the band |
|
|
137
138
|
| `check_allocation_bands` | Batch band checks for multiple target scenarios |
|
|
138
|
-
| `get_portfolio_state` | Live NAV and holdings (
|
|
139
|
+
| `get_portfolio_state` | Live NAV and holdings (CEX keys or public EVM wallet address) |
|
|
139
140
|
|
|
140
141
|
Market context is **holdings-scoped**: band assets (BTC/ETH) use OHLC bars; alt
|
|
141
142
|
holdings (e.g. HYPE) use quote snapshots when cached. The bridge auto-scopes
|
|
@@ -56,6 +56,12 @@ alloccontext/ingest/exchange/live.py
|
|
|
56
56
|
alloccontext/ingest/exchange/portfolio.py
|
|
57
57
|
alloccontext/ingest/exchange/registry.py
|
|
58
58
|
alloccontext/ingest/exchange/types.py
|
|
59
|
+
alloccontext/ingest/wallet/__init__.py
|
|
60
|
+
alloccontext/ingest/wallet/alchemy.py
|
|
61
|
+
alloccontext/ingest/wallet/chains.py
|
|
62
|
+
alloccontext/ingest/wallet/curated_tokens.py
|
|
63
|
+
alloccontext/ingest/wallet/etherscan.py
|
|
64
|
+
alloccontext/ingest/wallet/portfolio.py
|
|
59
65
|
alloccontext/integrations/__init__.py
|
|
60
66
|
alloccontext/integrations/langchain.py
|
|
61
67
|
alloccontext/mcp/__init__.py
|
|
@@ -163,6 +169,9 @@ tests/test_snapshots_and_delta.py
|
|
|
163
169
|
tests/test_status_report.py
|
|
164
170
|
tests/test_tool_catalog.py
|
|
165
171
|
tests/test_user_config.py
|
|
172
|
+
tests/test_wallet_alchemy.py
|
|
173
|
+
tests/test_wallet_etherscan.py
|
|
174
|
+
tests/test_wallet_portfolio.py
|
|
166
175
|
tests/test_workflows.py
|
|
167
176
|
tests/test_x402_bazaar_dynamic.py
|
|
168
177
|
tests/test_x402_production_check.py
|
|
@@ -8,6 +8,7 @@ from urllib.parse import urlparse
|
|
|
8
8
|
|
|
9
9
|
import yaml
|
|
10
10
|
|
|
11
|
+
from alloccontext.ingest.wallet.chains import DEFAULT_WALLET_CHAIN_IDS
|
|
11
12
|
from alloccontext.rollup.cluster_config import RollupConfig, load_rollup_config
|
|
12
13
|
|
|
13
14
|
|
|
@@ -101,6 +102,17 @@ class ExchangesConfig:
|
|
|
101
102
|
raise ValueError(f"unsupported primary exchange: {self.primary}")
|
|
102
103
|
|
|
103
104
|
|
|
105
|
+
@dataclass(frozen=True)
|
|
106
|
+
class WalletConfig:
|
|
107
|
+
enabled: bool
|
|
108
|
+
provider: str
|
|
109
|
+
chain_ids: tuple[int, ...]
|
|
110
|
+
min_value_usd: float
|
|
111
|
+
timeout_seconds: float
|
|
112
|
+
max_retries: int
|
|
113
|
+
retry_backoff_seconds: float
|
|
114
|
+
|
|
115
|
+
|
|
104
116
|
@dataclass(frozen=True)
|
|
105
117
|
class KalshiSeriesConfig:
|
|
106
118
|
asset: str
|
|
@@ -175,6 +187,7 @@ class AppConfig:
|
|
|
175
187
|
ingest: IngestConfig
|
|
176
188
|
kraken: KrakenConfig
|
|
177
189
|
exchanges: ExchangesConfig
|
|
190
|
+
wallet: WalletConfig
|
|
178
191
|
kalshi: KalshiConfig
|
|
179
192
|
rollup: RollupConfig
|
|
180
193
|
macro: MacroConfig
|
|
@@ -240,6 +253,27 @@ def _kraken_config_from_spot(spot: SpotExchangeConfig) -> KrakenConfig:
|
|
|
240
253
|
)
|
|
241
254
|
|
|
242
255
|
|
|
256
|
+
def _load_wallet_config(raw: dict[str, Any]) -> WalletConfig:
|
|
257
|
+
wallet_raw = raw.get("wallet") or {}
|
|
258
|
+
chain_ids_raw = wallet_raw.get("chain_ids")
|
|
259
|
+
if chain_ids_raw:
|
|
260
|
+
chain_ids = tuple(int(chain_id) for chain_id in chain_ids_raw)
|
|
261
|
+
else:
|
|
262
|
+
chain_ids = DEFAULT_WALLET_CHAIN_IDS
|
|
263
|
+
provider = str(wallet_raw.get("provider") or "alchemy").strip().lower()
|
|
264
|
+
if provider not in {"alchemy", "etherscan"}:
|
|
265
|
+
raise ValueError(f"unsupported wallet.provider: {provider}")
|
|
266
|
+
return WalletConfig(
|
|
267
|
+
enabled=bool(wallet_raw.get("enabled", True)),
|
|
268
|
+
provider=provider,
|
|
269
|
+
chain_ids=chain_ids,
|
|
270
|
+
min_value_usd=float(wallet_raw.get("min_value_usd") or 1.0),
|
|
271
|
+
timeout_seconds=float(wallet_raw.get("timeout_seconds") or 20.0),
|
|
272
|
+
max_retries=int(wallet_raw.get("max_retries") or 3),
|
|
273
|
+
retry_backoff_seconds=float(wallet_raw.get("retry_backoff_seconds") or 2.0),
|
|
274
|
+
)
|
|
275
|
+
|
|
276
|
+
|
|
243
277
|
def _load_exchanges_config(
|
|
244
278
|
raw: dict[str, Any],
|
|
245
279
|
*,
|
|
@@ -319,6 +353,7 @@ def load_config(path: str | Path | None = None) -> AppConfig:
|
|
|
319
353
|
ingest_sources=ingest_sources,
|
|
320
354
|
)
|
|
321
355
|
kraken = _kraken_config_from_spot(exchanges.kraken)
|
|
356
|
+
wallet = _load_wallet_config(raw)
|
|
322
357
|
|
|
323
358
|
return AppConfig(
|
|
324
359
|
paths=PathsConfig(
|
|
@@ -340,6 +375,7 @@ def load_config(path: str | Path | None = None) -> AppConfig:
|
|
|
340
375
|
),
|
|
341
376
|
kraken=kraken,
|
|
342
377
|
exchanges=exchanges,
|
|
378
|
+
wallet=wallet,
|
|
343
379
|
kalshi=KalshiConfig(
|
|
344
380
|
use_api=bool(kalshi_raw.get("use_api", True)),
|
|
345
381
|
base_url=_validate_kalshi_base_url(
|
|
@@ -15,7 +15,8 @@ from alloccontext.mcp.validation import validate_band, validate_target_pct
|
|
|
15
15
|
from alloccontext.rollup.allocation_analysis import build_allocation_analysis
|
|
16
16
|
from alloccontext.rollup.portfolio_payload import portfolio_dict_from_snapshot
|
|
17
17
|
|
|
18
|
-
SUPPORTED_EXCHANGES = frozenset({"kraken", "coinbase"})
|
|
18
|
+
SUPPORTED_EXCHANGES = frozenset({"kraken", "coinbase", "wallet"})
|
|
19
|
+
CEX_EXCHANGES = frozenset({"kraken", "coinbase"})
|
|
19
20
|
|
|
20
21
|
|
|
21
22
|
class LivePortfolioError(Exception):
|
|
@@ -29,6 +30,13 @@ def validate_exchange_id(exchange: str) -> ExchangeId:
|
|
|
29
30
|
return key # type: ignore[return-value]
|
|
30
31
|
|
|
31
32
|
|
|
33
|
+
def validate_cex_exchange_id(exchange: str) -> ExchangeId:
|
|
34
|
+
key = exchange.strip().lower()
|
|
35
|
+
if key not in CEX_EXCHANGES:
|
|
36
|
+
raise ValueError(f"unsupported exchange: {exchange}")
|
|
37
|
+
return key # type: ignore[return-value]
|
|
38
|
+
|
|
39
|
+
|
|
32
40
|
def _spot_config(config, exchange_id: ExchangeId):
|
|
33
41
|
if exchange_id == "kraken":
|
|
34
42
|
return config.exchanges.kraken
|
|
@@ -40,7 +48,24 @@ def fetch_live_portfolio_snapshot(
|
|
|
40
48
|
api_key: str,
|
|
41
49
|
api_secret: str,
|
|
42
50
|
config,
|
|
51
|
+
*,
|
|
52
|
+
wallet_address: str | None = None,
|
|
43
53
|
) -> PortfolioSnapshot:
|
|
54
|
+
if exchange_id == "wallet":
|
|
55
|
+
from alloccontext.ingest.wallet.portfolio import (
|
|
56
|
+
WalletPortfolioError,
|
|
57
|
+
fetch_wallet_portfolio_snapshot,
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
if not wallet_address or not wallet_address.strip():
|
|
61
|
+
raise LivePortfolioError("wallet_address is required")
|
|
62
|
+
try:
|
|
63
|
+
return fetch_wallet_portfolio_snapshot(wallet_address, config)
|
|
64
|
+
except WalletPortfolioError as exc:
|
|
65
|
+
raise LivePortfolioError(str(exc)) from exc
|
|
66
|
+
except ValueError as exc:
|
|
67
|
+
raise LivePortfolioError(str(exc)) from exc
|
|
68
|
+
|
|
44
69
|
spot = _spot_config(config, exchange_id)
|
|
45
70
|
resolver_config = quote_resolver_config_from_app(config)
|
|
46
71
|
key = api_key.strip()
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import time
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
import requests
|
|
8
|
+
|
|
9
|
+
from alloccontext.ingest.exchange_http import should_retry_exchange_attempt
|
|
10
|
+
from alloccontext.ingest.wallet.chains import alchemy_networks_for_chain_ids
|
|
11
|
+
|
|
12
|
+
ALCHEMY_DATA_BASE = "https://api.g.alchemy.com/data/v1"
|
|
13
|
+
|
|
14
|
+
# Native token metadata is often null; map network slug → band symbol.
|
|
15
|
+
NATIVE_SYMBOL_BY_NETWORK: dict[str, str] = {
|
|
16
|
+
"eth-mainnet": "ETH",
|
|
17
|
+
"arb-mainnet": "ETH",
|
|
18
|
+
"base-mainnet": "ETH",
|
|
19
|
+
"opt-mainnet": "ETH",
|
|
20
|
+
"polygon-mainnet": "POL",
|
|
21
|
+
"matic-mainnet": "POL",
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class AlchemyError(Exception):
|
|
26
|
+
pass
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass(frozen=True)
|
|
30
|
+
class AlchemyTokenRow:
|
|
31
|
+
symbol: str
|
|
32
|
+
quantity: float
|
|
33
|
+
price_usd: float | None = None
|
|
34
|
+
is_native: bool = False
|
|
35
|
+
token_address: str | None = None
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class AlchemyClient:
|
|
39
|
+
def __init__(
|
|
40
|
+
self,
|
|
41
|
+
api_key: str,
|
|
42
|
+
*,
|
|
43
|
+
timeout_seconds: float = 20.0,
|
|
44
|
+
max_retries: int = 3,
|
|
45
|
+
retry_backoff_seconds: float = 2.0,
|
|
46
|
+
) -> None:
|
|
47
|
+
key = api_key.strip()
|
|
48
|
+
if not key:
|
|
49
|
+
raise AlchemyError("alchemy_api_key_required")
|
|
50
|
+
self._api_key = key
|
|
51
|
+
self._timeout = timeout_seconds
|
|
52
|
+
self._max_retries = max_retries
|
|
53
|
+
self._retry_backoff = retry_backoff_seconds
|
|
54
|
+
|
|
55
|
+
def token_balances(
|
|
56
|
+
self,
|
|
57
|
+
address: str,
|
|
58
|
+
chain_ids: tuple[int, ...],
|
|
59
|
+
) -> list[AlchemyTokenRow]:
|
|
60
|
+
networks = alchemy_networks_for_chain_ids(chain_ids)
|
|
61
|
+
url = f"{ALCHEMY_DATA_BASE}/{self._api_key}/assets/tokens/by-address"
|
|
62
|
+
rows: list[AlchemyTokenRow] = []
|
|
63
|
+
page_key: str | None = None
|
|
64
|
+
while True:
|
|
65
|
+
payload = self._post(
|
|
66
|
+
url,
|
|
67
|
+
{
|
|
68
|
+
"addresses": [{"address": address, "networks": list(networks)}],
|
|
69
|
+
"includeNativeTokens": True,
|
|
70
|
+
"includeErc20Tokens": True,
|
|
71
|
+
"withMetadata": True,
|
|
72
|
+
"withPrices": True,
|
|
73
|
+
**({"pageKey": page_key} if page_key else {}),
|
|
74
|
+
},
|
|
75
|
+
)
|
|
76
|
+
data = payload.get("data") or {}
|
|
77
|
+
tokens = data.get("tokens") or []
|
|
78
|
+
if not isinstance(tokens, list):
|
|
79
|
+
break
|
|
80
|
+
for token in tokens:
|
|
81
|
+
parsed = _parse_token_row(token)
|
|
82
|
+
if parsed is not None:
|
|
83
|
+
rows.append(parsed)
|
|
84
|
+
page_key = data.get("pageKey")
|
|
85
|
+
if not page_key:
|
|
86
|
+
break
|
|
87
|
+
return rows
|
|
88
|
+
|
|
89
|
+
def _post(self, url: str, body: dict[str, Any]) -> dict[str, Any]:
|
|
90
|
+
last_exc: Exception | None = None
|
|
91
|
+
for attempt in range(self._max_retries + 1):
|
|
92
|
+
try:
|
|
93
|
+
response = requests.post(
|
|
94
|
+
url,
|
|
95
|
+
json=body,
|
|
96
|
+
headers={"Content-Type": "application/json"},
|
|
97
|
+
timeout=self._timeout,
|
|
98
|
+
)
|
|
99
|
+
if response.status_code == 429:
|
|
100
|
+
raise AlchemyError("alchemy_rate_limit")
|
|
101
|
+
response.raise_for_status()
|
|
102
|
+
payload = response.json()
|
|
103
|
+
if not isinstance(payload, dict):
|
|
104
|
+
raise AlchemyError("invalid_alchemy_response")
|
|
105
|
+
if payload.get("error"):
|
|
106
|
+
message = str((payload.get("error") or {}).get("message") or payload)
|
|
107
|
+
raise AlchemyError(message)
|
|
108
|
+
return payload
|
|
109
|
+
except Exception as exc: # noqa: BLE001
|
|
110
|
+
last_exc = exc
|
|
111
|
+
if attempt >= self._max_retries or not _should_retry_alchemy(exc):
|
|
112
|
+
break
|
|
113
|
+
time.sleep(self._retry_backoff * (attempt + 1))
|
|
114
|
+
if isinstance(last_exc, AlchemyError):
|
|
115
|
+
raise last_exc
|
|
116
|
+
if last_exc is not None:
|
|
117
|
+
raise AlchemyError(str(last_exc)) from last_exc
|
|
118
|
+
raise AlchemyError("alchemy_request_failed")
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _should_retry_alchemy(exc: Exception) -> bool:
|
|
122
|
+
if isinstance(exc, AlchemyError):
|
|
123
|
+
detail = str(exc).lower()
|
|
124
|
+
if "rate limit" in detail or "rate_limit" in detail:
|
|
125
|
+
return True
|
|
126
|
+
return should_retry_exchange_attempt(exc)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _parse_token_row(token: dict[str, Any]) -> AlchemyTokenRow | None:
|
|
130
|
+
if not isinstance(token, dict):
|
|
131
|
+
return None
|
|
132
|
+
network = str(token.get("network") or "")
|
|
133
|
+
token_address = token.get("tokenAddress")
|
|
134
|
+
is_native = not token_address
|
|
135
|
+
metadata = token.get("tokenMetadata") or {}
|
|
136
|
+
symbol = str(metadata.get("symbol") or "").strip()
|
|
137
|
+
if not symbol:
|
|
138
|
+
symbol = NATIVE_SYMBOL_BY_NETWORK.get(network, "")
|
|
139
|
+
if not symbol or len(symbol) > 20:
|
|
140
|
+
return None
|
|
141
|
+
decimals = metadata.get("decimals")
|
|
142
|
+
if is_native:
|
|
143
|
+
decimals_int = 18
|
|
144
|
+
else:
|
|
145
|
+
if decimals is None:
|
|
146
|
+
return None
|
|
147
|
+
try:
|
|
148
|
+
decimals_int = int(decimals)
|
|
149
|
+
except (TypeError, ValueError):
|
|
150
|
+
return None
|
|
151
|
+
quantity = _parse_hex_balance(str(token.get("tokenBalance") or "0x0"), decimals_int)
|
|
152
|
+
if quantity <= 0:
|
|
153
|
+
return None
|
|
154
|
+
price_usd = _price_usd_per_unit(token.get("tokenPrices"))
|
|
155
|
+
contract = str(token_address).lower() if token_address else None
|
|
156
|
+
return AlchemyTokenRow(
|
|
157
|
+
symbol=symbol,
|
|
158
|
+
quantity=quantity,
|
|
159
|
+
price_usd=price_usd,
|
|
160
|
+
is_native=is_native,
|
|
161
|
+
token_address=contract,
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _parse_hex_balance(raw: str, decimals: int) -> float:
|
|
166
|
+
if not raw or raw in {"0x", "0x0"}:
|
|
167
|
+
return 0.0
|
|
168
|
+
try:
|
|
169
|
+
amount = int(raw, 16)
|
|
170
|
+
except ValueError:
|
|
171
|
+
return 0.0
|
|
172
|
+
if amount <= 0:
|
|
173
|
+
return 0.0
|
|
174
|
+
return amount / (10**decimals)
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def _price_usd_per_unit(token_prices: Any) -> float | None:
|
|
178
|
+
if not isinstance(token_prices, list):
|
|
179
|
+
return None
|
|
180
|
+
for row in token_prices:
|
|
181
|
+
if not isinstance(row, dict):
|
|
182
|
+
continue
|
|
183
|
+
if str(row.get("currency") or "").lower() != "usd":
|
|
184
|
+
continue
|
|
185
|
+
try:
|
|
186
|
+
value = float(row.get("value"))
|
|
187
|
+
except (TypeError, ValueError):
|
|
188
|
+
continue
|
|
189
|
+
if value > 0:
|
|
190
|
+
return value
|
|
191
|
+
return None
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
|
|
5
|
+
# ADR-012 D1: EVM mainnet + major L2s (deterministic, bounded set).
|
|
6
|
+
DEFAULT_WALLET_CHAIN_IDS: tuple[int, ...] = (1, 42161, 8453, 10, 137)
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass(frozen=True)
|
|
10
|
+
class EvmChain:
|
|
11
|
+
chain_id: int
|
|
12
|
+
label: str
|
|
13
|
+
native_symbol: str
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
EVM_CHAINS: dict[int, EvmChain] = {
|
|
17
|
+
1: EvmChain(chain_id=1, label="ethereum", native_symbol="ETH"),
|
|
18
|
+
42161: EvmChain(chain_id=42161, label="arbitrum", native_symbol="ETH"),
|
|
19
|
+
8453: EvmChain(chain_id=8453, label="base", native_symbol="ETH"),
|
|
20
|
+
10: EvmChain(chain_id=10, label="optimism", native_symbol="ETH"),
|
|
21
|
+
137: EvmChain(chain_id=137, label="polygon", native_symbol="POL"),
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
# Alchemy Portfolio API network slugs (see dashboard.alchemy.com/chains).
|
|
26
|
+
ALCHEMY_NETWORK_BY_CHAIN_ID: dict[int, str] = {
|
|
27
|
+
1: "eth-mainnet",
|
|
28
|
+
42161: "arb-mainnet",
|
|
29
|
+
8453: "base-mainnet",
|
|
30
|
+
10: "opt-mainnet",
|
|
31
|
+
137: "polygon-mainnet",
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def resolve_wallet_chains(chain_ids: tuple[int, ...]) -> tuple[EvmChain, ...]:
|
|
36
|
+
unknown = [chain_id for chain_id in chain_ids if chain_id not in EVM_CHAINS]
|
|
37
|
+
if unknown:
|
|
38
|
+
raise ValueError(f"unsupported wallet chain_ids: {unknown}")
|
|
39
|
+
return tuple(EVM_CHAINS[chain_id] for chain_id in chain_ids)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def alchemy_networks_for_chain_ids(chain_ids: tuple[int, ...]) -> tuple[str, ...]:
|
|
43
|
+
unknown = [chain_id for chain_id in chain_ids if chain_id not in ALCHEMY_NETWORK_BY_CHAIN_ID]
|
|
44
|
+
if unknown:
|
|
45
|
+
raise ValueError(f"unsupported wallet chain_ids for alchemy: {unknown}")
|
|
46
|
+
return tuple(ALCHEMY_NETWORK_BY_CHAIN_ID[chain_id] for chain_id in chain_ids)
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
|
|
5
|
+
from alloccontext.ingest.wallet.chains import DEFAULT_WALLET_CHAIN_IDS
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@dataclass(frozen=True)
|
|
9
|
+
class CuratedToken:
|
|
10
|
+
contract: str
|
|
11
|
+
symbol: str
|
|
12
|
+
decimals: int
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
# Free-tier Etherscan tokenbalance lookups (addresstokenbalance is API Pro).
|
|
16
|
+
_CURATED_BY_CHAIN: dict[int, tuple[CuratedToken, ...]] = {
|
|
17
|
+
1: (
|
|
18
|
+
CuratedToken("0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48", "USDC", 6),
|
|
19
|
+
CuratedToken("0xdac17f958d2ee523a2206206994597c13d831ec7", "USDT", 6),
|
|
20
|
+
CuratedToken("0x6b175474e89094c44da98b954eedeac495271d0f", "DAI", 18),
|
|
21
|
+
CuratedToken("0x2260fac5e5542a773aa44fbcfedf7c193bc2c599", "WBTC", 8),
|
|
22
|
+
CuratedToken("0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2", "WETH", 18),
|
|
23
|
+
),
|
|
24
|
+
42161: (
|
|
25
|
+
CuratedToken("0xaf88d065e77c8cc2239327c5edb3a432268e5831", "USDC", 6),
|
|
26
|
+
CuratedToken("0xff970a61a04b1ca14834a43f5de4533ebddb5cc8", "USDC", 6),
|
|
27
|
+
CuratedToken("0xfd086bc7cd5c481dcc9c85ebe478a1c0b69fcbb9", "USDT", 6),
|
|
28
|
+
CuratedToken("0xda10009cbd5d07dd0cecc66161fc93d7c9000da1", "DAI", 18),
|
|
29
|
+
CuratedToken("0x2f2a2543b76a4166549f7aab2e75bef0aefc5b0f", "WBTC", 8),
|
|
30
|
+
CuratedToken("0x82af49447d8a07e3bd95bd0d56f35241523fbab1", "WETH", 18),
|
|
31
|
+
),
|
|
32
|
+
8453: (
|
|
33
|
+
CuratedToken("0x833589fcd6edb6e08f4c7c32d4f71b54bda02913", "USDC", 6),
|
|
34
|
+
CuratedToken("0xd9aaec86b65d6f9524af1fdb8ae83789d97377ce", "USDC", 6),
|
|
35
|
+
CuratedToken("0x4200000000000000000000000000000000000006", "WETH", 18),
|
|
36
|
+
CuratedToken("0x2ae3f1ec7f1f5012cfeab0185bfc2aacee711e6", "CBETH", 18),
|
|
37
|
+
),
|
|
38
|
+
10: (
|
|
39
|
+
CuratedToken("0x0b2c639c533813c4aa6d0577d8ec5024ba0262b2", "USDC", 6),
|
|
40
|
+
CuratedToken("0x7f5c764cbc14f9669b88837ca1490cca17c31607", "USDC", 6),
|
|
41
|
+
CuratedToken("0x94b008aa00543c1307b0b1d40c0e6cbfc7971044", "USDT", 6),
|
|
42
|
+
CuratedToken("0xda10009cbd5d07dd0cecc66161fc93d7c9000da1", "DAI", 18),
|
|
43
|
+
CuratedToken("0x68f180fcce6831688e6282403cb1d48dc36a3d3", "WBTC", 8),
|
|
44
|
+
CuratedToken("0x4200000000000000000000000000000000000006", "WETH", 18),
|
|
45
|
+
),
|
|
46
|
+
137: (
|
|
47
|
+
CuratedToken("0x3c499c542cef5e3811e1192ce70d8cc03d5c3359", "USDC", 6),
|
|
48
|
+
CuratedToken("0x2791bca1f2de4661ed88a30c99a7a9449aa84174", "USDC", 6),
|
|
49
|
+
CuratedToken("0xc2132d05d31c914a87c6611c10748aeb04b58e8f", "USDT", 6),
|
|
50
|
+
CuratedToken("0x8f3cf7ad23cd3cadbd9735aff958023239c6a063", "DAI", 18),
|
|
51
|
+
CuratedToken("0x1bfd67037b42cf73acf2047067bd4f2c47d9bfd6", "WBTC", 8),
|
|
52
|
+
CuratedToken("0x7ceb23fd6bc0add59e62ac25578270cff1b9f619", "WETH", 18),
|
|
53
|
+
),
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def curated_tokens_for_chain(chain_id: int) -> tuple[CuratedToken, ...]:
|
|
58
|
+
return _CURATED_BY_CHAIN.get(chain_id, ())
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def curated_tokens_for_chains(chain_ids: tuple[int, ...]) -> dict[int, tuple[CuratedToken, ...]]:
|
|
62
|
+
return {chain_id: curated_tokens_for_chain(chain_id) for chain_id in chain_ids}
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def default_curated_chain_ids() -> tuple[int, ...]:
|
|
66
|
+
return DEFAULT_WALLET_CHAIN_IDS
|