Skill: liji3597/solana-portfolio (version 0.1.0)

--- name: solana-portfolio description: 管理 Solana 投资组合 — 追踪多钱包余额、代币分布和资产估值。当用户想查看持仓、添加钱包或了解资产状况时触发。 version: 1.0.0 metadata: openclaw: requires: env: ["SOLANA_NETWORK"] bins: ["node"] emoji: "💼" --- # Solana 投资组合管理 ## When to Use - 用户想查看投资组合、余额、持仓 - 用户想添加或移除 Solana 钱包地址 - 用户想了解资产分布和总价值 - 用户提到"组合"、"余额"、"持仓"、"钱包" ## Workflow ### 用户想查看投资组合 1. 获取用户的 Telegram User ID 2. 运行 `node skills/solana-portfolio/scripts/get-portfolio.js <user_id>` 3. **如果输出包含 "没有连接的钱包"**： - 友善地告知用户还没有连接钱包 - 主动引导："请发送你的 Solana 钱包地址，我帮你添加" 4. **如果成功返回数据**： - 用友善的语气展示结果（总价值、持仓明细） - 主动提问："需要设置价格警报吗？" 或 "要看看更详细的行情吗？" ### 用户想添加钱包 1. 如果用户没有提供地址，主动询问："请发送你的 Solana 钱包地址" 2. 验证地址格式（32-44 个字符的 base58 编码） 3. 运行 `node skills/solana-portfolio/scripts/add-wallet.js <user_id> <address>` 4. 确认添加成功后，建议用户查看投资组合 ### 用户想列出已连接的钱包 1. 运行 `node skills/solana-portfolio/scripts/list-wallets.js <user_id>` 2. 如果没有钱包，引导添加 ### 用户想移除钱包 1. 如果用户没有指定地址，先列出所有钱包让用户选择 2. 确认用户的选择："确定要移除这个钱包吗？" 3. 运行 `node skills/solana-portfolio/scripts/remove-wallet.js <user_id> <address>` ## Guardrails - **只读操作** — 查看余额不需要私钥，绝不要求用户提供 - **最多 5 个钱包** — 超出时友善提示限制 - **地址验证** — 添加前验证格式，无效时提示正确格式而非报错 - **不评价持仓** — 不说"你的 XXX 持仓太多/太少"，只展示数据 ## Available Scripts | 脚本 | 用途 | 参数 | |------|------|------| | `get-portfolio.js` | 查看组合 | `<user_id> [--lang en]` | | `add-wallet.js` | 添加钱包 | `<user_id> <address>` | | `list-wallets.js` | 列出钱包 | `<user_id>` | | `remove-wallet.js` | 移除钱包 | `<user_id> <address>` |

path: scripts/add-wallet.js
content: #!/usr/bin/env node
/**
 * Add a Solana wallet to a user's account
 * Usage: node add-wallet.js <telegram_user_id> <solana_address> [--lang zh|en]
 */
const path = require('path');
const sharedDir = path.resolve(__dirname, '..', '..', '..', 'shared');

const { findOrCreateUser, addWallet, getUserWallets } = require(path.join(sharedDir, 'services'));
const { formatError } = require(path.join(sharedDir, 'errors'));
const { isValidAddress } = require(path.join(sharedDir, 'wallet'));

const telegramId = process.argv[2];
const address = process.argv[3];
const lang = process.argv.includes('--lang') ? process.argv[process.argv.indexOf('--lang') + 1] : 'zh';

const missing = [];
if (!telegramId) missing.push('用户ID / User ID');
if (!address) missing.push('钱包地址 / Wallet address');
if (missing.length > 0) {
    console.log(JSON.stringify({
        code: 'MISSING_PARAMS',
        missing,
        message: `我需要更多信息才能继续：${missing.join('、')} / I need more information to proceed: ${missing.join(', ')}`
    }, null, 2));
    process.exit(0);
}

if (!isValidAddress(address)) {
    console.log(`❌ ${formatError('MISSING_REQUIRED_PARAMS', lang)}: ${lang === 'zh' ? '无效的 Solana 地址' : 'Invalid Solana address'}`);
    process.exit(1);
}

