Skill: alondotsh/fund-tracker (version 0.1.0)

--- name: fund-tracker version: "0.1.0" description: USE WHEN user types /fund-tracker or wants to check fund purchase availability, daily purchase quota, fee, and changes versus the previous run. Uses local AKShare-based tracker presets under <skill-root>. Do not use for stock or ETF market-price analysis. requires: bins: - python3 --- # Fund Purchase Status Tracker Track purchase status and quota changes for any fund using AKShare. ## Instructions When the user types `/fund-tracker`, follow these steps: ### Step 1: Determine preset - `/fund-tracker` → use default preset - `/fund-tracker <preset_name>` → use specified preset - `/fund-tracker list` → show available presets ### Step 2: Run the check script ```bash # Default preset python3 <skill-root>/tools/check.py # Specific preset python3 <skill-root>/tools/check.py <preset_name> # List presets python3 <skill-root>/tools/check.py list ``` ### Step 3: Present results to the user Show a table with the following columns: | 基金代码 | 基金简称 | 申购状态 | 日限额 | 手续费 | 变化 | The "变化" column highlights any change from the previous check: - **NEW** if this is the first check (no history) - Status changes like "暂停→限额" or "限额→开放" - Quota changes like "100→10000" - "-" if no change ### Step 4: Summarize actionable items After the table, add a one-line summary: - Which funds became available or more accessible - Which funds became restricted - Or "无变化" if nothing changed ## Managing Presets Presets are stored in `<skill-root>/tools/presets.json`. When the user wants to add a new preset, update the file at `<skill-root>/tools/presets.json`: ```json { "default": { "label": "汇丰QDII", "funds": ["006075", "050025", "016057", "016055", "016533", "016532"] }, "dingtou": { "label": "定投组合", "funds": ["110011", "519736", "007721"] } } ``` Each preset has its own runtime history file (`runtime/history_<name>.json`) for independent change tracking. ## Default Preset | 代码 | 简称 | |------|------| | 006075 | 博时标普500ETF联接C | | 050025 | 博时标普500ETF联接A | | 016055 | 博时纳斯达克100联接A | | 016057 | 博时纳斯达克100联接C | | 016532 | 嘉实纳斯达克100联接A | | 016533 | 嘉实纳斯达克100联接C | ## About Alon Public skill from Alon's real daily workflows. - GitHub: https://github.com/alondotsh - ClawHub: https://clawhub.ai/u/alondotsh - X: https://x.com/alondotsh - WeChat Official Account: alondotsh

path: README.md
content: # Fund Purchase Status Tracker

[中文说明](./README.zh.md)

Track fund purchase availability, daily purchase quotas, fees, and changes since the previous run with AKShare.

## Quick Install

Install the skill:

```bash
npx skills add alondotsh/alon-skills --skill fund-tracker
```

Then install the Python runtime dependencies inside the installed skill directory:

```bash
pip install -r tools/requirements.txt
```

## When to Use

Use this when you want to check whether a fund is open for purchase, limited by a daily quota, paused, or changed since the previous check.

This skill is designed for fund purchase status tracking. It is not for stock or ETF market-price analysis.

## What It Does

- Reads fund code presets from `tools/presets.json`.
- Fetches purchase status from AKShare's Eastmoney fund purchase interface.
- Compares the latest result with `runtime/history_<preset>.json`.
- Prints JSON containing fund code, name, purchase status, daily quota, fee, net value, and detected changes.

## Usage

```bash
# Default preset
python3 tools/check.py

# Specific preset
python3 tools/check.py dingtou

# List available presets
python3 tools/check.py list
```

## Preset Management

Presets live in `tools/presets.json`:

```json
{
  "default": {
    "label": "汇丰QDII",
    "funds": ["006075", "050025", "016057", "016055", "016533", "016532"]
  }
}
```

Each preset has an independent runtime history file under `runtime/`.

## Safety and Limits

- The script depends on AKShare and the upstream Eastmoney data source.
- Network or upstream schema failures are returned as JSON errors.
- Runtime history is local state and should not be committed.
- The output is informational and is not financial advice.

## Output

The command prints JSON with:

- `preset`
- `label`
- `missing_funds`
- `data`

Each `data` row includes fund code, short name, purchase status, daily quota, fee, net value, and change summary.

## Project Structure

| Path | Purpose |
|------|---------|
| `SKILL.md` | Agent skill instructions |
| `tools/check.py` | Fund status checker |
| `tools/presets.json` | Preset definitions |
| `tools/requirements.txt` | Python dependencies |
| `runtime/history_<preset>.json` | Generated local history |
| `tests/test_check.py` | Unit tests |

