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
64 changes: 38 additions & 26 deletions src/tools/portfolio.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,34 +18,41 @@ async function lastClose(ticker: string): Promise<number | null> {

export async function shapeBrokerPortfolio(
snap: BrokerSnapshot,
priceFor: (ticker: string) => Promise<number | null>,
priceFor: (tickers: string[]) => Promise<Record<string, number | null>>,
) {
const positions = await Promise.all(
snap.positions.map(async (p) => {
const px = p.lastPrice ?? await priceFor(p.ticker);
const cost_basis_vnd = p.avgCost * p.quantity * 1000;
const market_value_vnd = p.marketValueVnd ?? (px != null ? px * p.quantity * 1000 : null);
const unrealized_pnl_vnd =
p.unrealizedPnlVnd ?? (px != null ? (px - p.avgCost) * p.quantity * 1000 : null);
const unrealized_pnl_pct =
p.unrealizedPnlPct ?? (px != null && p.avgCost > 0
? ((px - p.avgCost) / p.avgCost) * 100
: null);
return {
ticker: p.ticker,
quantity: p.quantity,
sub_account_id: p.subAccountId ?? null,
custody_code: p.custodyCode ?? null,
avg_cost_thousand_vnd: p.avgCost,
last_close_thousand_vnd: px,
cost_basis_vnd,
market_value_vnd,
unrealized_pnl_vnd,
unrealized_pnl_pct,
};
}),
const tickersNeedingPrice = Array.from(
new Set(snap.positions.filter((p) => p.lastPrice == null).map((p) => p.ticker)),
);

let fetchedPrices: Record<string, number | null> = {};
if (tickersNeedingPrice.length > 0) {
fetchedPrices = await priceFor(tickersNeedingPrice);
}

const positions = snap.positions.map((p) => {
const px = p.lastPrice ?? fetchedPrices[p.ticker] ?? null;
const cost_basis_vnd = p.avgCost * p.quantity * 1000;
const market_value_vnd = p.marketValueVnd ?? (px != null ? px * p.quantity * 1000 : null);
const unrealized_pnl_vnd =
p.unrealizedPnlVnd ?? (px != null ? (px - p.avgCost) * p.quantity * 1000 : null);
const unrealized_pnl_pct =
p.unrealizedPnlPct ?? (px != null && p.avgCost > 0
? ((px - p.avgCost) / p.avgCost) * 100
: null);
return {
ticker: p.ticker,
quantity: p.quantity,
sub_account_id: p.subAccountId ?? null,
custody_code: p.custodyCode ?? null,
avg_cost_thousand_vnd: p.avgCost,
last_close_thousand_vnd: px,
cost_basis_vnd,
market_value_vnd,
unrealized_pnl_vnd,
unrealized_pnl_pct,
};
});

const totals = positions.reduce(
(a, p) => {
a.cost_basis_vnd += p.cost_basis_vnd;
Expand Down Expand Up @@ -76,6 +83,11 @@ export const listPositionsTool = tool(
const ok = await requireBrokerConsent("portfolio_list", "read cash, positions, and exposure");
if (!ok) return asText({ ok: false, error: "user_declined" });
const snap = await getBroker().snapshot();
return asText(await shapeBrokerPortfolio(snap, lastClose));
return asText(
await shapeBrokerPortfolio(snap, async (tickers: string[]) => {
const prices = await Promise.all(tickers.map((t) => lastClose(t)));
return Object.fromEntries(tickers.map((t, i) => [t, prices[i] ?? null]));
})
);
},
);
13 changes: 13 additions & 0 deletions test-batch-dnse.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { request } from "undici";

async function fetchOhlcs(symbol: string) {
const url = `https://services.entrade.com.vn/chart-api/v2/ohlcs/stock?symbol=${encodeURIComponent(
symbol,
)}&resolution=1D&from=1700000000&to=1800000000`;
const { statusCode, body } = await request(url, {
method: "GET",
headers: { accept: "application/json" },
});
console.log(statusCode, await body.text());
}
fetchOhlcs("FPT,SSI");
11 changes: 11 additions & 0 deletions test-ssi-batch.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { request } from "undici";

async function fetchSsi(symbol: string) {
const url = `https://iboard-query.ssi.com.vn/stock/${encodeURIComponent(symbol)}`;
const { statusCode, body } = await request(url, {
method: "GET",
headers: { accept: "application/json" },
});
console.log(statusCode, await body.text());
}
fetchSsi("FPT,SSI");
2 changes: 1 addition & 1 deletion tests/portfolio.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ describe("portfolio units", () => {
cashVnd: 1_000_000,
positions: [{ ticker: "FPT", quantity: 100, avgCost: 25 }],
},
async () => 30,
async (tickers) => Object.fromEntries(tickers.map(t => [t, 30])),
);

expect(shaped.cash_vnd).toBe(1_000_000);
Expand Down
Loading