if (typeof findOrCreateUser !== 'function' || typeof getUserWallets !== 'function' || typeof addWallet !== 'function') {
    console.log(`❌ ${formatError('INTERNAL_ERROR', lang)}`);
    process.exit(1);
}

try {
    const user = findOrCreateUser(telegramId, '');
    const wallets = getUserWallets(user.id);

    if (wallets.length >= 5) {
        console.log(`❌ ${formatError('MISSING_REQUIRED_PARAMS', lang)}: ${lang === 'zh' ? '最多支持 5 个钱包' : 'Maximum 5 wallets allowed'}`);
        process.exit(1);
    }

    const added = addWallet(user.id, address);
    if (added) {
        console.log(`✅ ${lang === 'zh' ? '钱包已添加' : 'Wallet added'}: ${address.slice(0, 6)}...${address.slice(-4)}`);
        console.log(`${lang === 'zh' ? '当前共' : 'Total'} ${wallets.length + 1} ${lang === 'zh' ? '个钱包' : 'wallets'}`);
        process.exit(0);
    }

    console.log(`⚠️ ${lang === 'zh' ? '该钱包已存在' : 'Wallet already exists'}`);
    process.exit(0);
} catch (err) {
    console.log(`❌ ${formatError(err.code || 'INTERNAL_ERROR', lang)}`);
    process.exit(1);
}


path: scripts/get-pnl.js
content: #!/usr/bin/env node
/**
 * Get portfolio PnL summary for a user
 * Usage: node get-pnl.js <telegram_user_id> [--lang zh|en]
 */
const path = require('path');
const sharedDir = path.resolve(__dirname, '..', '..', '..', 'shared');

const {
    findOrCreateUser,
    getUserWallets,
    getPortfolioPnl,
} = require(path.join(sharedDir, 'services'));
const { getPortfolio } = require(path.join(sharedDir, 'tracker'));
const { formatError } = require(path.join(sharedDir, 'errors'));

const telegramId = process.argv[2];
const lang = process.argv.includes('--lang') ? process.argv[process.argv.indexOf('--lang') + 1] : 'zh';

if (!telegramId) {
    console.log(JSON.stringify({
        code: 'MISSING_PARAMS',
        missing: ['用户ID / User ID'],
        message: '我需要更多信息才能继续：用户ID / User ID / I need more information to proceed: User ID',
    }, null, 2));
    process.exit(0);
}

function formatAmount(value) {
    if (!Number.isFinite(value)) return '0';
    if (value >= 1) return value.toFixed(4).replace(/\.?0+$/, '');
    return value.toFixed(6).replace(/\.?0+$/, '');
}

function formatUsd(value) {
    const num = Number(value) || 0;
    return `$${Math.abs(num).toFixed(2)}`;
}

function formatSignedUsd(value) {
    const num = Number(value) || 0;
    if (num > 0) return `+${formatUsd(num)}`;
    if (num < 0) return `-${formatUsd(num)}`;
    return '$0.00';
}

function formatSignedPercent(value) {
    const num = Number(value) || 0;
    if (num > 0) return `+${num.toFixed(2)}%`;
    if (num < 0) return `${num.toFixed(2)}%`;
    return '0.00%';
}