## About Alon

These public skills come from Alon's real daily workflows.

Alon is actively exploring the future of agent skills and is open to connecting with people who want to build useful skills.

- GitHub: https://github.com/alondotsh
- ClawHub: https://clawhub.ai/u/alondotsh
- X: https://x.com/alondotsh
- WeChat Official Account: alondotsh


path: README.zh.md
content: # 基金申购状态跟踪器

[English](./README.md)

使用 AKShare 跟踪基金申购状态、日限额、手续费，以及相对上次运行的变化。

## 快速安装

先安装 skill：

```bash
npx skills add alondotsh/alon-skills --skill fund-tracker
```

然后在安装后的 skill 目录中安装 Python 运行依赖：

```bash
pip install -r tools/requirements.txt
```

## 适用场景

适用于你想检查基金是否开放申购、是否限额、是否暂停申购，以及这些状态是否相对上次检查发生变化的场景。

这个 skill 面向基金申购状态跟踪，不用于股票或 ETF 市价分析。

## 功能说明

- 从 `tools/presets.json` 读取基金代码 preset。
- 通过 AKShare 的东方财富基金申购接口拉取状态。
- 与 `runtime/history_<preset>.json` 中的上次结果对比。
- 输出包含基金代码、简称、申购状态、日限额、手续费、净值和变化说明的 JSON。

## 使用方式

```bash
# 默认 preset
python3 tools/check.py

# 指定 preset
python3 tools/check.py dingtou

# 查看全部 preset
python3 tools/check.py list
```

## Preset 管理

基金列表通过 preset 组织，保存在 `tools/presets.json`：

```json
{
  "default": {
    "label": "汇丰QDII",
    "funds": ["006075", "050025", "016057", "016055", "016533", "016532"]
  }
}
```

每个 preset 都有独立的历史文件，保存在 `runtime/` 下。

## 安全边界

- 脚本依赖 AKShare 和上游东方财富数据源。
- 网络错误或上游字段变化会以 JSON error 返回。
- `runtime/` 是本地运行状态，不应提交。
- 输出仅供信息参考，不构成投资建议。

## 输出结果

命令输出 JSON，包含：

- `preset`
- `label`
- `missing_funds`
- `data`

每条 `data` 记录包含基金代码、简称、申购状态、日限额、手续费、最新净值和变化说明。

## 项目结构

| 路径 | 用途 |
|------|------|
| `SKILL.md` | Agent skill 指令 |
| `tools/check.py` | 基金状态检查脚本 |
| `tools/presets.json` | Preset 定义 |
| `tools/requirements.txt` | Python 依赖 |
| `runtime/history_<preset>.json` | 自动生成的本地历史 |
| `tests/test_check.py` | 单元测试 |

## 关于 Alon

这些公开 skills 来自 Alon 日常高频使用的真实工作流。

我长期看好 agent skills，也愿意和对 skill 制作感兴趣的人交流。

- GitHub：https://github.com/alondotsh
- ClawHub：https://clawhub.ai/u/alondotsh
- X：https://x.com/alondotsh
- 公众号：alondotsh


path: tests/test_check.py
content: """Unit tests for the fund tracker script."""

import json
import tempfile
import unittest
from contextlib import redirect_stdout
from io import StringIO
from pathlib import Path
import sys
from unittest.mock import patch

import pandas as pd

sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "tools"))

import check


