244 lines
6.8 KiB
Go
244 lines
6.8 KiB
Go
package output
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"os"
|
|
"strings"
|
|
"time"
|
|
|
|
"gitea.konchin.com/service/stonks/internal/config"
|
|
"gitea.konchin.com/service/stonks/internal/report"
|
|
)
|
|
|
|
func init() {
|
|
Register("discord", newDiscordOutput)
|
|
}
|
|
|
|
// discordMessageLimit is Discord's max character count for a single
|
|
// webhook message's `content` field.
|
|
const discordMessageLimit = 2000
|
|
|
|
// discordParams is the plugin-specific config for the discord output.
|
|
type discordParams struct {
|
|
// WebhookEnvVar names the environment variable holding the Discord
|
|
// webhook URL. Defaults to DISCORD_WEBHOOK_URL.
|
|
WebhookEnvVar string `yaml:"webhook_env_var"`
|
|
}
|
|
|
|
// discordOutput posts the report to a Discord incoming webhook, splitting it
|
|
// into multiple messages if it exceeds Discord's 2000-character limit.
|
|
type discordOutput struct {
|
|
webhookURL string
|
|
client *http.Client
|
|
}
|
|
|
|
func newDiscordOutput(cfg config.PluginConfig) (Output, error) {
|
|
params := discordParams{WebhookEnvVar: "DISCORD_WEBHOOK_URL"}
|
|
if err := cfg.DecodeParams(¶ms); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
url := os.Getenv(params.WebhookEnvVar)
|
|
if url == "" {
|
|
return nil, fmt.Errorf("discord: environment variable %q is not set", params.WebhookEnvVar)
|
|
}
|
|
|
|
return &discordOutput{
|
|
webhookURL: url,
|
|
client: &http.Client{Timeout: 15 * time.Second},
|
|
}, nil
|
|
}
|
|
|
|
func (d *discordOutput) Name() string { return "discord" }
|
|
|
|
func (d *discordOutput) Send(ctx context.Context, rep report.Report) error {
|
|
for i, chunk := range splitForDiscord(rep.Text, discordMessageLimit) {
|
|
if err := d.post(ctx, chunk); err != nil {
|
|
return fmt.Errorf("discord: send chunk %d: %w", i+1, err)
|
|
}
|
|
}
|
|
|
|
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)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (d *discordOutput) post(ctx context.Context, content string) error {
|
|
return d.postJSON(ctx, map[string]any{"content": content})
|
|
}
|
|
|
|
func (d *discordOutput) postEmbeds(ctx context.Context, embeds []map[string]any) error {
|
|
return d.postJSON(ctx, map[string]any{"embeds": embeds})
|
|
}
|
|
|
|
func (d *discordOutput) postJSON(ctx context.Context, payload map[string]any) error {
|
|
body, err := json.Marshal(payload)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, d.webhookURL, bytes.NewReader(body))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
req.Header.Set("Content-Type", "application/json")
|
|
|
|
resp, err := d.client.Do(req)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode >= 300 {
|
|
respBody, _ := io.ReadAll(resp.Body)
|
|
return fmt.Errorf("unexpected status %d: %s", resp.StatusCode, string(respBody))
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Discord embed colors (decimal RGB).
|
|
const (
|
|
colorBuy = 0x57F287 // green
|
|
colorSell = 0xED4245 // red
|
|
)
|
|
|
|
// discordDescriptionLimit is Discord's max character count for a single
|
|
// embed's `description`.
|
|
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 — 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 {
|
|
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 {
|
|
buyLines = append(buyLines, line)
|
|
}
|
|
}
|
|
|
|
var embeds []map[string]any
|
|
if len(buyLines) > 0 {
|
|
embeds = append(embeds, recommendationEmbed("🟢 Buy", colorBuy, buyLines))
|
|
}
|
|
if len(sellLines) > 0 {
|
|
embeds = append(embeds, recommendationEmbed("🔴 Sell", colorSell, sellLines))
|
|
}
|
|
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,
|
|
"color": color,
|
|
"description": truncateForDescription(strings.Join(lines, "\n")),
|
|
}
|
|
}
|
|
|
|
func truncateForDescription(s string) string {
|
|
if len(s) <= discordDescriptionLimit {
|
|
return s
|
|
}
|
|
return s[:discordDescriptionLimit-3] + "..."
|
|
}
|
|
|
|
// splitForDiscord breaks text into chunks of at most limit characters,
|
|
// preferring to split on paragraph/line boundaries so Markdown formatting
|
|
// stays intact across messages.
|
|
func splitForDiscord(text string, limit int) []string {
|
|
if len(text) <= limit {
|
|
return []string{text}
|
|
}
|
|
|
|
var chunks []string
|
|
var current strings.Builder
|
|
|
|
flush := func() {
|
|
if current.Len() > 0 {
|
|
chunks = append(chunks, strings.TrimSpace(current.String()))
|
|
current.Reset()
|
|
}
|
|
}
|
|
|
|
for _, line := range strings.Split(text, "\n") {
|
|
// A single line longer than the limit must be hard-split.
|
|
for len(line) > limit {
|
|
if current.Len() > 0 {
|
|
flush()
|
|
}
|
|
chunks = append(chunks, line[:limit])
|
|
line = line[limit:]
|
|
}
|
|
if current.Len()+len(line)+1 > limit {
|
|
flush()
|
|
}
|
|
current.WriteString(line)
|
|
current.WriteString("\n")
|
|
}
|
|
flush()
|
|
|
|
return chunks
|
|
}
|