async function main() {
    if (
        typeof findOrCreateUser !== 'function' ||
        typeof getUserWallets !== 'function' ||
        typeof getPortfolioPnl !== 'function'
    ) {
        console.log(`❌ ${formatError('INTERNAL_ERROR', lang)}`);
        process.exit(1);
    }

    const user = findOrCreateUser(telegramId, '');
    const wallets = getUserWallets(user.id);
    if (!wallets || wallets.length === 0) {
        console.log('📭 暂无钱包 / No wallets. Use add-wallet first.');
        process.exit(0);
    }

    const portfolio = await getPortfolio(user.id);
    if (!portfolio.holdings || portfolio.holdings.length === 0) {
        console.log('📭 暂无持仓 / No holdings yet.');
        process.exit(0);
    }

    const pnlRows = await getPortfolioPnl(user.id, portfolio.holdings);
    if (!pnlRows || pnlRows.length === 0) {
        console.log('📊 暂无盈亏数据 / No PnL data yet.');
        process.exit(0);
    }

    let totalUnrealized = 0;
    let totalRealized = 0;
    const lines = ['📊 盈亏统计 / PnL Summary', ''];

    for (const row of pnlRows) {
        const costBase = row.avgCost * row.amount;
        const pct = costBase > 0 ? (row.unrealizedPnl / costBase) * 100 : 0;
        totalUnrealized += row.unrealizedPnl;
        totalRealized += row.realizedPnl;

        lines.push(`${row.symbol}: ${formatAmount(row.amount)} tokens @ ${formatUsd(row.avgCost)} avg`);
        lines.push(`   未实现盈亏 / Unrealized: ${formatSignedUsd(row.unrealizedPnl)} (${formatSignedPercent(pct)})`);
        lines.push('');
    }

    lines.push(`总未实现盈亏 / Total Unrealized: ${formatSignedUsd(totalUnrealized)}`);
    lines.push(`总已实现盈亏 / Total Realized: ${formatSignedUsd(totalRealized)}`);
    console.log(lines.join('\n'));
}

main().catch((err) => {
    console.log(`❌ ${formatError(err.code || 'INTERNAL_ERROR', lang)}`);
    process.exit(1);
});


path: scripts/get-portfolio.js
content: #!/usr/bin/env node
/**
 * Get portfolio summary for a user
 * Usage: node get-portfolio.js <telegram_user_id> [--lang zh|en]
 */
const path = require('path');
const sharedDir = path.resolve(__dirname, '..', '..', '..', 'shared');

const { findOrCreateUser, getUserWallets } = require(path.join(sharedDir, 'services'));
const { formatError } = require(path.join(sharedDir, 'errors'));
const { getPortfolio } = require(path.join(sharedDir, 'tracker'));
const { formatPortfolioSummary } = require(path.join(sharedDir, 'formatter'));

const telegramId = process.argv[2];
const lang = process.argv.includes('--lang') ? process.argv[process.argv.indexOf('--lang') + 1] : 'zh';

if (!telegramId) {
    console.log(JSON.stringify({
        code: 'MISSING_PARAMS',
        missing: ['用户ID / User ID'],
        message: '我需要更多信息才能继续：用户ID / User ID / I need more information to proceed: User ID'
    }, null, 2));
    process.exit(0);
}

async function main() {
    if (typeof findOrCreateUser !== 'function' || typeof getUserWallets !== 'function') {
        console.log(`❌ ${formatError('INTERNAL_ERROR', lang)}`);
        process.exit(1);
    }

    const user = findOrCreateUser(telegramId, '');
    const wallets = getUserWallets(user.id);

    if (!wallets || wallets.length === 0) {
        console.log('📭 暂无钱包 / No wallets. Use add-wallet first.');
        process.exit(0);
    }

    const portfolio = await getPortfolio(user.id);
    console.log(formatPortfolioSummary(portfolio, lang));
}

main().catch((err) => {
    console.log(`❌ ${formatError(err.code || 'INTERNAL_ERROR', lang)}`);
    process.exit(1);
});


path: scripts/get-rebalance-suggestion.js
content: #!/usr/bin/env node
/**
 * Get smart rebalancing suggestions by comparing current allocation vs benchmark.
 * Usage: node get-rebalance-suggestion.js <telegram_user_id> [conservative|moderate|aggressive] [--lang zh|en]
 */
const path = require('path');
const sharedDir = path.resolve(__dirname, '..', '..', '..', 'shared');

const { findOrCreateUser, getUserWallets, generateRebalanceSuggestion } = require(path.join(sharedDir, 'services'));
const { getPortfolio } = require(path.join(sharedDir, 'tracker'));
const { formatUSD } = require(path.join(sharedDir, 'formatter'));
const { formatError } = require(path.join(sharedDir, 'errors'));

const telegramId = process.argv[2];
const benchmarkArg = process.argv[3];
const lang = process.argv.includes('--lang') ? process.argv[process.argv.indexOf('--lang') + 1] : 'zh';
const isZh = lang !== 'en';