class FundTrackerTests(unittest.TestCase):
    """Test local fund tracker behavior that does not require network access."""

    def test_detect_changes_handles_status_and_quota_edges(self) -> None:
        """Detect status changes and quota changes from or to missing values."""
        current = pd.DataFrame([
            {
                "基金代码": "000001",
                "基金简称": "Fund A",
                "申购状态": "开放申购",
                "日累计限定金额": 100.0,
            },
            {
                "基金代码": "000002",
                "基金简称": "Fund B",
                "申购状态": "暂停申购",
                "日累计限定金额": float("nan"),
            },
        ])
        history = {
            "000001": {"申购状态": "限大额", "日累计限定金额": None},
            "000002": {"申购状态": "暂停申购", "日累计限定金额": 100.0},
        }

        changes = check.detect_changes(current, history)

        self.assertEqual(changes, ["限大额→开放申购, 限额无→100", "限额100→无"])

    def test_build_current_state_normalizes_nan_values(self) -> None:
        """Convert pandas NaN values to JSON-safe None values."""
        current = pd.DataFrame([
            {
                "基金代码": "000001",
                "基金简称": "Fund A",
                "申购状态": "开放申购",
                "日累计限定金额": float("nan"),
                "手续费": float("nan"),
                "最新净值/万份收益": 1.23,
            },
        ])

        state = check.build_current_state(current)

        self.assertIsNone(state["000001"]["日累计限定金额"])
        self.assertIsNone(state["000001"]["手续费"])
        self.assertEqual(state["000001"]["最新净值"], 1.23)

    def test_run_returns_error_for_missing_preset(self) -> None:
        """Return a non-zero status when the requested preset does not exist."""
        with patch.object(check, "load_presets", return_value={"default": {"funds": []}}):
            with redirect_stdout(StringIO()):
                status = check.run("missing")

        self.assertEqual(status, 1)

    def test_missing_funds_keep_existing_history(self) -> None:
        """Keep previous history for funds missing from the latest data source."""
        current = pd.DataFrame([
            {
                "基金代码": "000001",
                "基金简称": "Fund A",
                "申购状态": "开放申购",
                "日累计限定金额": 100.0,
                "手续费": 0.0,
                "最新净值/万份收益": 1.23,
            },
        ])
        old_missing_state = {
            "基金简称": "Fund B",
            "申购状态": "暂停申购",
            "日累计限定金额": 100.0,
        }
        presets = {"default": {"label": "Default", "funds": ["000001", "000002"]}}
        history = {"000002": old_missing_state}

        with tempfile.TemporaryDirectory() as temp_dir:
            history_path = Path(temp_dir) / "history_default.json"
            with (
                patch.object(check, "load_presets", return_value=presets),
                patch.object(check, "load_history", return_value=history),
                patch.object(check, "fetch_status", return_value=current),
                patch.object(check, "get_history_path", return_value=str(history_path)),
            ):
                with redirect_stdout(StringIO()):
                    status = check.run("default")

            saved = json.loads(history_path.read_text(encoding="utf-8"))

        self.assertEqual(status, 0)
        self.assertEqual(saved["000002"], old_missing_state)


if __name__ == "__main__":
    unittest.main()


path: tools/check.py
content: #!/usr/bin/env python3
"""Fund purchase status tracker."""

import json
import sys
from datetime import datetime
from pathlib import Path

import akshare as ak
import pandas as pd

TOOL_DIR = Path(__file__).resolve().parent
SKILL_ROOT = TOOL_DIR.parent
PRESETS_FILE = TOOL_DIR / "presets.json"
RUNTIME_DIR = SKILL_ROOT / "runtime"
DEFAULT_PRESET = "default"

SOURCE_COLUMNS = [
    "基金代码",
    "基金简称",
    "申购状态",
    "日累计限定金额",
    "手续费",
    "最新净值/万份收益",
    "最新净值/万份收益-报告时间",
]


def load_presets() -> dict[str, dict[str, object]]:
    """Load preset definitions."""
    with PRESETS_FILE.open("r", encoding="utf-8") as f:
        return json.load(f)


def get_history_path(preset_name: str) -> Path:
    """Get history file path for a specific preset."""
    return RUNTIME_DIR / f"history_{preset_name}.json"


def load_history(preset_name: str) -> dict[str, dict[str, object]]:
    """Load previous check results for a preset."""
    path = get_history_path(preset_name)
    if path.exists():
        with path.open("r", encoding="utf-8") as f:
            return json.load(f)
    return {}


def save_history(preset_name: str, current: dict[str, dict[str, object]]) -> None:
    """Save current results as history for next comparison."""
    path = Path(get_history_path(preset_name))
    path.parent.mkdir(parents=True, exist_ok=True)
    with path.open("w", encoding="utf-8") as f:
        json.dump(current, f, ensure_ascii=False, indent=2)


def fetch_status(fund_codes: list[str]) -> pd.DataFrame:
    """Fetch purchase status for specified funds."""
    df = ak.fund_purchase_em()
    missing_columns = [column for column in SOURCE_COLUMNS if column not in df.columns]
    if missing_columns:
        raise ValueError(f"AKShare response missing columns: {missing_columns}")

    filtered_df = df[df["基金代码"].isin(fund_codes)][SOURCE_COLUMNS].copy()
    order = {code: index for index, code in enumerate(fund_codes)}
    filtered_df["__order"] = filtered_df["基金代码"].map(order)
    return filtered_df.sort_values("__order").drop(columns=["__order"]).reset_index(drop=True)


