feat: add portfolio
This commit is contained in:
11 files changed
+669
-38
No files matched your search
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user