if (!telegramId) {
    console.log(JSON.stringify({
        code: 'MISSING_PARAMS',
        missing: ['用户ID / User ID'],
        message: '我需要更多信息才能继续：用户ID / User ID / I need more information to proceed: User ID',
    }, null, 2));
    process.exit(0);
}

function pct(value) {
    const parsed = Number(value) || 0;
    return `${parsed.toFixed(2)}%`;
}

function benchmarkLabel(value) {
    const normalized = String(value || '').toLowerCase();
    if (normalized === 'conservative') return isZh ? '保守型 / Conservative' : 'Conservative / 保守型';
    if (normalized === 'aggressive') return isZh ? '进取型 / Aggressive' : 'Aggressive / 进取型';
    return isZh ? '稳健型 / Moderate' : 'Moderate / 稳健型';
}

async function main() {
    try {
        if (typeof findOrCreateUser !== 'function'
            || typeof getUserWallets !== 'function'
            || typeof generateRebalanceSuggestion !== 'function') {
            console.log(`❌ ${formatError('INTERNAL_ERROR', lang)}`);
            process.exit(1);
        }

        const user = findOrCreateUser(telegramId, '');
        const wallets = getUserWallets(user.id);
        if (!wallets || wallets.length === 0) {
            console.log(isZh ? '📭 暂无钱包，无法生成再平衡建议。' : '📭 No wallets found. Unable to generate rebalancing suggestions.');
            process.exit(0);
        }

        const portfolio = await getPortfolio(user.id);
        if (!portfolio || portfolio.isEmpty || portfolio.totalValue <= 0) {
            console.log(isZh ? '📭 投资组合为空，无法生成再平衡建议。' : '📭 Portfolio is empty. Unable to generate rebalancing suggestions.');
            process.exit(0);
        }

        const profile = benchmarkArg || user.risk_profile || 'moderate';
        const result = generateRebalanceSuggestion(portfolio, {
            profile,
            rebalanceThresholdPct: 5,
        });

        const actionable = result.suggestions || [];
        console.log(isZh ? '⚖️ 智能再平衡建议（教育用途）' : '⚖️ Smart Rebalancing Suggestions (Educational)');
        console.log(`${isZh ? '组合总值' : 'Portfolio Value'}: ${formatUSD(portfolio.totalValue)}`);
        console.log(`${isZh ? '参考基准' : 'Benchmark'}: ${benchmarkLabel(profile)}`);
        console.log(`${isZh ? '偏离分数' : 'Divergence Score'}: ${pct(result.divergenceScore)}`);
        console.log(`${isZh ? '稳定币占比' : 'Stablecoin Share'}: ${pct(result.stablecoinShare)}`);
        console.log('');

        if (actionable.length === 0) {
            console.log(isZh ? '✅ 当前仓位与基准接近，暂无明显再平衡动作。' : '✅ Your allocation is close to benchmark. No major rebalance action.');
        } else {
            console.log(isZh ? '📌 关注项（偏离 >= 5%）:' : '📌 Focus areas (drift >= 5%):');
            for (const item of actionable) {
                const action = item.action === 'reduce'
                    ? (isZh ? '可考虑降低配置' : 'consider reducing exposure')
                    : (isZh ? '可考虑提高配置' : 'consider increasing exposure');
                console.log(
                    `- ${item.symbol}: ${action} | ${isZh ? '当前' : 'Current'} ${pct(item.currentPct)} / ${isZh ? '基准' : 'Target'} ${pct(item.targetPct)} | ${isZh ? '偏离' : 'Drift'} ${pct(item.driftPct)} | ${isZh ? '名义金额' : 'Notional'} ${formatUSD(item.notionalUsd)}`
                );
            }
        }

        console.log('');
        console.log(isZh
            ? '⚠️ 提示：以上仅为模型化对比结果，不构成投资建议。'
            : '⚠️ Note: This is a model-based comparison and not investment advice.');
    } catch (err) {
        console.log(`❌ ${formatError(err.code || 'INTERNAL_ERROR', lang)}`);
        process.exit(1);
    }
}

main();


path: scripts/list-wallets.js
content: #!/usr/bin/env node
/**
 * List all wallets for a user
 * Usage: node list-wallets.js <telegram_user_id> [--lang zh|en]
 */
