feat: add portfolio

This commit is contained in:
ytshih committed 2026-09-11 09:54:49 +08:00
1 parent ba63c5deb0
commit f09cf4d5c2
11 files changed
+669 -38

No files matched your search

+54 -4
View File
@@ -63,7 +63,9 @@ func (d *discordOutput) Send(ctx context.Context, rep report.Report) error {
}
}
if embeds := buildRecommendationEmbeds(rep.Recommendations); len(embeds) > 0 {
embeds := buildRecommendationEmbeds(rep.Recommendations)
embeds = append(embeds, buildPortfolioEmbed(rep.Portfolio))
if len(embeds) > 0 {
if err := d.postEmbeds(ctx, embeds); err != nil {
return fmt.Errorf("discord: send recommendation embeds: %w", err)
}
@@ -116,12 +118,17 @@ const discordDescriptionLimit = 4096
// buildRecommendationEmbeds renders the buy and sell recommendations as
// separate Discord embeds, one per non-empty list. If there are no
// recommendations at all, it returns nil and no message is sent — an empty
// buy/sell call isn't worth a Discord notification.
// recommendations at all, it returns nil — an empty buy/sell call gets no
// embed of its own, though the portfolio embed (see buildPortfolioEmbed)
// is still always sent alongside whatever this returns.
func buildRecommendationEmbeds(recs []report.Recommendation) []map[string]any {
var buyLines, sellLines []string
for _, r := range recs {
line := fmt.Sprintf("**%s** — %s", r.Symbol, r.Reason)
leverage := r.Leverage
if leverage <= 0 {
leverage = 1
}
line := fmt.Sprintf("**%s** — $%.2f at %.2gx — %s", r.Symbol, r.AmountUSD, leverage, r.Reason)
if r.Action == "sell" {
sellLines = append(sellLines, line)
} else {
@@ -139,6 +146,49 @@ func buildRecommendationEmbeds(recs []report.Recommendation) []map[string]any {
return embeds
}
// buildPortfolioEmbed renders the paper-trading portfolio's post-trade state
// — cash, equity, total profit, this run's executed trades, and any open
// positions — computed entirely by internal/portfolio, not Claude. Always
// returns an embed (never nil) so the profit update is visible every run.
func buildPortfolioEmbed(p report.PortfolioSnapshot) map[string]any {
lines := []string{
fmt.Sprintf("Cash: $%.2f — Equity: $%.2f", p.CashUSD, p.EquityUSD),
fmt.Sprintf("Total P&L: $%.2f (%.2f%%) since $%.2f start", p.TotalProfitUSD, p.TotalProfitPct, p.InitialBudgetUSD),
}
if len(p.Trades) == 0 {
lines = append(lines, "No trades executed this run.")
} else {
lines = append(lines, "", "**Trades this run:**")
for _, t := range p.Trades {
status := "skipped"
if t.Executed {
status = "executed"
}
line := fmt.Sprintf("%s %s @ %.4g (%s): %s", strings.ToUpper(t.Action), t.Symbol, t.Price, status, t.Note)
lines = append(lines, line)
}
}
if len(p.Positions) > 0 {
lines = append(lines, "", "**Open positions:**")
for _, pos := range p.Positions {
lines = append(lines, fmt.Sprintf("%s %s: %.6g units, entry %.4g, now %.4g, unrealized $%.2f",
pos.Symbol, pos.Side, pos.Quantity, pos.EntryPrice, pos.CurrentPrice, pos.UnrealizedPnL))
}
}
color := colorBuy
if p.TotalProfitUSD < 0 {
color = colorSell
}
return map[string]any{
"title": "💰 Portfolio",
"color": color,
"description": truncateForDescription(strings.Join(lines, "\n")),
}
}
func recommendationEmbed(title string, color int, lines []string) map[string]any {
return map[string]any{
"title": title,