
Katbotai Hyperliquid Trader
- 1 installs
- Updated March 25, 2026
- claytantor/katbotai-hyperliquid-trader
Execute Hyperliquid Perpetuals trading with recommendations and position management
About
KatbotAI Hyperliquid Trader enables autonomous trading of Hyperliquid Perpetuals through AI agent recommendations. Use it to execute trades, manage positions, and optimize trading strategies.
- Hyperliquid Perpetuals trading via Katbot API
- Trade recommendations and position management
Katbotai Hyperliquid Trader by the numbers
- 1 all-time installs (skills.sh)
- Ranked #909 of 1,106 Finance & Trading skills by installs in the Skillselion catalog
- Data as of Jul 8, 2026 (Skillselion catalog sync)
npx skills add https://github.com/claytantor/katbotai-hyperliquid-trader --skill katbotai-hyperliquid-traderAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 1 |
|---|---|
| Last updated | March 25, 2026 |
| Repository | claytantor/katbotai-hyperliquid-trader ↗ |
What it does
Execute Hyperliquid Perpetuals trading with recommendations and position management
Files
katbotai-hyperliquid-trader Skill
This skill allows the agent to trade Hyperliquid Perpetuals by using the Katbot.ai API. The agent can get reccomendations, execute trades, manage positions, and monitor market conditions to optimize trading strategies.
# ClawdHub install metadata (created on install, not part of skill)
.clawdhub/
.clawhub/
# Python
__pycache__/
*.py[cod]
*$py.class
.venv/
venv/
env/
# Environment variables
.env
.env.*
# OS files
.DS_Store
Thumbs.db
# Private keys and secrets
*.pem
*.key
katbot_secrets.json
katbot-identity/.installed_version
3.13
def main():
print("Hello from katbotai-hyperliquid-trader!")
if __name__ == "__main__":
main()
.PHONY: publish publish-bump publish-bump-build publish-dry-run publish-verbose publish-help
# Publish the skill to clawhub
publish:
@bash scripts/publish.sh
# Auto-increment minor version and publish
publish-bump:
@bash scripts/publish.sh --bump
# Auto-increment patch/build number and publish
publish-bump-build:
@bash scripts/publish.sh --bump-patch
# Perform a dry-run of the publish process
publish-dry-run:
@bash scripts/publish.sh --dry-run
# Publish with verbose output
publish-verbose:
@bash scripts/publish.sh --verbose
# Show publish help
publish-help:
@bash scripts/publish.sh --help
# Alternative Python-based publish target
publish-py:
@python scripts/publish.py
publish-py-dry-run:
@python scripts/publish.py --dry-run
publish-py-verbose:
@python scripts/publish.py --verbose
.PHONY: help
help:
@echo "Clawhub Publishing Targets:"
@echo " make publish - Publish skill to clawhub"
@echo " make publish-bump - Auto-increment minor version and publish"
@echo " make publish-bump-build - Auto-increment patch/build number and publish"
@echo " make publish-dry-run - Test publish without making changes"
@echo " make publish-verbose - Publish with verbose output"
@echo " make publish-help - Show detailed publish help"
@echo ""
@echo "Python-based alternatives:"
@echo " make publish-py - Python version of publish"
@echo ""
@echo "For CLI usage without make:"
@echo " ./scripts/publish.sh [--dry-run] [--verbose]"
@echo " python scripts/publish.py [--dry-run] [--verbose]"
Clawhub Publish Script - Implementation Summary
Overview
A complete automation system has been created to publish the katbot-trading skill to clawhub. The system includes Bash and Python scripts with full validation, error handling, and documentation.
Files Created
1. scripts/publish.sh (Bash Script)
- Purpose: Main publishing script in Bash
- Features:
- Checks for clawhub CLI and git
- Validates skill structure and SKILL.md
- Monitors git status for uncommitted changes
- Supports dry-run mode for testing
- Verbose logging for debugging
- Color-coded output for clarity
- Usage:
./scripts/publish.sh [--dry-run] [--verbose] [--skill-dir PATH]
2. scripts/publish.py (Python Script)
- Purpose: Python alternative for cross-platform compatibility
- Features: Same as Bash script, implemented in Python
- Requirements: Python 3.7+
- Usage:
python scripts/publish.py [--dry-run] [--verbose] [--skill-dir PATH]
3. Makefile (Make Targets)
- Purpose: Convenient make targets for publishing
- Targets:
make publish- Publish skill to clawhubmake publish-dry-run- Test publish without changesmake publish-verbose- Publish with verbose outputmake publish-help- Show detailed helpmake publish-py- Python versionmake help- Show all publishing targets
4. scripts/PUBLISH.md (Documentation)
- Purpose: Comprehensive guide for publishing process
- Sections:
- Quick start instructions
- Feature overview
- Command-line options
- Workflow examples
- CI/CD integration
- Troubleshooting guide
- GitHub Actions workflow example
Quick Start
Option 1: Using Make (Recommended)
# Test publish without making changes
make publish-dry-run
# Publish for real
make publishOption 2: Using Bash Script Directly
# Test publish
./scripts/publish.sh --dry-run
# Publish
./scripts/publish.shOption 3: Using Python Script
# Test publish
python scripts/publish.py --dry-run
# Publish
python scripts/publish.pyFeatures
✅ Prerequisites Validation
- Checks if clawhub CLI is installed
- Verifies git and Python availability
- Provides helpful error messages with install links
✅ Skill Structure Validation
- Confirms skill directory structure
- Validates SKILL.md with required fields
- Checks for tools directory
- Extracts and displays skill metadata
✅ Git Integration
- Detects uncommitted changes
- Identifies untracked files
- Encourages clean commits before publishing
✅ Dry-Run Mode
- Preview publishing without making changes
- Shows exact command to be executed
- Perfect for CI/CD testing
✅ Verbose Logging
- Detailed output for all validation steps
- Helps with debugging and understanding the process
Command Options
| Option | Purpose |
|---|---|
--dry-run | Preview without making changes |
--verbose | Show detailed output |
--skill-dir PATH | Publish from custom directory |
--help | Show help message |
Example Workflow
1. Make changes to the skill
# Edit tools, SKILL.md, etc.2. Test the changes
# Verify everything works locally3. Commit to git
git add .
git commit -m "Update skill features"4. Test publish (dry-run)
make publish-dry-run5. Publish for real
make publishRequirements
- clawhub CLI: Must be installed and authenticated
- git: Optional but recommended for version tracking
- Python: (For Python script) Version 3.7 or higher
- bash: (For Bash script) Available on Linux, macOS, Windows (WSL)
CI/CD Integration
Both scripts work perfectly in CI/CD pipelines. Example for GitHub Actions:
- name: Publish skill (dry-run)
run: ./scripts/publish.sh --dry-run
- name: Publish skill
run: ./scripts/publish.sh
env:
CLAWHUB_API_KEY: ${{ secrets.CLAWHUB_API_KEY }}Troubleshooting
"clawhub CLI not found"
Install from: https://github.com/clawai/clawhub-cli
"SKILL.md not found"
Use --skill-dir option to specify the correct path
"Uncommitted changes detected"
Commit changes first: git add . && git commit -m "message"
For more detailed troubleshooting, see scripts/PUBLISH.md
Next Steps
1. ✅ Scripts are ready to use 2. ✅ Makefile targets configured 3. ✅ Documentation complete 4. ✅ Dry-run testing verified
You can now start publishing by running:
make publish-dry-run # Test first
make publish # Publish for real
make publish-bump # Auto-increment version and publish
make publish-bump-build # Bump version, build, and publishAdditional Resources
- Clawhub CLI Documentation
- OpenClaw Documentation
- SKILL.md Reference
- Detailed Publishing Guide
[project]
name = "katbotai-hyperliquid-trader"
version = "0.1.0"
description = "Add your description here"
readme = "README.md"
requires-python = ">=3.13"
dependencies = []
katbotai-hyperliquid-trader
Add live Hyperliquid trading superpowers to any OpenClaw agent using Katbot.ai.
This repository contains the OpenClaw skill for Katbot.ai trading — giving your agent the ability to:
- Monitor the BTC Momentum Index (BMI) for directional trade signals
- Automatically select the best-performing tokens from Hyperliquid
- Request AI-powered trade recommendations from the Katbot agent
- Execute and manage live trades on Hyperliquid — all from natural conversation
⚠️ Live trading involves real financial risk. Start with testnet. Never risk more than you can afford to lose.
---
Requirements
| Requirement | Notes |
|---|---|
| OpenClaw | Installed and running |
| Katbot.ai account | Whitelisted (pre-alpha) |
| MetaMask | Connected to Arbitrum network |
| Hyperliquid account | Testnet or Mainnet |
| Python 3.11+ | For running the trading scripts |
---
How It Works
You (chat) ──→ OpenClaw Agent ──→ https://api.katbot.ai ──→ HyperliquidYour OpenClaw agent uses the Katbot API to get recommendations and execute trades. The agent never holds your private keys in memory — they live in environment variables and are only used to sign trade requests.
---
Repository Structure
katbotai-hyperliquid-trader/
├── README.md ← You are here
├── scripts/
│ ├── katbot_client.py ← API client (SIWE auth, all API ops)
│ ├── katbot_workflow.py ← Full trading workflow (BMI → trade)
│ └── token_selector.py ← CoinGecko token selection by momentum
└── skills/
└── katbot-trading/
├── SKILL.md ← OpenClaw skill definition
└── tools/ ← Symlinks to scripts (used by the skill)---
Quick Start (6 Steps to Your First Trade)
1. Get OpenClaw running Install OpenClaw and have an agent running. That's your AI assistant that will do the trading.
2. Install the skill
clawhub install katbot-trading3. Install Python deps
pip install requests eth-account4. Run the onboarding wizard
python3 ~/.openclaw/workspace/skills/katbot-trading/tools/katbot_onboard.pyIt will ask for your MetaMask private key (hidden, never saved to disk). It logs you into Katbot.ai, creates your Hyperliquid portfolio, and saves your agent key locally.
5. Authorize the agent on Hyperliquid The wizard prints your agent address. Go to app.hyperliquid.xyz → Settings → API, add that address with trading permissions. One-time setup.
6. Start trading — just talk to your agent
"How's the market looking?"
"Run the trading workflow"
"How's my portfolio doing?"
"Close the position"
The agent checks the BMI, picks tokens, gets a recommendation, and asks you to confirm before executing anything. Your keys never leave your machine.
The only manual part is step 5 — the Hyperliquid agent authorization requires a MetaMask signature in the browser. Everything else is automated.
---
Installation (Detailed)
1. Install the Skill via ClawHub
clawhub install katbot-tradingOr clone manually and point your agent at skills/katbot-trading/SKILL.md.
2. Install Python Dependencies
pip install -r skills/katbot-trading/requirements.txt3. Run the Onboarding Wizard
Instead of manually configuring files and env vars, run the interactive wizard:
python3 scripts/katbot_onboard.pyThe wizard will: 1. Prompt for your MetaMask private key (hidden input — never saved to disk) 2. Authenticate with api.katbot.ai via SIWE (Sign-In with Ethereum) 3. List existing portfolios or walk you through creating a new one 4. Save the agent private key and config to ~/.openclaw/workspace/katbot-identity/ (mode 600) 5. Print the Hyperliquid agent authorization steps and env var export lines
After onboarding your identity files will be at:
~/.openclaw/workspace/katbot-identity/
├── katbot_config.json ← portfolio config (wallet address, portfolio ID, chain)
├── katbot_secrets.json ← agent private key (chmod 600, never commit)
└── katbot_token.json ← JWT token cache (chmod 600)4. Set Environment Variables (Headless / Automated Setups Only)
For normal interactive use, you don't need to set any env vars — the wizard saves everything locally and the JWT token is reused automatically.
If you're running in a headless or automated environment (e.g. a server, CI, or scheduled cron job), set these in your ~/.bashrc or ~/.zshrc:
# Wallet key — only needed for unattended token refresh (never store in files)
export WALLET_PRIVATE_KEY=0xYourMetaMaskPrivateKey
# Agent key — saved locally by the wizard, but can also be set here for portability
export KATBOT_HL_AGENT_PRIVATE_KEY=0xYourAgentPrivateKey⚠️ Never commit these values to git. Use a secrets manager or your shell profile only.
5. Authorize the Agent on Hyperliquid
The wizard prints your agent address. Then: 1. Go to app.hyperliquid.xyz → Settings → API 2. Add agent address as an API Wallet with trading permissions 3. Set expiry to 180 days and confirm the MetaMask transaction
---
Running the Trading Workflow
python3 scripts/katbot_workflow.py \
--top 5This will: 1. Check the BMI — exits cleanly if neutral (no low-conviction trades) 2. Select top/worst 5 tokens based on market direction 3. Update your portfolio token list 4. Request a recommendation from the Katbot AI agent 5. Present the recommendation with entry, TP, SL, R/R, and leverage 6. Ask you to confirm before executing
---
Agent Setup
Tell your OpenClaw agent about this skill by adding to your MEMORY.md:
## Katbot Trading Setup
- API: https://api.katbot.ai
- Identity: ~/.openclaw/workspace/katbot-identity/
- Client: katbot_client.py
- Portfolio ID: 5 (my-hl-mainnet)
- Skill: katbotai-hyperliquid-trader/skills/katbot-trading/SKILL.mdThen just talk to your agent naturally:
"How's the market looking?"
→ Agent checks BMI and reports BTC momentum + top movers
"Run the trading workflow"
→ Agent checks BMI, selects tokens, gets recommendation, asks to confirm
"How's the portfolio doing?"
→ Agent queries API and reports positions + uPnL
"Close the position"
→ Agent closes via API after your confirmation
---
BMI Signal Reference
The BTC Momentum Index (BMI) tells us whether the market is trending strongly enough to trade.
| BMI | Signal | Action |
|---|---|---|
| ≥ +15 | 🟢 BULLISH | Select top gainers → get LONG recommendation |
| ≤ -15 | 🔴 BEARISH | Select worst performers → get SHORT recommendation |
| -15 to +15 | ⚪ NEUTRAL | Stay flat. No trade. |
---
Leverage Guidelines
| Condition | Leverage |
|---|---|
| RSI < 20 or > 80 (extreme) | 1x only |
| Clear trend, BMI ±15–30 | 1–2x |
| Strong momentum, BMI ±30+ | 2–5x |
| Textbook breakout, high volume | Up to 5x |
At 5x leverage, a 5% stop loss = 25% of your margin at risk. Always honor your stops.
---
API Reference
Full Swagger docs: https://api.katbot.ai/docs
| Operation | Method | Endpoint |
|---|---|---|
| Get nonce | GET | /get-nonce/{address}?chain_id=42161 |
| Login | POST | /login |
| Verify auth | GET | /me |
| List portfolios | GET | /portfolio |
| Create portfolio | POST | /portfolio |
| Portfolio state | GET | /portfolio/{id} |
| Update tokens | PUT | /portfolio/{id} |
| Request recommendation | POST | /agent/recommendation/message |
| Poll recommendation | GET | /agent/recommendation/poll/{ticket_id} |
| Execute trade | POST | /portfolio/{id}/execute |
| Close position | POST | /portfolio/{id}/close-position |
---
Troubleshooting
401 Unauthorized — JWT expired. katbot_client.py auto-refreshes. If it fails, delete katbot_token.json and re-run.
403 / Agent key rejected — Verify KATBOT_HL_AGENT_PRIVATE_KEY matches the agent address added to Hyperliquid.
Recommendation FAILED — Check your Katbot subscription includes AI recommendations. Contact support on Discord.
Trade won't fill — On testnet, some pairs have thin orderbooks. Try mainnet or switch to BTC/ETH.
BMI always neutral — BMI is based on BTC 4h momentum. In choppy sideways markets this is expected — it's protecting you from bad trades.
---
Contributing
Found a bug? Have an improvement? PRs welcome.
This repo is the living configuration for an agent that trades real money. Every improvement helps real users make better decisions.
---
Links
- Katbot.ai
- Katbot API Docs
- OpenClaw Docs
- ClawHub — katbot-trading skill
- Hyperliquid
- Katbot Discord
- OpenClaw Discord
---
Built by Tubman Clawbot 😼 — the OpenClaw agent that trades its own portfolio.
#!/usr/bin/env bash
# Clawhub Publishing Quick Reference
# Print this file for a quick cheat sheet
cat << 'EOF'
┌────────────────────────────────────────────────────────────────┐
│ CLAWHUB PUBLISH - QUICK REFERENCE │
└────────────────────────────────────────────────────────────────┘
📋 BASIC USAGE
──────────────────────────────────────────────────────────────────
Test Before Publishing (DRY-RUN):
$ make publish-dry-run
OR: ./scripts/publish.sh --dry-run
Publish for Real:
$ make publish
OR: ./scripts/publish.sh
Show Help:
$ make publish-help
OR: ./scripts/publish.sh --help
🔧 MAKE TARGETS
──────────────────────────────────────────────────────────────────
make publish Publish skill to clawhub
make publish-dry-run Test publish without changes
make publish-verbose Publish with detailed output
make publish-help Show detailed help
make publish-py Use Python version
make help Show all make targets
💻 DIRECT SCRIPT USAGE
──────────────────────────────────────────────────────────────────
Bash Script:
./scripts/publish.sh [OPTIONS]
./scripts/publish.sh --dry-run
./scripts/publish.sh --verbose
./scripts/publish.sh --skill-dir ./my-skill
Python Script:
python scripts/publish.py [OPTIONS]
python scripts/publish.py --dry-run
python scripts/publish.py --verbose
⚙️ WORKFLOW
──────────────────────────────────────────────────────────────────
1. Make changes to the skill:
$ # Edit files in skills/katbot-trading/
2. Commit to git:
$ git add .
$ git commit -m "Update skill features"
3. Test publish:
$ make publish-dry-run
4. Publish:
$ make publish
✅ VALIDATION CHECKS
──────────────────────────────────────────────────────────────────
Scripts automatically check:
✓ clawhub CLI is installed
✓ Python/bash availability
✓ Skill directory exists
✓ SKILL.md has required metadata
✓ Git status (uncommitted changes)
✓ Untracked files in skill directory
⚠️ TROUBLESHOOTING
──────────────────────────────────────────────────────────────────
"clawhub CLI not found"
→ Install from: https://github.com/clawai/clawhub-cli
"SKILL.md not found"
→ Run from project root or use: --skill-dir ./path
"Uncommitted changes detected"
→ Run: git add . && git commit -m "message"
📚 DOCUMENTATION
──────────────────────────────────────────────────────────────────
Quick Start: PUBLISH_SETUP.md
Detailed Guide: scripts/PUBLISH.md
Skill Config: skills/katbot-trading/SKILL.md
Main README: README.md
🚀 ONE-LINERS
──────────────────────────────────────────────────────────────────
Test & Publish:
$ make publish-dry-run && make publish
Commit & Publish:
$ git add . && git commit -m "Ready to publish" && make publish
Verbose Publishing:
$ make publish-verbose
With Custom Directory:
$ ./scripts/publish.sh --skill-dir ./skills/my-skill
📖 MORE HELP
──────────────────────────────────────────────────────────────────
./scripts/publish.sh --help
python scripts/publish.py --help
cat scripts/PUBLISH.md
EOF
Publishing the Katbot-Trading Skill to Clawhub
This directory contains publish tooling only (publish.sh, publish.py). The actual trading tool scripts (katbot_client.py, katbot_onboard.py, katbot_workflow.py, token_selector.py) live exclusively in skills/katbot-trading/tools/ — do not add copies here.
Two publish script versions are provided: a Bash script and a Python script, providing flexibility based on your environment.
Quick Start
Using Make (Recommended)
# Publish skill
make publish
# Test publish without making changes
make publish-dry-run
# Publish with verbose output
make publish-verboseUsing Bash Script Directly
./scripts/publish.sh
# Or with options
./scripts/publish.sh --dry-run
./scripts/publish.sh --verboseUsing Python Script
python scripts/publish.py
# Or with options
python scripts/publish.py --dry-run
python scripts/publish.py --verboseFeatures
Both scripts provide the following functionality:
✅ Prerequisites Check
- Verifies clawhub CLI is installed
- Confirms git and Python availability
- Provides helpful install instructions if tools are missing
✅ Skill Structure Validation
- Confirms skill directory exists and has proper structure
- Validates SKILL.md with required metadata
- Checks for tools directory and counts Python files
- Extracts and displays skill name
✅ Git Status Monitoring
- Detects uncommitted changes
- Identifies untracked files in skill directory
- Encourages clean commits before publishing
✅ Dry-Run Mode
- Preview what would be published without making changes
- Shows the exact command that would be executed
- Perfect for CI/CD pipelines and testing
✅ Verbose Logging
- Detailed output showing all validation steps
- Useful for debugging and understanding the process
Command-Line Options
Common Options
| Option | Usage | Purpose |
|---|---|---|
--dry-run | ./scripts/publish.sh --dry-run | Preview publish without making changes |
--verbose | ./scripts/publish.sh --verbose | Show detailed output for all steps |
--skill-dir PATH | ./scripts/publish.sh --skill-dir ./my-skill | Publish from custom skill directory |
--help | ./scripts/publish.sh --help | Show help message (bash only) |
Requirements
Before using these scripts, ensure you have:
1. Clawhub CLI installed:
# Install from https://github.com/clawai/clawhub-cli2. Git installed (optional but recommended):
git --version3. Python 3.7+ (for Python script):
python --versionWorkflow Examples
1. Test Before Publishing
# Always do a dry-run first to see what would happen
./scripts/publish.sh --dry-run
# Review the output, then publish for real
./scripts/publish.sh2. Debug Publishing Issues
# Use verbose mode to see detailed output
./scripts/publish.sh --verbose
# Or with the Python script
python scripts/publish.py --verbose3. CI/CD Integration
# In your CI/CD pipeline, use dry-run to test
./scripts/publish.sh --dry-run || exit 1
# Then publish
./scripts/publish.sh || exit 14. Publish from Different Directory
# Publish from a specific skill directory
./scripts/publish.sh --skill-dir ./custom-skill-dirEnvironment Setup
For Clawhub CLI
Ensure you're authenticated with clawhub:
# Check if clawhub is configured
clawhub auth status
# Login if needed
clawhub loginFor Git Integration
The scripts check your git status. For best results:
# Commit your changes before publishing
git add .
git commit -m "Prepare skill for publishing"
# Then publish
./scripts/publish.shOutput Examples
Successful Publish
===================================================
Clawhub Publish Script
===================================================
ℹ Checking prerequisites...
✓ clawhub CLI is installed
✓ Python 3.11 is installed
✓ git is installed
ℹ Validating skill structure...
✓ Skill directory exists
✓ SKILL.md found
✓ tools directory exists
✓ SKILL.md has required metadata
ℹ Checking git status...
✓ Working directory is clean
===================================================
Publishing Skill to Clawhub
===================================================
ℹ Publishing skill...
✓ Skill published successfully!
✓ Complete!Dry-Run Mode
===================================================
Clawhub Publish Script
===================================================
...
===================================================
Publishing Skill to Clawhub
===================================================
ℹ Running in DRY-RUN mode (no changes will be made)
Command that would be executed:
clawhub publish /home/user/project/skills/katbot-trading
ℹ Skill name: katbot-trading
ℹ Skill directory: /home/user/project/skills/katbot-trading
ℹ Run without --dry-run to publish
✓ Complete!Troubleshooting
"clawhub CLI not found"
Solution: Install clawhub CLI from https://github.com/clawai/clawhub-cli
# Example installation (check repo for latest instructions)
npm install -g @clawhub/cli"SKILL.md not found"
Solution: Ensure you're in the correct directory or use --skill-dir option:
./scripts/publish.sh --skill-dir ./skills/katbot-trading"Uncommitted changes detected"
Solution: Commit your changes before publishing:
git add .
git commit -m "Update skill files"
./scripts/publish.shModule Not Found Error (Python publish script)
Solution: Ensure Python is correctly installed:
python --version # Should be 3.7 or higherIf you're trying to run a trading tool (e.g.katbot_workflow.py) and get aModuleNotFoundError, you need to setPYTHONPATHto the tools directory:
```bash
PYTHONPATH=skills/katbot-trading/tools python3 skills/katbot-trading/tools/katbot_workflow.py
```
Script Comparison
| Feature | Bash Script | Python Script |
|---|---|---|
| Dependencies | bash, standard utils | python 3.7+ |
| Portability | Linux/macOS/Windows (WSL) | Linux/macOS/Windows |
| Performance | Fast | Slightly slower |
| Readability | Shell syntax | Python syntax |
| Extensibility | Required shell knowledge | Python familiarity |
| Debugging | Standard bash tools | Python debugger |
Integration with GitHub Actions
Add to your .github/workflows/publish.yml:
name: Publish to Clawhub
on:
push:
branches: [ main ]
paths:
- 'skills/katbot-trading/**'
jobs:
publish:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Install clawhub CLI
run: npm install -g @clawhub/cli
- name: Publish skill (dry-run)
run: ./scripts/publish.sh --dry-run
- name: Publish skill
run: ./scripts/publish.sh
env:
CLAWHUB_API_KEY: ${{ secrets.CLAWHUB_API_KEY }}Maintenance
To keep these scripts updated:
1. Check clawhub CLI documentation for API changes 2. Update SKILL.md when modifying skill metadata 3. Test with --dry-run before publishing 4. Review git status before publishing
Support
For issues with:
- Clawhub CLI: Visit https://github.com/clawai/clawhub-cli
- This skill: Check the main project README
- Publishing scripts: Review the script source code (well-documented)
License
These publishing scripts are part of the katbotai-hyperliquid-trader project.
#!/usr/bin/env python3
"""
Clawhub Publish Script
This script automates publishing the katbot-trading skill to clawhub.
It validates prerequisites, checks for required files, and runs the publish
command with appropriate error handling.
Usage:
python scripts/publish.py [--dry-run] [--verbose] [--skill-dir PATH]
"""
import argparse
import sys
import subprocess
import os
import json
from pathlib import Path
from typing import Optional
import re
class Colors:
"""ANSI color codes for terminal output"""
RED = '\033[0;31m'
GREEN = '\033[0;32m'
YELLOW = '\033[1;33m'
BLUE = '\033[0;34m'
RESET = '\033[0m'
def print_header(text: str) -> None:
"""Print a formatted header"""
print(f"\n{Colors.BLUE}{'='*51}{Colors.RESET}")
print(f"{Colors.BLUE}{text}{Colors.RESET}")
print(f"{Colors.BLUE}{'='*51}{Colors.RESET}\n")
def print_success(text: str) -> None:
"""Print a success message"""
print(f"{Colors.GREEN}✓ {text}{Colors.RESET}")
def print_error(text: str) -> None:
"""Print an error message"""
print(f"{Colors.RED}✗ {text}{Colors.RESET}")
def print_warning(text: str) -> None:
"""Print a warning message"""
print(f"{Colors.YELLOW}⚠ {text}{Colors.RESET}")
def print_info(text: str) -> None:
"""Print an info message"""
print(f"{Colors.BLUE}ℹ {text}{Colors.RESET}")
def log_verbose(text: str, verbose: bool) -> None:
"""Print verbose output if enabled"""
if verbose:
print(f"{Colors.BLUE} → {text}{Colors.RESET}")
def check_command_exists(cmd: str) -> bool:
"""Check if a command exists in PATH"""
return subprocess.run(['which', cmd], capture_output=True).returncode == 0
def check_prerequisites(verbose: bool) -> bool:
"""Check if all required tools are installed"""
print_info("Checking prerequisites...")
# Check clawhub CLI
if not check_command_exists('clawhub'):
print_error("clawhub CLI not found. Please install clawhub first.")
print(" Visit: https://github.com/clawai/clawhub-cli")
return False
print_success("clawhub CLI is installed")
# Check Python version
py_version = (
f"{sys.version_info.major}.{sys.version_info.minor}"
f".{sys.version_info.micro}"
)
print_success(f"Python {py_version} is installed")
log_verbose(f"Python executable: {sys.executable}", verbose)
# Check git
if not check_command_exists('git'):
print_warning("git not found. Some features may not work properly.")
else:
print_success("git is installed")
return True
def extract_skill_name(skill_md_path: Path) -> Optional[str]:
"""Extract skill name from SKILL.md"""
try:
with open(skill_md_path, 'r') as f:
content = f.read()
match = re.search(r'^name:\s*(.+?)$', content, re.MULTILINE)
if match:
return match.group(1).strip()
except Exception:
pass
return None
def extract_skill_version(skill_md_path: Path) -> Optional[str]:
"""Extract skill version from SKILL.md"""
try:
with open(skill_md_path, 'r') as f:
content = f.read()
match = re.search(r'^version:\s*(.+?)$', content, re.MULTILINE)
if match:
return match.group(1).strip()
except Exception:
pass
return None
def validate_skill_structure(skill_dir: Path, verbose: bool) -> bool:
"""Validate the skill directory structure"""
print_info("Validating skill structure...")
# Check skill directory exists
if not skill_dir.exists():
print_error(f"Skill directory not found: {skill_dir}")
return False
print_success("Skill directory exists")
log_verbose(str(skill_dir), verbose)
# Check SKILL.md exists
skill_md = skill_dir / "SKILL.md"
if not skill_md.exists():
print_error(f"SKILL.md not found in {skill_dir}")
return False
print_success("SKILL.md found")
# Check tools directory
tools_dir = skill_dir / "tools"
if not tools_dir.exists():
print_warning("tools directory not found in skill directory")
else:
print_success("tools directory exists")
py_files = list(tools_dir.glob("*.py"))
log_verbose(f"Found {len(py_files)} Python tool files", verbose)
# Validate SKILL.md has required metadata
with open(skill_md) as f:
content = f.read()
if 'name:' not in content:
print_error("SKILL.md missing required 'name' field")
return False
if 'version:' not in content:
print_error("SKILL.md missing required 'version' field")
return False
print_success("SKILL.md has required metadata")
skill_name = extract_skill_name(skill_md)
if skill_name:
log_verbose(f"Skill name: {skill_name}", verbose)
skill_version = extract_skill_version(skill_md)
if skill_version:
log_verbose(f"Skill version: {skill_version}", verbose)
return True
def check_git_status(project_root: Path, skill_dir: Path, verbose: bool) -> None:
"""Check git status of the project"""
print_info("Checking git status...")
# Check if in git repo
result = subprocess.run(
['git', '-C', str(project_root), 'rev-parse', '--git-dir'],
capture_output=True
)
if result.returncode != 0:
print_warning("Not in a git repository")
return
# Check for uncommitted changes
result = subprocess.run(
['git', '-C', str(project_root), 'diff-index', '--quiet', 'HEAD', '--'],
capture_output=True
)
if result.returncode != 0:
print_warning("Uncommitted changes detected")
log_verbose("You may want to commit your changes before publishing", verbose)
else:
print_success("Working directory is clean")
# Check for untracked files in skill directory
result = subprocess.run(
['git', '-C', str(project_root), 'ls-files', '--others',
'--exclude-standard', str(skill_dir)],
capture_output=True,
text=True
)
untracked_count = len([l for l in result.stdout.split('\n') if l.strip()])
if untracked_count > 0:
print_warning(f"{untracked_count} untracked files in skill directory")
def publish_skill(
skill_dir: Path,
dry_run: bool,
verbose: bool
) -> bool:
"""Publish the skill to clawhub"""
print_header("Publishing Skill to Clawhub")
skill_version = extract_skill_version(skill_dir / "SKILL.md")
if not skill_version:
print_error("Version not found in SKILL.md. Please add 'version: X.Y.Z'")
return False
cmd = ['clawhub', 'publish', '--version', skill_version]
if dry_run:
print_info("Running in DRY-RUN mode (no changes will be made)")
print()
print("Command that would be executed:")
print(f" {' '.join(cmd)} {skill_dir}")
print()
skill_name = extract_skill_name(skill_dir / "SKILL.md")
if skill_name:
print_info(f"Skill name: {skill_name}")
print_info(f"Skill version: {skill_version}")
print_info(f"Skill directory: {skill_dir}")
print()
print_info("Run without --dry-run to publish")
return True
print_info(f"Publishing skill (version: {skill_version})...")
cmd.append(str(skill_dir))
try:
result = subprocess.run(cmd, check=True)
if result.returncode == 0:
print_success("Skill published successfully!")
return True
except subprocess.CalledProcessError as e:
print_error(f"Failed to publish skill (exit code: {e.returncode})")
return False
except FileNotFoundError:
print_error("clawhub command not found")
return False
return False
def main():
"""Main entry point"""
parser = argparse.ArgumentParser(
description='Publish the katbot-trading skill to clawhub'
)
parser.add_argument(
'--dry-run',
action='store_true',
help='Show what would be published without actually publishing'
)
parser.add_argument(
'--verbose',
action='store_true',
help='Enable verbose output'
)
parser.add_argument(
'--skill-dir',
type=Path,
help='Custom skill directory path (default: skills/katbot-trading)'
)
args = parser.parse_args()
# Determine paths
script_dir = Path(__file__).parent
project_root = script_dir.parent
skill_dir = args.skill_dir or (project_root / 'skills' / 'katbot-trading')
# Run checks
print_header("Clawhub Publish Script")
if not check_prerequisites(args.verbose):
sys.exit(1)
print()
if not validate_skill_structure(skill_dir, args.verbose):
sys.exit(1)
print()
check_git_status(project_root, skill_dir, args.verbose)
print()
if not publish_skill(skill_dir, args.dry_run, args.verbose):
print()
print_error("Publication failed")
sys.exit(1)
print()
print_success("Complete!")
print()
if __name__ == '__main__':
main()
#!/bin/bash
###############################################################################
# Clawhub Publish Script
#
# This script automates publishing the katbot-trading skill to clawhub.
# It validates prerequisites, checks for required files, and runs the publish
# command with appropriate error handling.
#
# Usage:
# ./scripts/publish.sh [--dry-run] [--verbose] [--skill-dir PATH]
#
###############################################################################
set -e
# Configuration
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_ROOT="$(dirname "$SCRIPT_DIR")"
SKILL_DIR="${PROJECT_ROOT}/skills/katbot-trading"
DRY_RUN=false
VERBOSE=false
BUMP=false
BUMP_PART="minor"
# Colors for output
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m' # No Color
###############################################################################
# Functions
###############################################################################
print_header() {
echo -e "${BLUE}===================================================${NC}"
echo -e "${BLUE}$1${NC}"
echo -e "${BLUE}===================================================${NC}"
}
print_success() {
echo -e "${GREEN}✓ $1${NC}"
}
print_error() {
echo -e "${RED}✗ $1${NC}"
}
print_warning() {
echo -e "${YELLOW}⚠ $1${NC}"
}
print_info() {
echo -e "${BLUE}ℹ $1${NC}"
}
log_verbose() {
if [ "$VERBOSE" = true ]; then
echo -e "${BLUE} → $1${NC}"
fi
}
usage() {
cat << EOF
Usage: $0 [OPTIONS]
Options:
--dry-run Show what would be published without actually publishing
--verbose Enable verbose output
--skill-dir Custom skill directory path (default: skills/katbot-trading)
--bump Auto-increment the minor version in SKILL.md before publishing
--bump-patch Auto-increment the patch/build version (X.Y.Z+1) before publishing
--help Show this help message
Examples:
# Publish normally
./scripts/publish.sh
# Auto-increment minor version and publish
./scripts/publish.sh --bump
# Test publish without making changes
./scripts/publish.sh --dry-run
# Verbose output
./scripts/publish.sh --verbose
EOF
}
check_prerequisites() {
print_info "Checking prerequisites..."
# Check if clawhub CLI is installed
if ! command -v clawhub &> /dev/null; then
print_error "clawhub CLI not found. Please install clawhub first."
echo " Visit: https://github.com/clawai/clawhub-cli"
exit 1
fi
print_success "clawhub CLI is installed"
log_verbose "$(clawhub --version 2>/dev/null || echo 'version check skipped')"
# Check if git is available
if ! command -v git &> /dev/null; then
print_warning "git not found. Some features may not work properly."
else
print_success "git is installed"
fi
}
validate_skill_structure() {
print_info "Validating skill structure..."
# Check if skill directory exists
if [ ! -d "$SKILL_DIR" ]; then
print_error "Skill directory not found: $SKILL_DIR"
exit 1
fi
print_success "Skill directory exists"
log_verbose "$SKILL_DIR"
# Check if SKILL.md exists
if [ ! -f "$SKILL_DIR/SKILL.md" ]; then
print_error "SKILL.md not found in $SKILL_DIR"
exit 1
fi
print_success "SKILL.md found"
# Check if tools directory exists
if [ ! -d "$SKILL_DIR/tools" ]; then
print_warning "tools directory not found in skill directory"
else
print_success "tools directory exists"
local tool_count=$(find "$SKILL_DIR/tools" -type f -name "*.py" | wc -l)
log_verbose "Found $tool_count Python tool files"
fi
# Validate SKILL.md has required metadata
if ! grep -q "^name:" "$SKILL_DIR/SKILL.md"; then
print_error "SKILL.md missing required 'name' field"
exit 1
fi
print_success "SKILL.md has required metadata"
local skill_name=$(grep "^name:" "$SKILL_DIR/SKILL.md" | head -1 | sed 's/.*: //' | xargs)
log_verbose "Skill name: $skill_name"
# Check for version
if ! grep -q "^version:" "$SKILL_DIR/SKILL.md"; then
print_error "SKILL.md missing required 'version' field"
exit 1
fi
local skill_version=$(grep "^version:" "$SKILL_DIR/SKILL.md" | head -1 | sed 's/.*: //' | xargs)
log_verbose "Skill version: $skill_version"
}
check_git_status() {
print_info "Checking git status..."
# Check if we're in a git repository
if ! git -C "$PROJECT_ROOT" rev-parse --git-dir > /dev/null 2>&1; then
print_warning "Not in a git repository"
return
fi
# Check for uncommitted changes
if ! git -C "$PROJECT_ROOT" diff-index --quiet HEAD --; then
print_warning "Uncommitted changes detected"
log_verbose "You may want to commit your changes before publishing"
else
print_success "Working directory is clean"
fi
# Check for untracked files in skill directory
local untracked=$(git -C "$PROJECT_ROOT" ls-files --others --exclude-standard "$SKILL_DIR" | wc -l)
if [ "$untracked" -gt 0 ]; then
print_warning "$untracked untracked files in skill directory"
fi
}
bump_version() {
local part="${1:-minor}" # major, minor, or patch
local skill_md="$SKILL_DIR/SKILL.md"
local current=$(grep "^version:" "$skill_md" | head -1 | sed 's/.*: //' | xargs)
if [ -z "$current" ]; then
print_error "Cannot bump: version not found in SKILL.md"
exit 1
fi
local major minor patch
IFS='.' read -r major minor patch <<< "$current"
local new_version
case "$part" in
major) new_version="$((major + 1)).0.0" ;;
minor) new_version="${major}.$((minor + 1)).0" ;;
patch) new_version="${major}.${minor}.$((patch + 1))" ;;
*)
print_error "Unknown bump type: $part (use major, minor, or patch)"
exit 1
;;
esac
sed -i "s/^version: .*/version: ${new_version}/" "$skill_md"
print_success "Version bumped: ${current} → ${new_version}"
}
publish_skill() {
print_header "Publishing Skill to Clawhub"
local skill_version=$(grep "^version:" "$SKILL_DIR/SKILL.md" | head -1 | sed 's/.*: //' | xargs)
if [ -z "$skill_version" ]; then
print_error "Version not found in SKILL.md. Please add 'version: X.Y.Z'"
return 1
fi
local publish_cmd="clawhub publish --version $skill_version"
if [ "$DRY_RUN" = true ]; then
print_info "Running in DRY-RUN mode (no changes will be made)"
echo ""
echo "Command that would be executed:"
echo " $publish_cmd \"$SKILL_DIR\""
echo ""
print_info "Skill directory: $SKILL_DIR"
print_info "Skill name: $(grep "^name:" "$SKILL_DIR/SKILL.md" | head -1 | sed 's/.*: //' | xargs)"
print_info "Skill version: $skill_version"
echo ""
print_info "Run without --dry-run to publish"
return 0
fi
print_info "Publishing skill (version: $skill_version)..."
if $publish_cmd "$SKILL_DIR"; then
print_success "Skill published successfully!"
return 0
else
print_error "Failed to publish skill"
return 1
fi
}
###############################################################################
# Main
###############################################################################
main() {
# Parse arguments
while [[ $# -gt 0 ]]; do
case $1 in
--dry-run)
DRY_RUN=true
shift
;;
--verbose)
VERBOSE=true
shift
;;
--skill-dir)
SKILL_DIR="$2"
shift 2
;;
--bump)
BUMP=true
BUMP_PART="minor"
shift
;;
--bump-patch)
BUMP=true
BUMP_PART="patch"
shift
;;
--help)
usage
exit 0
;;
*)
print_error "Unknown option: $1"
usage
exit 1
;;
esac
done
print_header "Clawhub Publish Script"
echo ""
# Run checks and publish
check_prerequisites
echo ""
validate_skill_structure
echo ""
check_git_status
echo ""
if [ "$BUMP" = true ]; then
bump_version "$BUMP_PART"
echo ""
fi
if ! publish_skill; then
echo ""
print_error "Publication failed"
exit 1
fi
echo ""
print_success "Complete!"
echo ""
}
# Run main function
main "$@"
Security Review Response: katbot-trading Skill
This document is a technical response to the security flag raised against the katbot-trading skill on the OpenClaw/OpenHub registry. It explains what the skill does, why certain credential-handling patterns are architecturally necessary, and what specific steps have been taken to protect users.
---
What the Skill Does
katbot-trading is an OpenClaw skill that connects an AI agent to Katbot.ai, a portfolio management service for trading perpetual futures on Hyperliquid. The skill allows an agent to:
1. Authenticate with the Katbot API using Sign-In with Ethereum (SIWE) 2. Retrieve AI-generated trade recommendations (entry, take-profit, stop-loss, leverage) 3. Execute those trades on Hyperliquid, with mandatory explicit user confirmation 4. Monitor open positions and portfolio performance
The skill does not interact with Hyperliquid directly. All on-chain interactions are delegated to the Katbot API, which uses a scoped agent wallet — not the user's primary MetaMask wallet — to sign and submit transactions.
---
Why Credential Handling Is Required
The Hyperliquid Agent Wallet Model
Hyperliquid's security architecture supports a concept called an API wallet (agent wallet): a separate, limited-scope keypair that a user explicitly authorizes to trade on behalf of their main wallet. This is a first-party Hyperliquid feature documented at app.hyperliquid.xyz → Settings → API.
The agent wallet:
- Has trading permissions only — it cannot withdraw funds to external addresses
- Has a configurable expiry (typically 180 days)
- Can be revoked instantly by the user at any time from the Hyperliquid UI
- Is a separate keypair from the user's MetaMask wallet — the main wallet's funds are not directly accessible to it
This design means the blast radius of a compromised agent key is bounded: an attacker could place or close trades, but cannot drain the underlying wallet to an arbitrary address.
Why the Agent Key Must Be Transmitted to the API
Hyperliquid's on-chain transaction submission requires the agent wallet to sign each order. The Katbot API operates as a server-side execution engine: it constructs the transaction, signs it using the agent key, and submits it to Hyperliquid.
This means the agent key must be available to the Katbot API at execution time. There is no local-signing alternative in this architecture — Hyperliquid's on-chain order format requires the signing to happen at the point of submission, and the submission happens server-side.
This is the same model used by all server-side trading bots and API-connected trading platforms (e.g., 3Commas, Pionex, Bitsgap) — the trading key is shared with the platform's server in exchange for automated execution. The scoped agent wallet model limits what that key can do.
The key is transmitted only on two API calls:
POST /agent/recommendation/message— to request a trade recommendationPOST /portfolio/{id}/execute— to execute a confirmed trade
It is sent as both an HTTP header (X-Agent-Private-Key) and in the JSON body. It is never logged, stored in browser state, or included in read-only calls (portfolio state, chat, polling).
Why the MetaMask Wallet Key Is Handled Differently
The MetaMask wallet key (WALLET_PRIVATE_KEY) is used exclusively for SIWE (Sign-In with Ethereum) authentication. SIWE is an industry-standard login protocol (EIP-4361) used across the Web3 ecosystem.
The key signing happens entirely locally using the eth_account library. Only the resulting signature is sent to the API — the private key itself is never transmitted over the network. The skill enforces this with hard rules: the wallet key must not be persisted to disk, must not be set in environment profiles, and is only accepted via interactive hidden input during onboarding.
---
What Has Been Done to Protect Users
The following measures have been implemented specifically in response to security review feedback:
1. Removed Silent Private Key Injection from .env Loader
An earlier version of katbot_client.py would load WALLET_PRIVATE_KEY and KATBOT_HL_AGENT_PRIVATE_KEY from a .env file into os.environ at import time. This was removed. The .env loader now reads only non-secret config (KATBOT_BASE_URL, KATBOT_IDENTITY_DIR, CHAIN_ID). Private keys cannot be loaded from any file path at import time.
2. Narrowed .env Search Paths
The original .env loader searched three project-relative paths, any of which could be silently populated by placing a file in the repository tree. The search paths have been narrowed to:
~/katbot_client.env(user home directory)$OPENCLAW_HOME/katbot_identity/katbot_client.env(only ifOPENCLAW_HOMEis explicitly set)
This eliminates the risk of a project-committed file silently loading secrets.
3. Removed WALLET_PRIVATE_KEY from Registry Required Env Vars
The OpenClaw metadata previously declared WALLET_PRIVATE_KEY as a required environment variable, implying it should be set before skill installation. This was incorrect and has been removed. The registry now declares only KATBOT_HL_AGENT_PRIVATE_KEY as required, accurately reflecting that the wallet key is an emergency fallback used only during re-authentication.
4. Identity Files Written with Mode 600
All files containing secrets (katbot_token.json, katbot_secrets.json) are written with Unix file mode 0o600 (owner read/write only). The WALLET_PRIVATE_KEY is explicitly never written to any file on disk.
5. Explicit Credential Transmission Notice in SKILL.md
SKILL.md contains a dedicated Credential Transmission Notice section that the agent is instructed to present to the user before the first onboarding or trading operation. The notice includes a complete table of what credentials leave the machine, on which calls, and why. The agent is instructed not to proceed without affirmative user confirmation.
6. Agent Rules in SKILL.md Enforce Conservative Key Handling
SKILL.md contains explicit, enumerated rules for the AI agent:
- Never pre-set
WALLET_PRIVATE_KEYin the environment - Never create a
.envfile containing private keys - Never log, print, or reveal any key or token in chat
- Never read or summarize identity directory files
- Warn the user if
WALLET_PRIVATE_KEYis found already set in the environment outside of an active re-auth session
---
Summary of Credential Behavior
| Credential | Stored where | Transmitted to | When |
|---|---|---|---|
WALLET_PRIVATE_KEY | Memory only (never to disk) | Never (signature only is sent) | Onboarding / re-auth only |
KATBOT_HL_AGENT_PRIVATE_KEY | ~/.openclaw/workspace/katbot-identity/katbot_secrets.json (mode 600) | api.katbot.ai | Recommendation requests and trade execution |
access_token / refresh_token | ~/.openclaw/workspace/katbot-identity/katbot_token.json (mode 600) | api.katbot.ai | All authenticated API calls (Bearer header) |
---
Residual Trust Requirement
This skill requires the user to trust api.katbot.ai with their Hyperliquid agent trading key. This is an explicit, documented, user-consented trust grant — not a hidden behavior. The skill makes this trust requirement clear before any credential is used. Users who do not wish to extend this trust to the Katbot API should not install this skill.
The Katbot API is the intended recipient of the agent key. This is the designed purpose of the Hyperliquid agent wallet model, and is consistent with how all API-connected trading automation works.
eth-account>=0.8.0
requests>=2.31.0#!/usr/bin/env python3
"""
BMI Alert Workflow — Fetches BMI data and sends an alert via openclaw.
Set OPENCLAW_NOTIFY_CHANNEL (e.g. "telegram", "slack") and OPENCLAW_NOTIFY_TARGET
to enable message delivery. Without these, the alert is printed to stdout only.
"""
import os
import sys
import json
import subprocess
# Local tool imports — require PYTHONPATH={baseDir}/tools
try:
import btc_momentum
except ImportError:
# If not in PYTHONPATH yet, try to find it relative to this script
sys.path.append(os.path.dirname(os.path.abspath(__file__)))
import btc_momentum
# Portfolio tokens configuration — default path in workspace
PORTFOLIO_FILE = os.path.expanduser("~/.openclaw/workspace/portfolio_tokens.json")
STATE_FILE = os.path.expanduser("~/.openclaw/workspace/memory/bmi_alert_state.json")
def get_bmi():
"""Fetch BMI signal by calling btc_momentum logic directly."""
# Use the function from btc_momentum if it provides one,
# or run it as a subprocess if that's more reliable.
# Here we run as subprocess to maintain existing behavior but use the local path.
script_path = os.path.join(os.path.dirname(__file__), "btc_momentum.py")
result = subprocess.run(
[sys.executable, script_path, "--json"],
capture_output=True,
text=True
)
if result.returncode != 0:
raise RuntimeError(f"BMI script failed: {result.stderr}")
data = json.loads(result.stdout)
return {
"bmi": data["bmi"],
"signal": data["signal"],
"btc_24h_pct": data["btc_24h_pct"]
}
def get_portfolio_tokens():
"""Load portfolio tokens from configuration file."""
try:
with open(PORTFOLIO_FILE) as f:
data = json.load(f)
return data.get("portfolio_tokens", [])
except Exception as e:
print(f"Warning: Could not load portfolio tokens: {e}")
return []
def get_top_tokens(top=5, bearish=False):
"""Fetch top gainers/losers from CoinGecko."""
try:
import requests
resp = requests.get(
"https://api.coingecko.com/api/v3/coins/markets",
params={"vs_currency": "usd", "order": "market_cap_desc", "per_page": 100, "page": 1},
timeout=10
)
resp.raise_for_status()
data = resp.json()
coins = [c for c in data if c.get("price_change_percentage_24h")]
if bearish:
sorted_coins = sorted(coins, key=lambda x: x["price_change_percentage_24h"])[:top]
else:
sorted_coins = sorted(coins, key=lambda x: x["price_change_percentage_24h"], reverse=True)[:top]
return [{"symbol": c["symbol"].upper(), "pct_24h": c["price_change_percentage_24h"]} for c in sorted_coins]
except Exception as e:
print(f"Warning: Could not fetch tokens: {e}")
return []
def send_alert(message):
"""Send alert via openclaw using the configured channel and target.
Reads OPENCLAW_NOTIFY_CHANNEL and OPENCLAW_NOTIFY_TARGET from the environment.
If either is unset, prints the message to stdout and skips the send.
"""
channel = os.environ.get("OPENCLAW_NOTIFY_CHANNEL", "")
target = os.environ.get("OPENCLAW_NOTIFY_TARGET", "")
if not channel or not target:
print(
"Warning: OPENCLAW_NOTIFY_CHANNEL and OPENCLAW_NOTIFY_TARGET must both be set to send alerts. Printing to stdout.",
file=sys.stderr,
)
print(message)
return
subprocess.run(
["openclaw", "message", "send", "--channel", channel, "--target", target, "--message", message],
capture_output=True,
text=True
)
print(message)
def load_state():
try:
with open(STATE_FILE) as f:
return json.load(f)
except Exception:
return {"last_direction": None}
def save_state(state):
os.makedirs(os.path.dirname(STATE_FILE), exist_ok=True)
with open(STATE_FILE, "w") as f:
json.dump(state, f)
def main():
bmi_data = get_bmi()
bmi_value = bmi_data['bmi']
signal = bmi_data['signal']
btc_24h = bmi_data['btc_24h_pct']
# Determine direction
if bmi_value >= 20:
direction = "BULLISH"
header = "🚀 BMI Alert"
tokens = get_top_tokens(5, bearish=False)
footer = "Consider running the LONG workflow."
elif bmi_value <= -20:
direction = "BEARISH"
header = "📉 BMI Alert"
tokens = get_top_tokens(5, bearish=True)
footer = "Consider running the SHORT workflow."
else:
direction = "NEUTRAL"
header = "⏸ BMI Alert"
tokens = []
footer = "Market is neutral. No action needed."
# Only alert if the direction has changed
state = load_state()
if state.get("last_direction") == direction:
print(f"BMI {bmi_value} ({direction}) — no change, skipping alert.")
return
# Direction changed — send alert and save new state
save_state({"last_direction": direction})
message = f"{header}: {bmi_value} ({direction}) | BTC 24h: {btc_24h:+.2f}%\n\n"
# Use portfolio tokens instead of CoinGecko top tokens
portfolio_tokens = get_portfolio_tokens()
if portfolio_tokens:
message += "Portfolio Tokens:\n"
for t in portfolio_tokens:
message += f"• {t['symbol']}: {t['pct_24h']:+.2f}%\n"
elif tokens:
message += "Top 5 Bullish Tokens:\n" if direction == "BULLISH" else "Top 5 Bearish Tokens:\n"
for t in tokens:
message += f"• {t['symbol']}: {t['pct_24h']:+.2f}%\n"
message += f"\n{footer}"
send_alert(message)
if __name__ == "__main__":
main()
#!/usr/bin/env python3
"""
btc_momentum.py — BTC Momentum Index (BMI)
Fetches 24 hourly BTC/USD candles from Kraken, computes a momentum score
from -100 (max bearish) to +100 (max bullish), and evaluates open positions
against that momentum to support trade decisions.
Usage:
python btc_momentum.py # print full report
python btc_momentum.py --json # machine-readable output
python btc_momentum.py --send # send via openclaw (requires OPENCLAW_NOTIFY_CHANNEL + OPENCLAW_NOTIFY_TARGET)
"""
import argparse
import json
import math
import os
import pathlib
import subprocess
import sys
import time
from typing import Optional
import requests
KRAKEN_OHLC_URL = "https://api.kraken.com/0/public/OHLC"
BTC_PAIR = "XBTUSD"
INTERVAL = 60 # 1-hour candles
LOOKBACK = 24 # candles
# Messaging — read channel and target from env vars so alerts work with any openclaw channel.
# Set OPENCLAW_NOTIFY_CHANNEL (e.g. "telegram", "slack", "discord") and
# OPENCLAW_NOTIFY_TARGET (e.g. a chat/user ID) to enable --send.
CHANNEL = os.environ.get("OPENCLAW_NOTIFY_CHANNEL", "")
TARGET_ID = os.environ.get("OPENCLAW_NOTIFY_TARGET", "")
# Resolve tools directory dynamically from this file's location — no hardcoded paths
_TOOLS_DIR = str(pathlib.Path(__file__).parent.resolve())
# ─── Data Fetch ───────────────────────────────────────────────────────────────
def fetch_candles() -> list[dict]:
"""Fetch last 24 hourly candles from Kraken for BTC/USD."""
since = int(time.time()) - (LOOKBACK + 2) * 3600
resp = requests.get(KRAKEN_OHLC_URL, params={"pair": BTC_PAIR, "interval": INTERVAL, "since": since}, timeout=10)
resp.raise_for_status()
data = resp.json()
if data.get("error"):
raise ValueError(f"Kraken error: {data['error']}")
raw = list(data["result"].values())[0]
candles = [
{
"time": int(c[0]),
"open": float(c[1]),
"high": float(c[2]),
"low": float(c[3]),
"close": float(c[4]),
"volume": float(c[6]),
}
for c in raw
]
return candles[-LOOKBACK:] # last 24
# ─── Indicators ───────────────────────────────────────────────────────────────
def exponential_weights(n: int) -> list[float]:
"""Exponential weights — recent candles matter more. Sums to 1."""
raw = [math.exp(i / (n / 3)) for i in range(n)]
total = sum(raw)
return [w / total for w in raw]
def compute_rsi(closes: list[float], period: int = 14) -> float:
if len(closes) < period + 1:
return 50.0
deltas = [closes[i] - closes[i - 1] for i in range(1, len(closes))]
gains = [max(d, 0) for d in deltas[-period:]]
losses = [abs(min(d, 0)) for d in deltas[-period:]]
avg_gain = sum(gains) / period
avg_loss = sum(losses) / period
if avg_loss == 0:
return 100.0
rs = avg_gain / avg_loss
return 100 - (100 / (1 + rs))
def compute_macd(closes: list[float]) -> dict:
"""Returns MACD line, signal, histogram."""
def ema(data, period):
k = 2 / (period + 1)
result = [data[0]]
for v in data[1:]:
result.append(v * k + result[-1] * (1 - k))
return result
if len(closes) < 26:
return {"macd": 0, "signal": 0, "histogram": 0}
ema12 = ema(closes, 12)
ema26 = ema(closes, 26)
macd_line = [e12 - e26 for e12, e26 in zip(ema12[-len(ema26):], ema26)]
signal = ema(macd_line, 9)
hist = macd_line[-1] - signal[-1]
return {"macd": macd_line[-1], "signal": signal[-1], "histogram": hist}
def candle_body_bias(candles: list[dict], weights: list[float]) -> float:
"""
Weighted ratio of green (bullish) candle bodies vs total.
Returns -1.0 (all bearish) to +1.0 (all bullish).
"""
score = 0.0
for c, w in zip(candles, weights):
if c["close"] > c["open"]:
score += w
elif c["close"] < c["open"]:
score -= w
return score # already weighted, range roughly -1 to +1
def volume_trend(candles: list[dict], weights: list[float]) -> float:
"""
Compare volume-weighted by candle direction.
Returns -1.0 to +1.0: positive = more volume on up candles.
"""
up_vol = sum(c["volume"] * w for c, w in zip(candles, weights) if c["close"] >= c["open"])
down_vol = sum(c["volume"] * w for c, w in zip(candles, weights) if c["close"] < c["open"])
total = up_vol + down_vol
if total == 0:
return 0.0
return (up_vol - down_vol) / total
# ─── Score Computation ────────────────────────────────────────────────────────
def compute_bmi(candles: list[dict]) -> dict:
closes = [c["close"] for c in candles]
weights = exponential_weights(len(candles))
# 1. Price trend: weighted average of hourly returns
returns = [(closes[i] - closes[i - 1]) / closes[i - 1] for i in range(1, len(closes))]
w_returns = weights[1:] # align
w_total = sum(w_returns)
weighted_return = sum(r * w for r, w in zip(returns, w_returns)) / w_total
# Normalize: ±0.5% per hour → ±100 score
trend_score = max(-1.0, min(1.0, weighted_return / 0.005))
# 2. RSI: map 0-100 → -1 to +1 (50 = 0)
rsi = compute_rsi(closes)
rsi_score = (rsi - 50) / 50 # -1 to +1
# 3. MACD histogram direction normalized
macd_data = compute_macd(closes)
hist = macd_data["histogram"]
price = closes[-1]
macd_score = max(-1.0, min(1.0, (hist / price) * 5000)) # scale to asset price
# 4. Candle body bias (already -1 to +1)
body_score = candle_body_bias(candles, weights)
# 5. Volume trend (already -1 to +1)
vol_score = volume_trend(candles, weights)
# Weighted composite (recent-heavy signals matter more)
# Trend and MACD are most actionable; RSI is context
W = {"trend": 0.30, "macd": 0.25, "body": 0.20, "volume": 0.15, "rsi": 0.10}
composite = (
trend_score * W["trend"] +
macd_score * W["macd"] +
body_score * W["body"] +
vol_score * W["volume"] +
rsi_score * W["rsi"]
)
bmi = round(composite * 100)
# Signal label
if bmi >= 50: signal = "STRONGLY BULLISH"
elif bmi >= 20: signal = "BULLISH"
elif bmi >= 5: signal = "MILDLY BULLISH"
elif bmi > -5: signal = "NEUTRAL"
elif bmi > -20: signal = "MILDLY BEARISH"
elif bmi > -50: signal = "BEARISH"
else: signal = "STRONGLY BEARISH"
# New position bias
if bmi >= 20: bias = "LONG"
elif bmi <= -20: bias = "SHORT"
else: bias = "FLAT"
return {
"bmi": bmi,
"signal": signal,
"bias": bias,
"open_new_position": abs(bmi) >= 20,
"btc_price": closes[-1],
"btc_24h_pct": round((closes[-1] - closes[0]) / closes[0] * 100, 2),
"rsi": round(rsi, 2),
"macd_histogram": round(macd_data["histogram"], 2),
"components": {
"trend": round(trend_score * 100),
"macd": round(macd_score * 100),
"body": round(body_score * 100),
"volume": round(vol_score * 100),
"rsi": round(rsi_score * 100),
}
}
# ─── Position Health ──────────────────────────────────────────────────────────
def evaluate_positions(bmi_data: dict, positions: list[dict]) -> list[dict]:
bmi = bmi_data["bmi"]
results = []
for pos in positions:
side = pos["side"].lower()
pnl = float(pos.get("unrealized_pnl", 0))
pct = float(pos.get("unrealized_pnl_pct", 0))
# Alignment: long + bullish OR short + bearish = aligned
if side == "long":
aligned = bmi >= 0
else:
aligned = bmi <= 0
# Health: is position making money AND aligned?
if aligned and pnl > 0:
health = "HEALTHY"
action = "HOLD"
elif aligned and pnl <= 0:
health = "WATCH"
action = "MONITOR — aligned with market but underwater"
elif not aligned and pnl > 0:
health = "WATCH"
action = "MONITOR — profitable but fighting market momentum"
else:
health = "AT RISK"
action = "CONSIDER CLOSING — fighting momentum and losing"
results.append({
"symbol": pos["symbol"],
"side": side,
"pnl_usd": round(pnl, 2),
"pnl_pct": round(pct, 2),
"aligned_with_bmi": aligned,
"health": health,
"action": action,
})
return results
def get_open_positions() -> list[dict]:
"""Fetch open positions via katbot_client using the current Python interpreter."""
script = f"""
import sys, json
sys.path.insert(0, {_TOOLS_DIR!r})
from katbot_client import get_token, get_portfolio, get_config
token = get_token()
config = get_config()
portfolio_id = config.get('portfolio_id')
if not portfolio_id:
print('[]')
else:
p = get_portfolio(token, portfolio_id)
print(json.dumps(p.get('open_positions', [])))
"""
result = subprocess.run(
[sys.executable, "-c", script],
capture_output=True, text=True, cwd=_TOOLS_DIR,
env={**os.environ, "PYTHONPATH": _TOOLS_DIR},
)
if result.returncode != 0:
return []
for line in result.stdout.splitlines():
if line.startswith("["):
import json as _json
return _json.loads(line)
return []
# ─── Report ───────────────────────────────────────────────────────────────────
def format_report(bmi_data: dict, position_health: list[dict]) -> str:
bmi = bmi_data["bmi"]
bar_len = abs(bmi) // 5
bar = ("█" * bar_len).ljust(20)
direction = "▶" if bmi >= 0 else "◀"
lines = [
f"📡 BTC Momentum Index: {bmi:+d} — {bmi_data['signal']}",
f" {direction} [{bar}]",
f" BTC: ${bmi_data['btc_price']:,.0f} | 24h: {bmi_data['btc_24h_pct']:+.2f}%",
f" RSI: {bmi_data['rsi']} | MACD hist: {bmi_data['macd_histogram']}",
f"",
f"🎯 Bias: {bmi_data['bias']} | New position: {'✅ YES' if bmi_data['open_new_position'] else '⛔ NO'}",
]
if position_health:
lines.append("")
lines.append("📋 Position Health:")
for p in position_health:
icon = "✅" if p["health"] == "HEALTHY" else ("⚠️" if p["health"] == "WATCH" else "🚨")
lines.append(f" {icon} {p['symbol']} {p['side'].upper()}: ${p['pnl_usd']:+.2f} ({p['pnl_pct']:+.2f}%) — {p['action']}")
return "\n".join(lines)
def send_message(msg: str):
if not CHANNEL or not TARGET_ID:
print(
"Warning: OPENCLAW_NOTIFY_CHANNEL and OPENCLAW_NOTIFY_TARGET must both be set to send messages. Skipping.",
file=sys.stderr,
)
return
subprocess.run(
["openclaw", "message", "send", "--channel", CHANNEL, "--target", TARGET_ID, "--message", msg],
capture_output=True, text=True
)
# ─── Main ─────────────────────────────────────────────────────────────────────
def run(send: bool = False, as_json: bool = False) -> dict:
candles = fetch_candles()
bmi_data = compute_bmi(candles)
positions = get_open_positions()
position_health = evaluate_positions(bmi_data, positions)
bmi_data["position_health"] = position_health
if as_json:
print(json.dumps(bmi_data, indent=2))
else:
report = format_report(bmi_data, position_health)
print(report)
if send:
send_message(report)
return bmi_data
if __name__ == "__main__":
parser = argparse.ArgumentParser()
parser.add_argument("--send", action="store_true", help="Send to Telegram")
parser.add_argument("--json", action="store_true", dest="as_json", help="JSON output")
args = parser.parse_args()
run(send=args.send, as_json=args.as_json)
#!/usr/bin/env bash
# ensure_env.sh — Verify the skill's Python dependencies are installed for the
# current skill version. Run this before executing any skill tool script.
#
# Usage:
# bash {baseDir}/tools/ensure_env.sh {baseDir}
#
# Exit codes:
# 0 — environment is ready
# 1 — install failed (pip error or missing python3)
set -e
SKILL_DIR="${1:-$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)}"
TOOLS_DIR="${SKILL_DIR}/tools"
REQUIREMENTS="${SKILL_DIR}/requirements.txt"
SKILL_MD="${SKILL_DIR}/SKILL.md"
STAMP_FILE="${SKILL_DIR}/.installed_version"
# ── Resolve current skill version from SKILL.md ───────────────────────────────
CURRENT_VERSION=$(grep "^version:" "$SKILL_MD" 2>/dev/null | head -1 | sed 's/.*: //' | tr -d '[:space:]')
if [ -z "$CURRENT_VERSION" ]; then
echo "⚠ Could not read skill version from SKILL.md — skipping version check."
CURRENT_VERSION="unknown"
fi
# ── Read installed version stamp ──────────────────────────────────────────────
INSTALLED_VERSION=""
if [ -f "$STAMP_FILE" ]; then
INSTALLED_VERSION=$(cat "$STAMP_FILE" | tr -d '[:space:]')
fi
# ── Skip if already up to date ────────────────────────────────────────────────
if [ "$INSTALLED_VERSION" = "$CURRENT_VERSION" ]; then
exit 0
fi
# ── Install / upgrade dependencies ────────────────────────────────────────────
echo "📦 Skill version changed (${INSTALLED_VERSION:-none} → ${CURRENT_VERSION}). Installing dependencies..."
if ! command -v python3 &>/dev/null; then
echo "❌ python3 not found. Please install Python 3.9+ and re-run." >&2
exit 1
fi
if [ ! -f "$REQUIREMENTS" ]; then
echo "❌ requirements.txt not found at $REQUIREMENTS" >&2
exit 1
fi
python3 -m pip install --quiet -r "$REQUIREMENTS"
# ── Write version stamp on success ────────────────────────────────────────────
echo "$CURRENT_VERSION" > "$STAMP_FILE"
echo "✅ Dependencies installed for katbot-trading@${CURRENT_VERSION}."
"""
katbot_client.py — Katbot.ai API client for agents and CLI tools.
Supports two configuration modes:
1. Environment variables (for OpenClaw skills)
2. .env file (for tubman-bobtail-py CLI usage)
Features:
- SIWE authentication with JWT refresh
- Portfolio management (CRUD, tokens, timeseries, chain info)
- Agent management (CRUD, assignment, invitations)
- Recommendation workflow (request, poll, execute, response)
- Trade execution and position closing
- Conversation history management
- User and subscription info
IMPORTANT: ALWAYS include X-Agent-Private-Key header for Hyperliquid portfolio calls.
See MEMORY.md Katbot/Tubman Client Rule for details.
Portfolio types:
HL_PAPER — paper trading on Hyperliquid (no real funds, was "PAPER")
HYPERLIQUID — live trading on Hyperliquid (agent key required)
"""
import base64
import json
import os
import time
import requests
from pathlib import Path
from eth_account import Account
from eth_account.messages import encode_defunct
# Configuration: support both env vars and .env file
BASE_URL = os.getenv("KATBOT_BASE_URL")
IDENTITY_DIR = os.getenv("KATBOT_IDENTITY_DIR")
# If not set via env vars, try loading from .env file (tubman-bobtail-py mode)
ENV_FILE = None
# 1. (user homedir)/katbot_client.env
# 2. (openclaw_home)/katbot_identity/katbot_client.env
if not BASE_URL or not IDENTITY_DIR:
env_candidates = [
Path(__file__).parent.parent.parent / "env" / "local" / "katbot_client.env",
Path(__file__).parent.parent / "env" / "local" / "katbot_client.env",
Path(__file__).parent / "katbot_client.env",
Path.home() / "katbot_client.env",
]
# Add the second candidate only if OPENCLAW_HOME is defined
openclaw_home = os.environ.get("OPENCLAW_HOME")
if openclaw_home:
env_candidates.append(Path(openclaw_home) / "katbot_identity" / "katbot_client.env")
for candidate in env_candidates:
if candidate.exists():
ENV_FILE = candidate
break
if ENV_FILE and ENV_FILE.exists():
with open(ENV_FILE) as f:
for line in f:
line = line.strip()
if "=" in line and not line.startswith("#"):
key, val = line.split("=", 1)
key = key.strip()
val = val.strip().strip('"')
if key == "KATBOT_BASE_URL" and not BASE_URL:
BASE_URL = val
elif key == "KATBOT_IDENTITY_DIR" and not IDENTITY_DIR:
IDENTITY_DIR = val
elif key == "CHAIN_ID" and not os.getenv("CHAIN_ID"):
os.environ["CHAIN_ID"] = val
# WALLET_PRIVATE_KEY and KATBOT_HL_AGENT_PRIVATE_KEY are intentionally
# NOT loaded from .env files — private keys must be supplied via
# environment variables or the identity directory only.
# Default fallbacks
if not BASE_URL:
BASE_URL = os.getenv("KATBOT_BASE_URL", "https://api.katbot.ai")
if not IDENTITY_DIR:
IDENTITY_DIR = os.getenv("KATBOT_IDENTITY_DIR", os.path.expanduser("~/.openclaw/workspace/katbot-identity"))
# File paths
TOKEN_FILE = os.path.join(IDENTITY_DIR, "katbot_token.json")
SECRETS_FILE = os.path.join(IDENTITY_DIR, "katbot_secrets.json")
CONFIG_FILE = os.path.join(IDENTITY_DIR, "katbot_config.json")
# Load keys from env vars or secrets file
WALLET_PRIVATE_KEY = os.getenv("WALLET_PRIVATE_KEY")
AGENT_PRIVATE_KEY = os.getenv("KATBOT_HL_AGENT_PRIVATE_KEY")
# If agent key not in env, try loading from secrets file
if not AGENT_PRIVATE_KEY and os.path.exists(SECRETS_FILE):
try:
with open(SECRETS_FILE) as f:
secrets = json.load(f)
AGENT_PRIVATE_KEY = secrets.get("agent_private_key")
except Exception:
pass # Fail silently if file is corrupt or unreadable
CHAIN_ID = int(os.getenv("CHAIN_ID", "42161"))
def get_config() -> dict:
"""Load configuration from the identity file."""
if os.path.exists(CONFIG_FILE):
try:
with open(CONFIG_FILE) as f:
return json.load(f)
except Exception:
return {}
return {}
def _jwt_expiry(token: str) -> float:
"""Decode JWT payload (no signature verification) and return exp as a Unix timestamp.
Returns 0 if the token is malformed or has no exp claim."""
try:
parts = token.split(".")
if len(parts) != 3:
return 0
# Add padding so base64 decodes cleanly
payload_b64 = parts[1] + "=" * (-len(parts[1]) % 4)
payload = json.loads(base64.urlsafe_b64decode(payload_b64))
return float(payload.get("exp", 0))
except Exception:
return 0
def _token_is_valid(token: str, margin_seconds: int = 60) -> bool:
"""Return True if the token exists and won't expire within margin_seconds."""
if not token:
return False
exp = _jwt_expiry(token)
if exp == 0:
return False # can't determine expiry — treat as invalid
return time.time() < (exp - margin_seconds)
def _refresh_access_token(refresh_token: str) -> str | None:
"""Exchange a refresh token for a new access token and refresh token.
Both tokens are saved to disk. Returns the new access token, or None if refresh fails."""
if not refresh_token:
return None
try:
r = requests.post(
f"{BASE_URL}/refresh",
json={"refresh_token": refresh_token},
timeout=15,
)
if r.status_code != 200:
return None
data = r.json()
new_access = data.get("access_token", "")
new_refresh = data.get("refresh_token", "")
if not new_access or not new_refresh:
return None
os.makedirs(IDENTITY_DIR, exist_ok=True)
with open(TOKEN_FILE, "w") as f:
json.dump({"access_token": new_access, "refresh_token": new_refresh}, f, indent=2)
try:
os.chmod(TOKEN_FILE, 0o600)
except Exception:
pass
return new_access
except Exception:
return None
def authenticate() -> str:
"""Perform SIWE login and return a fresh JWT. Saves token to disk."""
if not WALLET_PRIVATE_KEY:
raise ValueError(
"\n❌ Session expired and WALLET_PRIVATE_KEY not set.\n"
" Please re-run the onboarding script to refresh your session:\n"
" python3 skills/katbot-trading/tools/katbot_onboard.py"
)
account = Account.from_key(WALLET_PRIVATE_KEY)
address = account.address
# Step 1: Get nonce
r = requests.get(f"{BASE_URL}/get-nonce/{address}?chain_id={CHAIN_ID}")
r.raise_for_status()
message_text = r.json()["message"]
# Step 2: Sign
signable = encode_defunct(text=message_text)
signed = Account.sign_message(signable, WALLET_PRIVATE_KEY)
signature = signed.signature.hex()
# Step 3: Login
r = requests.post(f"{BASE_URL}/login", json={"address": address, "signature": signature, "chain_id": CHAIN_ID})
r.raise_for_status()
token_data = r.json()
os.makedirs(IDENTITY_DIR, exist_ok=True)
with open(TOKEN_FILE, "w") as f:
json.dump(token_data, f, indent=2)
try:
os.chmod(TOKEN_FILE, 0o600)
except Exception:
pass
return token_data["access_token"]
def get_token() -> str:
"""Return a valid access token, using refresh or re-auth as needed.
Token resolution order:
1. Use the saved access token if it is not yet expired.
2. If expired, call POST /refresh with the saved refresh token.
The API rotates the refresh token on every call, so both tokens are
written to disk immediately before returning — the old refresh token
is invalid the moment the response arrives.
3. If refresh fails (token expired, revoked, or missing), fall back to
full SIWE re-authentication via POST /login.
"""
if os.path.exists(TOKEN_FILE):
try:
with open(TOKEN_FILE) as f:
data = json.load(f)
except Exception:
data = {}
access_token = data.get("access_token", "")
refresh_token = data.get("refresh_token", "")
if _token_is_valid(access_token):
return access_token
# Access token expired — attempt refresh regardless of refresh token
# expiry (refresh tokens may be opaque and lack a decodable exp claim).
if refresh_token:
new_token = _refresh_access_token(refresh_token)
if new_token:
return new_token
return authenticate()
def _auth(token: str, agent_key: str = None) -> dict:
"""Build auth headers with optional agent private key.
CRITICAL: ALWAYS include X-Agent-Private-Key for Hyperliquid portfolio calls.
The API requires this header for all Hyperliquid portfolio endpoints.
"""
headers = {"Authorization": f"Bearer {token}"}
# Always include agent key if available - required for Hyperliquid portfolios
if agent_key:
headers["X-Agent-Private-Key"] = agent_key
elif AGENT_PRIVATE_KEY:
headers["X-Agent-Private-Key"] = AGENT_PRIVATE_KEY
return headers
def _require_agent_key() -> str:
"""Require agent private key to be available, raising clear error if not."""
if AGENT_PRIVATE_KEY:
return AGENT_PRIVATE_KEY
raise ValueError(
"\n❌ KATBOT_HL_AGENT_PRIVATE_KEY not set.\n"
" Required for Hyperliquid portfolio operations.\n"
" Set via environment variable or in secrets file:\n"
f" {SECRETS_FILE}"
)
# ============================================================================
# PORTFOLIO MANAGEMENT
# ============================================================================
def list_portfolios(token: str) -> list:
"""List all portfolios for the authenticated user."""
r = requests.get(f"{BASE_URL}/portfolio", headers=_auth(token))
r.raise_for_status()
return r.json()
def create_portfolio(token: str, name: str, portfolio_type: str = "HL_PAPER",
agent_private_key: str = None, amount: float = None,
is_testnet: bool = True, primary_agent_id: int = None,
arbitrum_rpc_url: str = None) -> dict:
"""Create a new portfolio.
Args:
token: JWT access token
name: Portfolio name
portfolio_type: "HL_PAPER" (paper trading) or "HYPERLIQUID" (live trading).
Note: the old "PAPER" value has been renamed to "HL_PAPER".
agent_private_key: Optional agent private key (for HYPERLIQUID type)
amount: Initial USD balance for paper portfolios (ignored for HYPERLIQUID)
is_testnet: Use Hyperliquid testnet (default True for safety)
primary_agent_id: ID of the agent to assign as primary to this portfolio
arbitrum_rpc_url: Arbitrum RPC URL for Hyperliquid portfolio
Returns:
Created PortfolioInfo dict with id, name, type, agent_address, etc.
"""
payload = {
"name": name,
"portfolio_type": portfolio_type,
"is_testnet": is_testnet,
}
key = agent_private_key or AGENT_PRIVATE_KEY
if key:
payload["agent_private_key"] = key
if amount is not None:
payload["amount"] = amount
if primary_agent_id is not None:
payload["primary_agent_id"] = primary_agent_id
if arbitrum_rpc_url is not None:
payload["arbitrum_rpc_url"] = arbitrum_rpc_url
r = requests.post(f"{BASE_URL}/portfolio", json=payload, headers=_auth(token))
r.raise_for_status()
return r.json()
def get_portfolio(token: str, portfolio_id: int, window: str = None, require_agent: bool = True) -> dict:
"""Get portfolio state.
For timeseries data use get_portfolio_timeseries() instead.
Args:
token: JWT access token
portfolio_id: Portfolio ID to query
window: (deprecated) Use get_portfolio_timeseries() for timeseries data
require_agent: If True (default), raises error if agent key not available.
Set to False for paper portfolios that don't need agent key.
Returns:
PortfolioInfo dict with portfolio state, positions, PnL metrics, etc.
Raises:
ValueError: If require_agent=True and KATBOT_HL_AGENT_PRIVATE_KEY not set
HTTPError: If API returns error (e.g., 400 for missing agent key on Hyperliquid)
"""
agent_key = _require_agent_key() if require_agent else AGENT_PRIVATE_KEY
params = {}
if window is not None:
params["window"] = window
r = requests.get(
f"{BASE_URL}/portfolio/{portfolio_id}",
params=params,
headers=_auth(token, agent_key)
)
r.raise_for_status()
return r.json()
def update_portfolio(token: str, portfolio_id: int,
name: str = None,
tokens_selected: list = None,
max_history_messages: int = None) -> dict:
"""Update portfolio settings (name, tokens, history limit).
Args:
token: Auth token
portfolio_id: Portfolio ID
name: New portfolio name (optional)
tokens_selected: List of token symbols (e.g., ["BTC", "ETH", "SOL"])
max_history_messages: Conversation history limit
Returns:
Updated PortfolioInfo dict
"""
payload = {}
if name is not None:
payload["name"] = name
if tokens_selected is not None:
payload["tokens_selected"] = tokens_selected
if max_history_messages is not None:
payload["max_history_messages"] = max_history_messages
r = requests.put(f"{BASE_URL}/portfolio/{portfolio_id}",
json=payload, headers=_auth(token))
r.raise_for_status()
return r.json()
def delete_portfolio(token: str, portfolio_id: int,
agent_private_key: str = None,
user_master_address: str = None) -> dict:
"""Delete a portfolio and all its associated data.
Args:
token: JWT access token
portfolio_id: Portfolio ID to delete
agent_private_key: Agent private key (for Hyperliquid portfolios)
user_master_address: Master wallet address
Returns:
Dict with success status and message
"""
params = {}
if user_master_address:
params["user_master_address"] = user_master_address
key = agent_private_key or AGENT_PRIVATE_KEY
if key:
params["agent_private_key"] = key
r = requests.delete(f"{BASE_URL}/portfolio/{portfolio_id}",
params=params, headers=_auth(token))
r.raise_for_status()
return r.json()
def get_portfolio_tokens(token: str, portfolio_id: int) -> list:
"""Get available trading token symbols for a portfolio.
Returns:
List of symbol strings (e.g., ["BTC", "ETH", "SOL"])
"""
r = requests.get(f"{BASE_URL}/portfolio/{portfolio_id}/tokens", headers=_auth(token))
r.raise_for_status()
return r.json()
def get_portfolio_chain_info(token: str, portfolio_id: int) -> dict:
"""Get chain information for a portfolio.
Returns:
Dict with portfolio_id, chain_id, is_testnet, network_name
"""
r = requests.get(f"{BASE_URL}/portfolio/{portfolio_id}/chain-info", headers=_auth(token))
r.raise_for_status()
return r.json()
def get_portfolio_timeseries(token: str, portfolio_id: int,
granularity: str,
limit: int = 100,
window: str = "24H",
agent_private_key: str = None) -> dict:
"""Get timeseries data for a portfolio.
Args:
token: JWT access token
portfolio_id: Portfolio ID
granularity: Data granularity string (e.g., "1m", "5m", "15m", "1h", "4h", "1d", "1w", "1M")
limit: Maximum number of data points (default 100)
window: Time window (e.g., "24H", "7D", "30D", default "24H")
agent_private_key: Agent private key (required for HYPERLIQUID portfolios)
Returns:
Dict with timeseries list, portfolio_id, granularity, window, limit
"""
key = agent_private_key or AGENT_PRIVATE_KEY
r = requests.get(
f"{BASE_URL}/portfolio/{portfolio_id}/timeseries",
params={"granularity": granularity, "limit": limit, "window": window},
headers=_auth(token, key)
)
r.raise_for_status()
return r.json()
def approve_builder_fee(token: str, portfolio_id: int,
action: dict, signature: dict, nonce: int) -> dict:
"""Broadcast a user-signed approveBuilderFee action to Hyperliquid.
The frontend constructs and signs the EIP-712 approveBuilderFee action.
This endpoint forwards it to Hyperliquid and records approval in the database.
Args:
token: JWT access token
portfolio_id: Portfolio ID (must be HYPERLIQUID type)
action: Full action dict (type, builder, maxFeeRate, nonce, etc.)
signature: {r, s, v} signature from MetaMask signTypedData
nonce: Millisecond timestamp used as nonce
Returns:
Dict with status, result, and portfolio_id
"""
payload = {"action": action, "signature": signature, "nonce": nonce}
r = requests.post(f"{BASE_URL}/portfolio/{portfolio_id}/approve-builder-fee",
json=payload, headers=_auth(token))
r.raise_for_status()
return r.json()
def validate_hyperliquid(token: str, agent_private_key: str = None,
is_testnet: bool = True) -> dict:
"""Validate a Hyperliquid connection using an agent private key.
Args:
token: JWT access token
agent_private_key: Agent private key to validate
is_testnet: Use testnet (default True for safety)
Returns:
Dict with status, validation details, and user_address
"""
key = agent_private_key or AGENT_PRIVATE_KEY
payload = {"agent_private_key": key, "is_testnet": is_testnet}
r = requests.post(f"{BASE_URL}/portfolio/validate-hyperliquid",
json=payload, headers=_auth(token))
r.raise_for_status()
return r.json()
# ============================================================================
# RECOMMENDATIONS
# ============================================================================
def request_recommendation(token: str, portfolio_id: int, message: str,
agent_id: int = None) -> dict:
"""Submit a recommendation request to the agent (async, returns ticket).
Args:
token: JWT access token
portfolio_id: Portfolio ID
message: Prompt/message for the agent
agent_id: Optional specific agent ID to use. If None, uses the portfolio's
primary agent.
Returns:
Dict with ticket_id and status
"""
payload = {
"portfolio_id": portfolio_id,
"message": message,
}
if agent_id is not None:
payload["agent_id"] = agent_id
if AGENT_PRIVATE_KEY:
payload["agent_private_key"] = AGENT_PRIVATE_KEY
r = requests.post(f"{BASE_URL}/agent/recommendation/message", json=payload, headers=_auth(token))
r.raise_for_status()
return r.json()
def poll_recommendation(token: str, ticket_id: str, max_wait: int = 60) -> dict:
"""Poll until recommendation is ready or timeout.
Returns:
Dict with ticket_id, status, done, response, error
"""
deadline = time.time() + max_wait
while time.time() < deadline:
r = requests.get(f"{BASE_URL}/agent/recommendation/poll/{ticket_id}", headers=_auth(token))
r.raise_for_status()
data = r.json()
if data.get("done") or data.get("status") in ("COMPLETED", "complete", "FAILED"):
return data
time.sleep(2)
raise TimeoutError(f"Recommendation not ready after {max_wait}s")
def submit_recommendation_response(token: str, portfolio_id: int,
recommendation: dict,
agent_id: int = None,
pack_goals: str = None,
agent_private_key: str = None) -> dict:
"""Submit a foreign agent's recommendation for analysis (async, returns ticket).
Used by openclaw to analyze a recommendation from another agent/katpack.
Args:
token: JWT access token
portfolio_id: Portfolio ID
recommendation: ForeignRecommendationContext dict with agent_name, symbol,
action, confidence, entry_price, take_profit_pct, stop_loss_pct,
rationale, katbot_portfolio_id
agent_id: Optional specific agent to use
pack_goals: Katpack goals/description
agent_private_key: Agent private key for Hyperliquid operations
Returns:
Dict with ticket_id and status
"""
payload = {
"portfolio_id": portfolio_id,
"recommendation": recommendation,
}
if agent_id is not None:
payload["agent_id"] = agent_id
if pack_goals is not None:
payload["pack_goals"] = pack_goals
key = agent_private_key or AGENT_PRIVATE_KEY
if key:
payload["agent_private_key"] = key
r = requests.post(f"{BASE_URL}/agent/recommendation/response",
json=payload, headers=_auth(token))
r.raise_for_status()
return r.json()
def poll_recommendation_response(token: str, ticket_id: str, max_wait: int = 60) -> dict:
"""Poll until recommendation response analysis is ready or timeout.
Returns:
Dict with ticket_id, status, done, response (markdown analysis), error
"""
deadline = time.time() + max_wait
while time.time() < deadline:
r = requests.get(f"{BASE_URL}/agent/recommendation/response/poll/{ticket_id}",
headers=_auth(token))
r.raise_for_status()
data = r.json()
if data.get("done") or data.get("status") in ("COMPLETED", "complete", "FAILED"):
return data
time.sleep(2)
raise TimeoutError(f"Recommendation response not ready after {max_wait}s")
def get_recommendations(token: str, portfolio_id: int) -> list:
"""Get existing recommendations for a portfolio."""
r = requests.get(f"{BASE_URL}/portfolio/{portfolio_id}/recommendation", headers=_auth(token))
r.raise_for_status()
return r.json()
def execute_recommendation(token: str, portfolio_id: int, rec_id: int,
execute_onchain: bool = False,
user_master_address: str = None) -> dict:
"""Execute an existing recommendation by ID."""
payload = {"recommendation_id": rec_id}
if execute_onchain is not None:
payload["execute_onchain"] = execute_onchain
if AGENT_PRIVATE_KEY:
payload["agent_private_key"] = AGENT_PRIVATE_KEY
if user_master_address:
payload["user_master_address"] = user_master_address
r = requests.post(f"{BASE_URL}/portfolio/{portfolio_id}/execute",
json=payload, headers=_auth(token))
r.raise_for_status()
return r.json()
# ============================================================================
# TRADES & POSITIONS
# ============================================================================
def close_position(token: str, portfolio_id: int, symbol: str,
user_master_address: str = None,
reason: str = "API position closure",
execute_onchain: bool = False) -> dict:
"""Close an open position by symbol.
Args:
token: JWT access token
portfolio_id: Portfolio ID
symbol: Token symbol (e.g., "ETH", "BTC")
user_master_address: Master wallet address for Hyperliquid agent approval
reason: Reason for closing the position (default "API position closure")
execute_onchain: Whether to execute on-chain (default False)
Returns:
ClosePositionResponse dict with success, message, symbol, exit_price, pnl_usd
"""
payload = {
"symbol": symbol,
"reason": reason,
"execute_onchain": execute_onchain,
}
if user_master_address:
payload["user_master_address"] = user_master_address
if AGENT_PRIVATE_KEY:
payload["agent_private_key"] = AGENT_PRIVATE_KEY
r = requests.post(f"{BASE_URL}/portfolio/{portfolio_id}/close-position",
json=payload, headers=_auth(token))
r.raise_for_status()
return r.json()
def list_trades(token: str, portfolio_id: int,
agent_private_key: str = None,
user_master_address: str = None) -> list:
"""List all trades for a portfolio.
Args:
token: JWT access token
portfolio_id: Portfolio ID
agent_private_key: Agent private key (for Hyperliquid portfolios)
user_master_address: Master wallet address
Returns:
List of trade dicts
"""
params = {}
if user_master_address:
params["user_master_address"] = user_master_address
key = agent_private_key or AGENT_PRIVATE_KEY
if key:
params["agent_private_key"] = key
r = requests.get(f"{BASE_URL}/portfolio/{portfolio_id}/trade",
params=params, headers=_auth(token))
r.raise_for_status()
return r.json()
def get_position_events(token: str, portfolio_id: int,
limit: int = 20,
event_type: str = None,
agent_private_key: str = None,
user_master_address: str = None) -> list:
"""Return position lifecycle events for a portfolio.
Event types: TP_HIT, SL_HIT, LIQUIDATED, MANUAL_CLOSE
Args:
token: JWT access token
portfolio_id: Portfolio ID
limit: Max events to return (default 20, max 200)
event_type: Filter by event type (optional)
agent_private_key: Required for HYPERLIQUID portfolios
user_master_address: Master wallet address
Returns:
List of position event dicts
"""
params = {"limit": limit}
if event_type:
params["event_type"] = event_type
if user_master_address:
params["user_master_address"] = user_master_address
key = agent_private_key or AGENT_PRIVATE_KEY
r = requests.get(f"{BASE_URL}/portfolio/{portfolio_id}/events",
params=params, headers=_auth(token, key))
r.raise_for_status()
return r.json()
# ============================================================================
# AGENT MANAGEMENT
# ============================================================================
def list_agents(token: str) -> list:
"""List all agents belonging to the authenticated user.
Returns:
List of AgentInfo dicts (id, name, max_history_messages, avatar_url, etc.)
"""
r = requests.get(f"{BASE_URL}/agents", headers=_auth(token))
r.raise_for_status()
return r.json()
def create_agent(token: str, name: str, max_history_messages: int = 10) -> dict:
"""Create a new agent.
Args:
token: JWT access token
name: Agent slug name (lowercase letters, numbers, hyphens; a 6-char suffix
is appended automatically by the server)
max_history_messages: Conversation history retention limit (1-100)
Returns:
AgentInfo dict with id, name, avatar_url, etc.
"""
payload = {"name": name, "max_history_messages": max_history_messages}
r = requests.post(f"{BASE_URL}/agents", json=payload, headers=_auth(token))
r.raise_for_status()
return r.json()
def get_agent(token: str, agent_id: int) -> dict:
"""Get details for a specific agent.
Returns:
AgentInfo dict
"""
r = requests.get(f"{BASE_URL}/agents/{agent_id}", headers=_auth(token))
r.raise_for_status()
return r.json()
def update_agent(token: str, agent_id: int,
name: str = None,
max_history_messages: int = None,
avatar_seed: str = None) -> dict:
"""Update an existing agent.
Args:
token: JWT access token
agent_id: Agent ID to update
name: New slug name (optional)
max_history_messages: Updated history limit (optional, 1-100)
avatar_seed: Seed string for avatar generation (optional)
Returns:
Updated AgentInfo dict
"""
payload = {}
if name is not None:
payload["name"] = name
if max_history_messages is not None:
payload["max_history_messages"] = max_history_messages
if avatar_seed is not None:
payload["avatar_seed"] = avatar_seed
r = requests.put(f"{BASE_URL}/agents/{agent_id}", json=payload, headers=_auth(token))
r.raise_for_status()
return r.json()
def delete_agent(token: str, agent_id: int) -> dict:
"""Delete an agent. Only agents with 0 active primary portfolio assignments can be deleted.
Returns:
Dict with success status
"""
r = requests.delete(f"{BASE_URL}/agents/{agent_id}", headers=_auth(token))
r.raise_for_status()
return r.json()
def search_agents(token: str, q: str, portfolio_id: int = None) -> list:
"""Search agents by name across all users (for invite flow).
Args:
token: JWT access token
q: Search query (minimum 3 characters)
portfolio_id: If provided, filters out agents already assigned or invited
Returns:
List of AgentInfo dicts (up to 10 results)
"""
params = {"q": q}
if portfolio_id is not None:
params["portfolio_id"] = portfolio_id
r = requests.get(f"{BASE_URL}/agents/search", params=params, headers=_auth(token))
r.raise_for_status()
return r.json()
# ============================================================================
# PORTFOLIO-AGENT ASSIGNMENTS
# ============================================================================
def get_portfolio_agent(token: str, portfolio_id: int) -> dict:
"""Get the active primary agent assignment for a portfolio.
Returns:
PortfolioAgentAssignmentInfo dict with id, portfolio_id, agent_id, role, agent info
"""
r = requests.get(f"{BASE_URL}/portfolio/{portfolio_id}/agent", headers=_auth(token))
r.raise_for_status()
return r.json()
def list_portfolio_agents(token: str, portfolio_id: int) -> list:
"""List all active agent assignments for a portfolio (primary and observers).
Returns:
List of PortfolioAgentAssignmentInfo dicts
"""
r = requests.get(f"{BASE_URL}/portfolio/{portfolio_id}/agents", headers=_auth(token))
r.raise_for_status()
return r.json()
def assign_agent(token: str, portfolio_id: int, agent_id: int,
role: str = "primary") -> dict:
"""Assign an agent to a portfolio.
For role "primary": deactivates the existing primary agent and sets this one.
For role "observer": adds the agent without affecting the existing primary.
Args:
token: JWT access token
portfolio_id: Portfolio ID
agent_id: Agent ID to assign
role: "primary" or "observer" (default "primary")
Returns:
PortfolioAgentAssignmentInfo dict
"""
payload = {"agent_id": agent_id, "role": role}
r = requests.post(f"{BASE_URL}/portfolio/{portfolio_id}/agent",
json=payload, headers=_auth(token))
r.raise_for_status()
return r.json()
def unassign_agent(token: str, portfolio_id: int, agent_id: int) -> dict:
"""Deactivate a specific agent assignment from a portfolio.
Returns:
Dict with success status
"""
r = requests.delete(f"{BASE_URL}/portfolio/{portfolio_id}/agent/{agent_id}",
headers=_auth(token))
r.raise_for_status()
return r.json()
# ============================================================================
# AGENT OBSERVER INVITATIONS
# ============================================================================
def create_agent_invitation(token: str, portfolio_id: int, agent_id: int) -> dict:
"""Invite an agent (owned by another user) to observe this portfolio.
Args:
token: JWT access token
portfolio_id: Portfolio ID (must be owned by caller)
agent_id: ID of the agent to invite as observer
Returns:
AgentObserverInviteInfo dict
"""
payload = {"agent_id": agent_id, "role": "observer"}
r = requests.post(f"{BASE_URL}/portfolio/{portfolio_id}/agent-invitations",
json=payload, headers=_auth(token))
r.raise_for_status()
return r.json()
def list_portfolio_invitations(token: str, portfolio_id: int) -> list:
"""List all agent invitations (pending, accepted, rejected) for a portfolio.
Returns:
List of AgentObserverInviteInfo dicts
"""
r = requests.get(f"{BASE_URL}/portfolio/{portfolio_id}/agent-invitations",
headers=_auth(token))
r.raise_for_status()
return r.json()
def list_pending_invitations(token: str) -> list:
"""List all pending invitations for agents owned by the authenticated user.
Returns:
List of AgentObserverInviteInfo dicts
"""
r = requests.get(f"{BASE_URL}/agents/invitations/pending", headers=_auth(token))
r.raise_for_status()
return r.json()
def respond_to_invitation(token: str, agent_id: int, invitation_id: int,
action: str) -> dict:
"""Accept or reject a pending agent observer invitation.
Args:
token: JWT access token
agent_id: ID of the agent (must be owned by caller)
invitation_id: Invitation ID to respond to
action: "accepted" or "rejected"
Returns:
Updated AgentObserverInviteInfo dict
"""
payload = {"action": action}
r = requests.post(f"{BASE_URL}/agents/{agent_id}/invitations/{invitation_id}/respond",
json=payload, headers=_auth(token))
r.raise_for_status()
return r.json()
def list_observer_portfolios(token: str) -> list:
"""Return portfolios that the authenticated user observes via an accepted agent invitation.
Returns:
List of PortfolioInfo dicts with observer_role="observer"
"""
r = requests.get(f"{BASE_URL}/agents/observer-portfolios", headers=_auth(token))
r.raise_for_status()
return r.json()
# ============================================================================
# CONVERSATION HISTORY
# ============================================================================
def get_conversation(token: str, portfolio_id: int) -> dict:
"""Get conversation history for a portfolio.
Returns:
Dict with portfolio_id, portfolio_name, exists, message_count,
last_interaction, created_at, conversation list
"""
r = requests.get(f"{BASE_URL}/portfolio/{portfolio_id}/conversation",
headers=_auth(token))
r.raise_for_status()
return r.json()
def delete_conversation(token: str, portfolio_id: int) -> dict:
"""Clear conversation history for a portfolio (preserves portfolio state).
Returns:
Dict with portfolio_id, portfolio_name, success, message
"""
r = requests.delete(f"{BASE_URL}/portfolio/{portfolio_id}/conversation",
headers=_auth(token))
r.raise_for_status()
return r.json()
# ============================================================================
# USER & SUBSCRIPTION
# ============================================================================
def get_user(token: str) -> dict:
"""Get user info including subscription and feature usage.
Returns:
GetUserResponse dict with sub, id, is_whitelisted, subscription, plan,
feature_usage
"""
r = requests.get(f"{BASE_URL}/user", headers=_auth(token))
r.raise_for_status()
return r.json()
def get_plans() -> list:
"""Get all available subscription plans (no auth required).
Returns:
List of PlanResponse dicts sorted by price ascending
"""
r = requests.get(f"{BASE_URL}/plans")
r.raise_for_status()
return r.json()
# ============================================================================
# CLI entry point
# ============================================================================
def main():
"""CLI entry point for katbot_client.py script."""
import sys
# Reload env if running as CLI
env = {}
if ENV_FILE and ENV_FILE.exists():
with open(ENV_FILE) as f:
for line in f:
line = line.strip()
if "=" in line and not line.startswith("#"):
key, val = line.split("=", 1)
env[key.strip()] = val.strip().strip('"')
env.update(os.environ) # Allow env var overrides
if len(sys.argv) < 2:
print("Usage: katbot_client.py <action> [args]")
print("Actions:")
print(" portfolio-state Get portfolio state")
print(" execute <rec_id> Execute a recommendation")
print(" close-position <sym> Close position by symbol")
print(" recommendations List recommendations")
print(" request-recommendation [msg] Request a new recommendation")
print(" poll-recommendation <ticket_id> Poll for recommendation result")
print(" update-portfolio --tokens BTC,ETH [--name Name] Update portfolio")
print(" list-agents List all agents")
print(" get-agent <agent_id> Get agent details")
print(" list-portfolio-agents List agents assigned to portfolio")
print(" assign-agent <agent_id> [--role primary|observer] Assign agent to portfolio")
print(" conversation Get conversation history")
print(" clear-conversation Clear conversation history")
print(" user Get user info and subscription")
print(" plans List subscription plans")
print(" tokens Get available trading tokens")
print(" chain-info Get portfolio chain info")
sys.exit(1)
action = sys.argv[1]
portfolio_id = env.get("PORTFOLIO_ID")
token = get_token()
if action == "portfolio-state":
if not portfolio_id:
print("ERROR: PORTFOLIO_ID must be set")
sys.exit(1)
result = get_portfolio(token, int(portfolio_id))
print(json.dumps(result, indent=2, default=str))
elif action == "execute":
if not portfolio_id:
print("ERROR: PORTFOLIO_ID must be set")
sys.exit(1)
recommendation_id = sys.argv[2] if len(sys.argv) > 2 else None
if not recommendation_id:
print("ERROR: recommendation_id required")
sys.exit(1)
result = execute_recommendation(
token, int(portfolio_id), int(recommendation_id),
execute_onchain=False,
user_master_address=env.get("WALLET_ADDRESS")
)
print(json.dumps(result, indent=2, default=str))
elif action == "close-position":
if not portfolio_id:
print("ERROR: PORTFOLIO_ID must be set")
sys.exit(1)
symbol = sys.argv[2] if len(sys.argv) > 2 else None
if not symbol:
print("ERROR: symbol required (e.g., ETH)")
sys.exit(1)
result = close_position(token, int(portfolio_id), symbol,
user_master_address=env.get("WALLET_ADDRESS"))
print(json.dumps(result, indent=2, default=str))
elif action == "recommendations":
if not portfolio_id:
print("ERROR: PORTFOLIO_ID must be set")
sys.exit(1)
result = get_recommendations(token, int(portfolio_id))
print(json.dumps(result, indent=2, default=str))
elif action == "request-recommendation":
if not portfolio_id:
print("ERROR: PORTFOLIO_ID must be set")
sys.exit(1)
message = sys.argv[2] if len(sys.argv) > 2 else "Analyze portfolio tokens and generate recommendations based on the current market."
result = request_recommendation(token, int(portfolio_id), message)
print(json.dumps(result, indent=2, default=str))
elif action == "poll-recommendation":
ticket_id = sys.argv[2] if len(sys.argv) > 2 else None
if not ticket_id:
print("ERROR: ticket_id required")
sys.exit(1)
result = poll_recommendation(token, ticket_id)
print(json.dumps(result, indent=2, default=str))
elif action == "update-portfolio":
if not portfolio_id:
print("ERROR: PORTFOLIO_ID must be set")
sys.exit(1)
tokens_arg = None
name_arg = None
i = 2
while i < len(sys.argv):
if sys.argv[i] == "--tokens" and i + 1 < len(sys.argv):
tokens_arg = sys.argv[i + 1].split(",")
i += 2
elif sys.argv[i] == "--name" and i + 1 < len(sys.argv):
name_arg = sys.argv[i + 1]
i += 2
else:
i += 1
if not tokens_arg and not name_arg:
print("Usage: update-portfolio --tokens BTC,ETH,SOL [--name \"New Name\"]")
sys.exit(1)
result = update_portfolio(token, int(portfolio_id), name=name_arg, tokens_selected=tokens_arg)
print(json.dumps(result, indent=2, default=str))
elif action == "list-agents":
result = list_agents(token)
print(json.dumps(result, indent=2, default=str))
elif action == "get-agent":
agent_id = sys.argv[2] if len(sys.argv) > 2 else None
if not agent_id:
print("ERROR: agent_id required")
sys.exit(1)
result = get_agent(token, int(agent_id))
print(json.dumps(result, indent=2, default=str))
elif action == "list-portfolio-agents":
if not portfolio_id:
print("ERROR: PORTFOLIO_ID must be set")
sys.exit(1)
result = list_portfolio_agents(token, int(portfolio_id))
print(json.dumps(result, indent=2, default=str))
elif action == "assign-agent":
if not portfolio_id:
print("ERROR: PORTFOLIO_ID must be set")
sys.exit(1)
agent_id = sys.argv[2] if len(sys.argv) > 2 else None
if not agent_id:
print("ERROR: agent_id required")
sys.exit(1)
role = "primary"
if "--role" in sys.argv:
role_idx = sys.argv.index("--role")
if role_idx + 1 < len(sys.argv):
role = sys.argv[role_idx + 1]
result = assign_agent(token, int(portfolio_id), int(agent_id), role=role)
print(json.dumps(result, indent=2, default=str))
elif action == "conversation":
if not portfolio_id:
print("ERROR: PORTFOLIO_ID must be set")
sys.exit(1)
result = get_conversation(token, int(portfolio_id))
print(json.dumps(result, indent=2, default=str))
elif action == "clear-conversation":
if not portfolio_id:
print("ERROR: PORTFOLIO_ID must be set")
sys.exit(1)
result = delete_conversation(token, int(portfolio_id))
print(json.dumps(result, indent=2, default=str))
elif action == "user":
result = get_user(token)
print(json.dumps(result, indent=2, default=str))
elif action == "plans":
result = get_plans()
print(json.dumps(result, indent=2, default=str))
elif action == "tokens":
if not portfolio_id:
print("ERROR: PORTFOLIO_ID must be set")
sys.exit(1)
result = get_portfolio_tokens(token, int(portfolio_id))
print(json.dumps(result, indent=2, default=str))
elif action == "chain-info":
if not portfolio_id:
print("ERROR: PORTFOLIO_ID must be set")
sys.exit(1)
result = get_portfolio_chain_info(token, int(portfolio_id))
print(json.dumps(result, indent=2, default=str))
else:
print(f"Unknown action: {action}")
sys.exit(1)
if __name__ == "__main__":
main()
import sys, os, time, json, argparse, importlib.util, pathlib, subprocess
import requests
from katbot_client import get_token, request_recommendation, poll_recommendation, execute_recommendation, get_portfolio, get_config
from token_selector import get_top_tokens
_TOOLS_DIR = str(pathlib.Path(__file__).parent.resolve())
def get_bmi():
"""Fetch BMI by running btc_momentum.py --json with the current interpreter."""
script_path = os.path.join(_TOOLS_DIR, "btc_momentum.py")
result = subprocess.run(
[sys.executable, script_path, "--json"],
capture_output=True, text=True,
env={**os.environ, "PYTHONPATH": _TOOLS_DIR},
)
if result.returncode != 0:
raise RuntimeError(f"btc_momentum.py failed: {result.stderr.strip()}")
data = json.loads(result.stdout)
return {"bmi": data["bmi"], "signal": data["signal"], "btc_24h_pct": data["btc_24h_pct"]}
def main():
parser = argparse.ArgumentParser()
parser.add_argument('--portfolio-id', type=int, help="Optional override for portfolio ID from config")
parser.add_argument('--top', type=int, default=5)
args = parser.parse_args()
token = get_token()
# Load portfolio ID from config if not provided
portfolio_id = args.portfolio_id
if not portfolio_id:
config = get_config()
portfolio_id = config.get("portfolio_id")
if not portfolio_id:
print("Error: --portfolio-id is required or must be set in katbot_config.json via onboarding")
sys.exit(1)
bmi_data = get_bmi()
bullish = bmi_data['bmi'] >= 15
bearish = bmi_data['bmi'] <= -15
if not bullish and not bearish:
print("Market is neutral. Skipping.")
return
tokens = get_top_tokens(args.top, bearish)
symbols = [t['symbol'] for t in tokens]
msg = f"Market is {'bullish' if bullish else 'bearish'}. Tokens: {symbols}. Get recommendation."
ticket = request_recommendation(token, portfolio_id, msg)
result = poll_recommendation(token, ticket['ticket_id'])
print(json.dumps(result, indent=2))
if __name__ == "__main__":
main()
import argparse, requests
HL_TOKENS = {
'BTC': 'bitcoin', 'ETH': 'ethereum', 'SOL': 'solana',
'ARB': 'arbitrum', 'HYPE': 'hyperliquid', 'AVAX': 'avalanche-2',
'LINK': 'chainlink', 'OP': 'optimism', 'INJ': 'injective-protocol',
'SUI': 'sui', 'APT': 'aptos', 'TIA': 'celestia',
'DOGE': 'dogecoin', 'ADA': 'cardano', 'DOT': 'polkadot',
'NEAR': 'near', 'FTM': 'fantom', 'ATOM': 'cosmos',
'LTC': 'litecoin', 'BCH': 'bitcoin-cash', 'UNI': 'uniswap',
'AAVE': 'aave', 'MKR': 'maker', 'CRV': 'curve-dao-token',
'WIF': 'dogwifcoin', 'PEPE': 'pepe', 'BONK': 'bonk',
'XRP': 'ripple', 'BNB': 'binancecoin', 'MATIC': 'matic-network',
'RUNE': 'thorchain', 'IMX': 'immutable-x',
'STX': 'blockstack', 'ALGO': 'algorand',
'TAO': 'bittensor', 'SEI': 'sei-network',
}
def get_top_tokens(top: int = 5, bearish: bool = False):
ids_str = ','.join(HL_TOKENS.values())
r = requests.get(
f'https://api.coingecko.com/api/v3/simple/price?ids={ids_str}&vs_currencies=usd&include_24hr_change=true',
timeout=15)
r.raise_for_status()
data = r.json()
id_to_sym = {v: k for k, v in HL_TOKENS.items()}
results = [
{'symbol': id_to_sym[cg_id], 'price': vals.get('usd', 0), 'pct_24h': vals.get('usd_24h_change') or 0}
for cg_id, vals in data.items() if cg_id in id_to_sym
]
return sorted(results, key=lambda x: x['pct_24h'], reverse=not bearish)[:top]
if __name__ == '__main__':
parser = argparse.ArgumentParser()
parser.add_argument('--top', type=int, default=5)
parser.add_argument('--direction', choices=['bullish', 'bearish'], default='bullish')
args = parser.parse_args()
tokens = get_top_tokens(args.top, args.direction == 'bearish')
print(json.dumps(tokens, indent=2))
version = 1
revision = 3
requires-python = ">=3.13"
[[package]]
name = "katbotai-hyperliquid-trader"
version = "0.1.0"
source = { virtual = "." }