const path = require('path');
const sharedDir = path.resolve(__dirname, '..', '..', '..', 'shared');

const { findOrCreateUser, getUserWallets } = require(path.join(sharedDir, 'services'));
const { formatError } = require(path.join(sharedDir, 'errors'));

const telegramId = process.argv[2];
const lang = process.argv.includes('--lang') ? process.argv[process.argv.indexOf('--lang') + 1] : 'zh';

if (!telegramId) {
    console.log(JSON.stringify({
        code: 'MISSING_PARAMS',
        missing: ['用户ID / User ID'],
        message: '我需要更多信息才能继续：用户ID / User ID / I need more information to proceed: User ID'
    }, null, 2));
    process.exit(0);
}

if (typeof findOrCreateUser !== 'function' || typeof getUserWallets !== 'function') {
    console.log(`❌ ${formatError('INTERNAL_ERROR', lang)}`);
    process.exit(1);
}

try {
    const user = findOrCreateUser(telegramId, '');
    const wallets = getUserWallets(user.id);

    if (wallets.length === 0) {
        console.log('📭 暂无钱包 / No wallets');
        process.exit(0);
    }

    console.log(`👛 钱包列表 / Wallets (${wallets.length}):\n`);
    wallets.forEach((wallet, index) => {
        const shortAddress = `${wallet.address.slice(0, 6)}...${wallet.address.slice(-4)}`;
        console.log(`${index + 1}. ${shortAddress}${wallet.label ? ` (${wallet.label})` : ''}`);
    });
} catch (err) {
    console.log(`❌ ${formatError(err.code || 'INTERNAL_ERROR', lang)}`);
    process.exit(1);
}


path: scripts/remove-wallet.js
content: #!/usr/bin/env node
/**
 * Remove a wallet from a user's account
 * Usage: node remove-wallet.js <telegram_user_id> <solana_address> [--lang zh|en]
 */
const path = require('path');
const sharedDir = path.resolve(__dirname, '..', '..', '..', 'shared');

const { findOrCreateUser, removeWallet } = require(path.join(sharedDir, 'services'));
const { formatError } = require(path.join(sharedDir, 'errors'));
const { isValidAddress } = require(path.join(sharedDir, 'wallet'));

const telegramId = process.argv[2];
const address = process.argv[3];
const lang = process.argv.includes('--lang') ? process.argv[process.argv.indexOf('--lang') + 1] : 'zh';

const missing = [];
if (!telegramId) missing.push('用户ID / User ID');
if (!address) missing.push('钱包地址 / Wallet address');
if (missing.length > 0) {
    console.log(JSON.stringify({
        code: 'MISSING_PARAMS',
        missing,
        message: `我需要更多信息才能继续：${missing.join('、')} / I need more information to proceed: ${missing.join(', ')}`
    }, null, 2));
    process.exit(0);
}

if (!isValidAddress(address)) {
    console.log(`❌ ${formatError('MISSING_REQUIRED_PARAMS', lang)}: ${lang === 'zh' ? '无效的 Solana 地址' : 'Invalid Solana address'}`);
    process.exit(1);
}

if (typeof findOrCreateUser !== 'function' || typeof removeWallet !== 'function') {
    console.log(`❌ ${formatError('INTERNAL_ERROR', lang)}`);
    process.exit(1);
}

try {
    const user = findOrCreateUser(telegramId, '');
    const removed = removeWallet(user.id, address);

    if (removed) {
        console.log(`✅ ${lang === 'zh' ? '钱包已移除' : 'Wallet removed'}: ${address.slice(0, 6)}...${address.slice(-4)}`);
        process.exit(0);
    }

    console.log(`⚠️ ${lang === 'zh' ? '钱包不存在' : 'Wallet not found'}`);
    process.exit(0);
} catch (err) {
    console.log(`❌ ${formatError(err.code || 'INTERNAL_ERROR', lang)}`);
    process.exit(1);
}


Security scan verdicts — ClawScan: suspicious | static analysis: clean | VirusTotal: clean | SkillSpector: suspicious (MEDIUM)