Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 11 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@

<p align="center">
<strong>Xueqiu stock data for AI agents</strong><br/>
<sub>30 commands | A-shares, HK, US stocks & funds | JSON output</sub>
<sub>32 commands | A-shares, HK, US stocks, funds & portfolios | JSON output</sub>
</p>

<p align="center">
Expand All @@ -17,7 +17,7 @@
<a href="https://www.npmjs.com/package/snowball-cli"><img src="https://img.shields.io/npm/v/snowball-cli?color=cb3837&logo=npm&logoColor=white" alt="npm" /></a>
<a href="https://github.com/baixianger/snowball-cli/blob/main/LICENSE"><img src="https://img.shields.io/badge/license-MIT-blue" alt="license" /></a>
<a href="https://bun.sh"><img src="https://img.shields.io/badge/runtime-Bun-f472b6?logo=bun&logoColor=white" alt="Bun" /></a>
<img src="https://img.shields.io/badge/commands-30-orange" alt="commands" />
<img src="https://img.shields.io/badge/commands-32-orange" alt="commands" />
<img src="https://img.shields.io/badge/data-Xueqiu%20%E9%9B%AA%E7%90%83-1DA1F2" alt="data source" />
</p>

Expand Down Expand Up @@ -123,6 +123,15 @@ Set `CHROME_PATH` or use `--chrome <path>` for custom Chrome/Chromium location.
| `snowball screen [SH\|HK\|US]` | Stock screener | * |
| `snowball fund <code> [--nav\|--growth]` | Fund detail / NAV / growth | |

### Portfolio (组合)

| Command | Description |
|---|---|
| `snowball portfolio-list` | List your portfolios |
| `snowball portfolio <id>` | Portfolio holdings (default) |
| `snowball portfolio <id> --performance [--period 1m\|3m\|6m\|1y\|all]` | Net value history |
| `snowball portfolio <id> --rebalance` | Rebalancing history |

## Symbol format

```
Expand Down
10 changes: 10 additions & 0 deletions SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,16 @@ snowball screen SH # 选股器
snowball fund 110011 --nav # 基金净值(无需登录)
```

### 组合(Portfolio)

```bash
snowball portfolio-list # 列出你的组合
snowball portfolio <组合ID> # 组合持仓
snowball portfolio <组合ID> --performance # 组合净值走势
snowball portfolio <组合ID> --performance --period 1y # 指定时间段
snowball portfolio <组合ID> --rebalance # 调仓历史
```

## Agent 工作流

### 早盘简报
Expand Down
23 changes: 23 additions & 0 deletions index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -506,6 +506,29 @@ const commands: Record<string, Record<string, Command>> = {
},
},
},

// ── Portfolio ────────────────────────────────────────────────
"Portfolio": {
"portfolio-list": {
usage: "portfolio-list",
desc: "List your portfolios (组合列表)",
run: async () => out(await api.portfolios()),
},
portfolio: {
usage: "portfolio <id> [--detail] [--performance] [--rebalance]",
desc: "Portfolio holdings / performance / rebalance history",
run: async () => {
const id = requireArg(1, "Usage: snowball portfolio <id> [--detail] [--performance] [--rebalance]");
if (hasFlag("performance")) {
out(await api.portfolioPerformance(id, (flag("period") ?? "all") as any));
} else if (hasFlag("rebalance")) {
out(await api.portfolioRebalance(id));
} else {
out(await api.portfolioDetail(id));
}
},
},
},
};

// ═══════════════════════════════════════════════════════════════
Expand Down
79 changes: 68 additions & 11 deletions lib/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,27 +21,36 @@ const HEADERS = {
"X-Requested-With": "XMLHttpRequest",
};

async function request(path: string, params: Record<string, string | number> = {}, base = STOCK_URL): Promise<any> {
async function request(path: string, params: Record<string, string | number> = {}, base = STOCK_URL, retries = 2): Promise<any> {
const cookie = getCookie();
const url = new URL(path, base);
for (const [k, v] of Object.entries(params)) {
url.searchParams.set(k, String(v));
}

const res = await fetch(url.toString(), {
headers: { ...HEADERS, Cookie: cookie },
});
for (let attempt = 0; attempt <= retries; attempt++) {
const res = await fetch(url.toString(), {
headers: { ...HEADERS, Cookie: cookie },
});

if (!res.ok) {
// Retry on 429 (rate limit) and 5xx (server error)
if ((res.status === 429 || res.status >= 500) && attempt < retries) {
await new Promise(r => setTimeout(r, 1000 * (attempt + 1)));
continue;
}
throw new Error(`HTTP ${res.status}: ${await res.text()}`);
}

if (!res.ok) {
throw new Error(`HTTP ${res.status}: ${await res.text()}`);
}
const data = await res.json();
if (data.error_code && data.error_code !== 0) {
throw new Error(`API error ${data.error_code}: ${data.error_description}`);
}

const data = await res.json();
if (data.error_code) {
throw new Error(`API error ${data.error_code}: ${data.error_description}`);
return data;
}

return data;
throw new Error("Request failed after retries");
}

/** Request without token (for public endpoints like quotec) */
Expand Down Expand Up @@ -488,3 +497,51 @@ export async function fundGrowth(code: string, period = "ty"): Promise<any> {
const data = await requestPublic(`/djapi/fund/growth/${code}`, { day: period }, DANJUAN_URL);
return data.data;
}

// ═══════════════════════════════════════════════════════════════
// PORTFOLIO (组合)
// ═══════════════════════════════════════════════════════════════

/** List user's portfolios (组合列表) */
export async function portfolios(): Promise<any> {
const data = await request("/v5/stock/portfolio/list.json", { system: "true" });
return data.data?.items ?? data.data;
}

/** Portfolio detail with current holdings (组合详情 + 持仓) */
export async function portfolioDetail(portfolioId: string): Promise<any> {
const data = await request("/v5/stock/portfolio/stock_list.json", {
portfolio_id: portfolioId,
size: 100,
});
return data.data;
}

/** Portfolio performance / net value history (组合净值走势) */
export async function portfolioPerformance(
portfolioId: string,
period: "1m" | "3m" | "6m" | "1y" | "all" = "all",
): Promise<any> {
const periodMap: Record<string, string> = {
"1m": "1month", "3m": "3month", "6m": "6month", "1y": "1year", "all": "all",
};
const data = await request("/v5/stock/portfolio/performance.json", {
portfolio_id: portfolioId,
period: periodMap[period] ?? period,
});
return data.data;
}

/** Portfolio rebalancing history (组合调仓历史) */
export async function portfolioRebalance(
portfolioId: string,
page = 1,
size = 20,
): Promise<any> {
const data = await request("/v5/stock/portfolio/rebalance/history.json", {
portfolio_id: portfolioId,
page,
size,
});
return data.data;
}