def normalize_number(value: object) -> float | None:
    """Convert numeric-like values to float and preserve missing values as None."""
    if value is None or pd.isna(value):
        return None
    return float(value)


def format_change_value(value: object) -> str:
    """Format a change value for compact display."""
    if value is None:
        return "无"
    if isinstance(value, float) and value.is_integer():
        return str(int(value))
    return str(value)


def build_current_state(current_df: pd.DataFrame) -> dict[str, dict[str, object]]:
    """Build normalized history state from current fund rows."""
    current_state = {}
    for _, row in current_df.iterrows():
        code = row["基金代码"]
        current_state[code] = {
            "基金简称": row["基金简称"],
            "申购状态": row["申购状态"],
            "日累计限定金额": normalize_number(row["日累计限定金额"]),
            "手续费": normalize_number(row["手续费"]),
            "最新净值": normalize_number(row["最新净值/万份收益"]),
            "检查时间": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
        }
    return current_state


def detect_changes(current_df: pd.DataFrame, history: dict[str, dict[str, object]]) -> list[str]:
    """Compare current status with history and return change descriptions."""
    changes = []
    for _, row in current_df.iterrows():
        code = row["基金代码"]
        status = row["申购状态"]
        quota = normalize_number(row["日累计限定金额"])
        if code not in history:
            changes.append("NEW")
        else:
            prev = history[code]
            parts = []
            if prev.get("申购状态") != status:
                parts.append(f"{prev.get('申购状态')}→{status}")
            prev_quota = normalize_number(prev.get("日累计限定金额"))
            if quota != prev_quota:
                parts.append(
                    f"限额{format_change_value(prev_quota)}→{format_change_value(quota)}"
                )
            changes.append(", ".join(parts) if parts else "-")
    return changes


def list_presets() -> int:
    """Print available presets as JSON."""
    presets = load_presets()
    output = []
    for name, preset in presets.items():
        funds = preset.get("funds", [])
        output.append({
            "name": name,
            "label": preset.get("label", name),
            "fund_count": len(funds) if isinstance(funds, list) else 0,
        })
    print(json.dumps({"presets": output}, ensure_ascii=False))
    return 0


def run(preset_name: str = DEFAULT_PRESET) -> int:
    """Run a fund status check for one preset."""
    presets = load_presets()
    if preset_name not in presets:
        print(json.dumps({
            "error": f"Preset '{preset_name}' not found",
            "available": list(presets.keys()),
        }, ensure_ascii=False))
        return 1

    preset = presets[preset_name]
    fund_codes = preset.get("funds")
    if not isinstance(fund_codes, list) or not all(isinstance(code, str) for code in fund_codes):
        print(json.dumps({
            "error": f"Preset '{preset_name}' has invalid funds list",
        }, ensure_ascii=False))
        return 1

    history = load_history(preset_name)
    current_df = fetch_status(fund_codes)
    changes = detect_changes(current_df, history)
    found_codes = set(current_df["基金代码"].tolist())
    missing_funds = [code for code in fund_codes if code not in found_codes]

    current_state = build_current_state(current_df)
    for code in missing_funds:
        if code in history:
            current_state[code] = history[code]

    save_history(preset_name, current_state)

    output = []
    for i, (_, row) in enumerate(current_df.iterrows()):
        output.append({
            "基金代码": row["基金代码"],
            "基金简称": row["基金简称"],
            "申购状态": row["申购状态"],
            "日限额": normalize_number(row["日累计限定金额"]),
            "手续费": normalize_number(row["手续费"]),
            "最新净值": normalize_number(row["最新净值/万份收益"]),
            "变化": changes[i],
        })
    print(json.dumps({
        "preset": preset_name,
        "label": preset["label"],
        "missing_funds": missing_funds,
        "data": output,
    }, ensure_ascii=False))
    return 0


def main(args: list[str]) -> int:
    """Parse command-line arguments and execute the requested action."""
    command = args[1] if len(args) > 1 else DEFAULT_PRESET
    try:
        if command == "list":
            return list_presets()
        return run(command)
    except Exception as exc:
        print(json.dumps({"error": str(exc)}, ensure_ascii=False))
        return 1


if __name__ == "__main__":
    raise SystemExit(main(sys.argv))


path: tools/presets.json
content: {
  "default": {
    "label": "汇丰QDII",
    "funds": ["006075", "050025", "016057", "016055", "016533", "016532"]
  }
}


path: tools/requirements.txt
content: akshare>=1.18
pandas


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