From 4864574c562fe8835a096801d52cccaa02c3d4a2 Mon Sep 17 00:00:00 2001 From: jbergner Date: Sun, 31 May 2026 16:04:59 +0200 Subject: [PATCH] Charts-Update --- go.mod | 3 +- go.sum | 2 + trading_charts.go | 356 +++++++++++++++++++++++++++++++++++++++++++++ trading_discord.go | 31 +++- 4 files changed, 387 insertions(+), 5 deletions(-) create mode 100644 trading_charts.go diff --git a/go.mod b/go.mod index bb00886..0912668 100644 --- a/go.mod +++ b/go.mod @@ -1,10 +1,11 @@ module starauftrag-bot -go 1.22 +go 1.25.0 require ( github.com/bwmarrin/discordgo v0.28.1 github.com/glebarez/sqlite v1.11.0 + golang.org/x/image v0.41.0 ) require ( diff --git a/go.sum b/go.sum index 96ae8e5..fc95faa 100644 --- a/go.sum +++ b/go.sum @@ -23,6 +23,8 @@ github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94 github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b h1:7mWr3k41Qtv8XlltBkDkl8LoP3mpSgBW8BUoxtEdbXg= golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4= +golang.org/x/image v0.41.0 h1:8wS72eGJMJaBxK6okTzd4WaXumUlTVlb753MlsSvTCo= +golang.org/x/image v0.41.0/go.mod h1:uIc348UZMSvS5Z65CVZ7iDPaNobNFEPeJ4kbqTOszmA= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= diff --git a/trading_charts.go b/trading_charts.go new file mode 100644 index 0000000..150fc3e --- /dev/null +++ b/trading_charts.go @@ -0,0 +1,356 @@ +package main + +import ( + "bytes" + "fmt" + "image" + "image/color" + "image/draw" + "image/png" + "math" + "sort" + "strings" + + "golang.org/x/image/font" + "golang.org/x/image/font/basicfont" + "golang.org/x/image/math/fixed" +) + +var ( + chartBG = color.RGBA{0x10, 0x17, 0x2a, 0xff} + chartPanel = color.RGBA{0x11, 0x18, 0x27, 0xff} + chartText = color.RGBA{0xe5, 0xe7, 0xeb, 0xff} + chartMuted = color.RGBA{0x94, 0xa3, 0xb8, 0xff} + chartGrid = color.RGBA{0x33, 0x41, 0x55, 0xff} + chartCurrent = color.RGBA{0x38, 0xbd, 0xf8, 0xff} + chartPrevious = color.RGBA{0xf5, 0x9e, 0x0b, 0xff} + purposeColors = []color.RGBA{ + {0x38, 0xbd, 0xf8, 0xff}, {0x22, 0xc5, 0x5e, 0xff}, {0xf5, 0x9e, 0x0b, 0xff}, {0xef, 0x44, 0x44, 0xff}, + {0xa7, 0x8b, 0xfa, 0xff}, {0xf4, 0x72, 0xb6, 0xff}, + } +) + +type chartMetric struct { + Label string + Current float64 + Previous float64 + Format func(float64) string +} + +type piePart struct { + Label string + Value float64 + Color color.RGBA +} + +func createTradingCharts(cur, prev *TradingSummary) (comparisonPNG []byte, purposePNG []byte, err error) { + comparisonPNG, err = buildTradingComparisonChart(cur, prev) + if err != nil { + return nil, nil, err + } + purposePNG, err = buildTradingPurposePie(cur) + if err != nil { + return comparisonPNG, nil, err + } + return comparisonPNG, purposePNG, nil +} + +func buildTradingComparisonChart(cur, prev *TradingSummary) ([]byte, error) { + img := image.NewRGBA(image.Rect(0, 0, 1100, 700)) + draw.Draw(img, img.Bounds(), &image.Uniform{chartBG}, image.Point{}, draw.Src) + fillRect(img, image.Rect(24, 24, 1076, 676), chartPanel) + text(img, 48, 62, "Trading-Vergleich zum Vormonat", chartText, true) + text(img, 48, 88, fmt.Sprintf("%02d/%d vs. %02d/%d", cur.Month, cur.Year, prev.Month, prev.Year), chartMuted, false) + legendX := 760 + fillRect(img, image.Rect(legendX, 46, legendX+18, 64), chartCurrent) + text(img, legendX+26, 60, fmt.Sprintf("Aktuell %02d/%d", cur.Month, cur.Year), chartText, false) + fillRect(img, image.Rect(legendX, 72, legendX+18, 90), chartPrevious) + text(img, legendX+26, 86, fmt.Sprintf("Vormonat %02d/%d", prev.Month, prev.Year), chartText, false) + + metrics := []chartMetric{ + {Label: "Gewinn", Current: float64(cur.GrossProfit), Previous: float64(prev.GrossProfit), Format: func(v float64) string { return formatAUEC(int64(math.Round(v))) }}, + {Label: "Orga-Anteil", Current: float64(cur.OrgShare), Previous: float64(prev.OrgShare), Format: func(v float64) string { return formatAUEC(int64(math.Round(v))) }}, + {Label: "SCU", Current: cur.TotalSCU, Previous: prev.TotalSCU, Format: func(v float64) string { return formatAmount(v) }}, + {Label: "Kosten", Current: float64(cur.TotalCosts), Previous: float64(prev.TotalCosts), Format: func(v float64) string { return formatAUEC(int64(math.Round(v))) }}, + } + + cardW, cardH := 490, 240 + positions := []image.Point{{50, 120}, {560, 120}, {50, 390}, {560, 390}} + for idx, m := range metrics { + pos := positions[idx] + drawMetricCard(img, pos.X, pos.Y, cardW, cardH, m) + } + return encodePNG(img) +} + +func drawMetricCard(img *image.RGBA, x, y, w, h int, m chartMetric) { + fillRect(img, image.Rect(x, y, x+w, y+h), color.RGBA{0x16, 0x21, 0x33, 0xff}) + strokeRect(img, image.Rect(x, y, x+w, y+h), chartGrid) + text(img, x+16, y+24, m.Label, chartText, true) + maxV := math.Max(math.Abs(m.Current), math.Abs(m.Previous)) + if maxV <= 0 { + maxV = 1 + } + chartBottom := y + h - 44 + chartTop := y + 54 + chartH := chartBottom - chartTop + barW := 90 + gap := 80 + bar1X := x + 100 + bar2X := bar1X + barW + gap + for i := 0; i <= 4; i++ { + gy := chartBottom - i*chartH/4 + line(img, x+24, gy, x+w-24, gy, chartGrid) + text(img, x+28, gy-4, m.Format(maxV*float64(i)/4), chartMuted, false) + } + curH := int((math.Abs(m.Current) / maxV) * float64(chartH-10)) + prevH := int((math.Abs(m.Previous) / maxV) * float64(chartH-10)) + fillRect(img, image.Rect(bar1X, chartBottom-curH, bar1X+barW, chartBottom), chartCurrent) + fillRect(img, image.Rect(bar2X, chartBottom-prevH, bar2X+barW, chartBottom), chartPrevious) + textCentered(img, bar1X+barW/2, chartBottom+20, "Aktuell", chartText, false) + textCentered(img, bar2X+barW/2, chartBottom+20, "Vormonat", chartText, false) + textCentered(img, bar1X+barW/2, chartBottom-curH-8, m.Format(m.Current), chartText, false) + textCentered(img, bar2X+barW/2, chartBottom-prevH-8, m.Format(m.Previous), chartText, false) + text(img, x+16, y+h-14, deltaText(m.Current, m.Previous, m.Format), chartMuted, false) +} + +func buildTradingPurposePie(cur *TradingSummary) ([]byte, error) { + img := image.NewRGBA(image.Rect(0, 0, 1100, 700)) + draw.Draw(img, img.Bounds(), &image.Uniform{chartBG}, image.Point{}, draw.Src) + fillRect(img, image.Rect(24, 24, 1076, 676), chartPanel) + text(img, 48, 62, "Verteilung nach Zweck", chartText, true) + subtitle := fmt.Sprintf("Monat %02d/%d – Anteil nach Gewinn", cur.Month, cur.Year) + + parts := []piePart{} + total := 0.0 + for idx, p := range cur.PurposeSummary { + v := float64(p.Profit) + if v < 0 { + v = 0 + } + if v <= 0 { + continue + } + parts = append(parts, piePart{Label: tradingPurposeLabel(p.Purpose), Value: v, Color: purposeColors[idx%len(purposeColors)]}) + total += v + } + if total <= 0 { + subtitle = fmt.Sprintf("Monat %02d/%d – Anteil nach Anzahl Runs", cur.Month, cur.Year) + parts = parts[:0] + total = 0 + for idx, p := range cur.PurposeSummary { + v := float64(p.Runs) + if v <= 0 { + continue + } + parts = append(parts, piePart{Label: tradingPurposeLabel(p.Purpose), Value: v, Color: purposeColors[idx%len(purposeColors)]}) + total += v + } + } + if total <= 0 { + parts = []piePart{{Label: "Keine Daten", Value: 1, Color: chartMuted}} + total = 1 + } + text(img, 48, 88, subtitle, chartMuted, false) + + centerX, centerY := 320, 380 + radius := 180 + start := -math.Pi / 2 + for _, p := range parts { + angle := 2 * math.Pi * (p.Value / total) + fillPieSlice(img, centerX, centerY, radius, start, start+angle, p.Color) + start += angle + } + fillCircle(img, centerX, centerY, 78, chartPanel) // donut hole + textCentered(img, centerX, centerY-6, "Gesamt", chartText, true) + textCentered(img, centerX, centerY+16, formatInt(int64(math.Round(total))), chartMuted, false) + + legendX := 600 + legendY := 150 + for idx, p := range parts { + y := legendY + idx*74 + fillRect(img, image.Rect(legendX, y, legendX+24, y+24), p.Color) + strokeRect(img, image.Rect(legendX, y, legendX+24, y+24), chartGrid) + text(img, legendX+36, y+18, p.Label, chartText, true) + pct := 0.0 + if total > 0 { + pct = (p.Value / total) * 100 + } + text(img, legendX+36, y+42, fmt.Sprintf("%s (%.1f%%)", formatInt(int64(math.Round(p.Value))), pct), chartMuted, false) + } + return encodePNG(img) +} + +func fillPieSlice(img *image.RGBA, cx, cy, r int, start, end float64, c color.RGBA) { + for y := cy - r; y <= cy+r; y++ { + for x := cx - r; x <= cx+r; x++ { + dx := float64(x - cx) + dy := float64(y - cy) + if dx*dx+dy*dy > float64(r*r) { + continue + } + a := math.Atan2(dy, dx) + if a < start { + a += 2 * math.Pi + } + adjEnd := end + for adjEnd < start { + adjEnd += 2 * math.Pi + } + if a >= start && a <= adjEnd { + img.Set(x, y, c) + } + } + } +} + +func fillCircle(img *image.RGBA, cx, cy, r int, c color.RGBA) { + for y := cy - r; y <= cy+r; y++ { + for x := cx - r; x <= cx+r; x++ { + dx := x - cx + dy := y - cy + if dx*dx+dy*dy <= r*r { + img.Set(x, y, c) + } + } + } +} + +func encodePNG(img image.Image) ([]byte, error) { + var buf bytes.Buffer + if err := png.Encode(&buf, img); err != nil { + return nil, err + } + return buf.Bytes(), nil +} + +func fillRect(img *image.RGBA, rect image.Rectangle, c color.RGBA) { + draw.Draw(img, rect, &image.Uniform{c}, image.Point{}, draw.Src) +} + +func strokeRect(img *image.RGBA, rect image.Rectangle, c color.RGBA) { + line(img, rect.Min.X, rect.Min.Y, rect.Max.X-1, rect.Min.Y, c) + line(img, rect.Min.X, rect.Max.Y-1, rect.Max.X-1, rect.Max.Y-1, c) + line(img, rect.Min.X, rect.Min.Y, rect.Min.X, rect.Max.Y-1, c) + line(img, rect.Max.X-1, rect.Min.Y, rect.Max.X-1, rect.Max.Y-1, c) +} + +func line(img *image.RGBA, x0, y0, x1, y1 int, c color.RGBA) { + dx := int(math.Abs(float64(x1 - x0))) + sx := -1 + if x0 < x1 { + sx = 1 + } + dy := -int(math.Abs(float64(y1 - y0))) + sy := -1 + if y0 < y1 { + sy = 1 + } + err := dx + dy + for { + img.Set(x0, y0, c) + if x0 == x1 && y0 == y1 { + break + } + e2 := 2 * err + if e2 >= dy { + err += dy + x0 += sx + } + if e2 <= dx { + err += dx + y0 += sy + } + } +} + +func text(img *image.RGBA, x, y int, label string, col color.RGBA, bold bool) { + face := basicfont.Face7x13 + d := &font.Drawer{Dst: img, Src: image.NewUniform(col), Face: face, Dot: fixed.P(x, y)} + if bold { + // simple faux bold + d.DrawString(label) + d.Dot = fixed.P(x+1, y) + } + d.DrawString(label) +} + +func textCentered(img *image.RGBA, cx, y int, label string, col color.RGBA, bold bool) { + w := font.MeasureString(basicfont.Face7x13, label).Round() + text(img, cx-w/2, y, label, col, bold) +} + +func deltaText(cur, prev float64, formatter func(float64) string) string { + diff := cur - prev + sign := "+" + if diff < 0 { + sign = "" + } + pctText := "n/a" + if math.Abs(prev) > 0.00001 { + pctText = fmt.Sprintf("%s%.1f%%", sign, (diff/prev)*100) + } + return fmt.Sprintf("Δ %s (%s)", formatter(diff), pctText) +} + +func comparisonSummaryText(cur, prev *TradingSummary) string { + lines := []string{ + compareLineMoney("Gewinn", cur.GrossProfit, prev.GrossProfit), + compareLineMoney("Orga-Anteil", cur.OrgShare, prev.OrgShare), + compareLineAmount("SCU", cur.TotalSCU, prev.TotalSCU), + compareLineMoney("Kosten", cur.TotalCosts, prev.TotalCosts), + } + return strings.Join(lines, "\n") +} + +func compareLineMoney(label string, cur, prev int64) string { + return fmt.Sprintf("%s: %s vs. %s (%s)", label, formatAUEC(cur), formatAUEC(prev), diffWithPercent(float64(cur), float64(prev), true)) +} + +func compareLineAmount(label string, cur, prev float64) string { + return fmt.Sprintf("%s: %s vs. %s (%s)", label, formatAmount(cur), formatAmount(prev), diffWithPercent(cur, prev, false)) +} + +func diffWithPercent(cur, prev float64, money bool) string { + diff := cur - prev + sign := "+" + if diff < 0 { + sign = "" + } + var diffValue string + if money { + diffValue = signPrefix(diff) + formatAUEC(int64(math.Round(math.Abs(diff)))) + } else { + diffValue = signPrefix(diff) + formatAmount(math.Abs(diff)) + } + if math.Abs(prev) <= 0.00001 { + return diffValue + ", kein Vergleich" + } + return fmt.Sprintf("%s, %s%.1f%%", diffValue, sign, (diff/prev)*100) +} + +func signPrefix(v float64) string { + if v > 0 { + return "+" + } + if v < 0 { + return "-" + } + return "±" +} + +func previousMonth(month, year int) (int, int) { + if month <= 1 { + return 12, year - 1 + } + return month - 1, year +} + +func topTraderBarChartData(sum *TradingSummary) []TradingTraderSummary { + items := append([]TradingTraderSummary(nil), sum.TopTraders...) + sort.Slice(items, func(i, j int) bool { return items[i].Profit > items[j].Profit }) + if len(items) > 3 { + items = items[:3] + } + return items +} diff --git a/trading_discord.go b/trading_discord.go index eb2e3de..aa84e95 100644 --- a/trading_discord.go +++ b/trading_discord.go @@ -1,6 +1,7 @@ package main import ( + "bytes" "fmt" "log" "math" @@ -176,20 +177,41 @@ func (b *Bot) cmdTradingReport(s *discordgo.Session, i *discordgo.InteractionCre b.replyEphemeral(s, i, "Für diesen Monat gibt es keine Trading-Einträge.") return } + prevMonth, prevYear := previousMonth(month, year) + prevSummary, err := b.store.SummarizeTradingRuns(prevMonth, prevYear) + if err != nil { + prevSummary = &TradingSummary{Month: prevMonth, Year: prevYear} + } + channelID := b.cfg.TradingReportChannelID if channelID == "" { channelID = b.cfg.InternalOrderChannelID } - _, err = s.ChannelMessageSendComplex(channelID, &discordgo.MessageSend{Embeds: []*discordgo.MessageEmbed{b.tradingReportEmbed(summary)}}) + + msg := &discordgo.MessageSend{Embeds: []*discordgo.MessageEmbed{b.tradingReportEmbed(summary, prevSummary)}} + if comparisonPNG, purposePNG, chartErr := createTradingCharts(summary, prevSummary); chartErr == nil { + msg.Files = []*discordgo.File{ + {Name: "trading_compare.png", Reader: bytes.NewReader(comparisonPNG), ContentType: "image/png"}, + {Name: "trading_purpose.png", Reader: bytes.NewReader(purposePNG), ContentType: "image/png"}, + } + msg.Embeds = append(msg.Embeds, + &discordgo.MessageEmbed{Title: "Vormonatsvergleich", Description: fmt.Sprintf("Vergleich %02d/%d zu %02d/%d", month, year, prevMonth, prevYear), Color: 0x3498db, Image: &discordgo.MessageEmbedImage{URL: "attachment://trading_compare.png"}}, + &discordgo.MessageEmbed{Title: "Verteilung nach Zweck", Description: fmt.Sprintf("Monat %02d/%d", month, year), Color: 0xf1c40f, Image: &discordgo.MessageEmbedImage{URL: "attachment://trading_purpose.png"}}, + ) + } else { + log.Println("trading charts failed:", chartErr) + } + + _, err = s.ChannelMessageSendComplex(channelID, msg) if err != nil { b.replyEphemeral(s, i, "Report konnte nicht gepostet werden: "+err.Error()) return } b.audit(fmt.Sprintf("Trading-Report %02d/%d gepostet durch %s", month, year, userString(i))) - b.replyEphemeral(s, i, fmt.Sprintf("Trading-Report %02d/%d wurde gepostet.", month, year)) + b.replyEphemeral(s, i, fmt.Sprintf("Trading-Report %02d/%d wurde gepostet. Vergleich zu %02d/%d:\n%s", month, year, prevMonth, prevYear, comparisonSummaryText(summary, prevSummary))) } -func (b *Bot) tradingReportEmbed(sum *TradingSummary) *discordgo.MessageEmbed { +func (b *Bot) tradingReportEmbed(sum, prev *TradingSummary) *discordgo.MessageEmbed { fields := []*discordgo.MessageEmbedField{ {Name: "Einträge", Value: fmt.Sprintf("%d", sum.Count), Inline: true}, {Name: "Gewinn gesamt", Value: formatAUEC(sum.GrossProfit), Inline: true}, @@ -197,10 +219,11 @@ func (b *Bot) tradingReportEmbed(sum *TradingSummary) *discordgo.MessageEmbed { {Name: "Transportierte SCU", Value: formatAmount(sum.TotalSCU), Inline: true}, {Name: "Kosten gesamt", Value: formatAUEC(sum.TotalCosts), Inline: true}, {Name: "Distanz gesamt", Value: formatAmount(sum.TotalDistance), Inline: true}, + {Name: "Vergleich zum Vormonat", Value: comparisonSummaryText(sum, prev), Inline: false}, {Name: "Top 3 Trader", Value: topTraderText(sum.TopTraders), Inline: false}, {Name: "Nach Zweck", Value: purposeSummaryText(sum.PurposeSummary), Inline: false}, } - return &discordgo.MessageEmbed{Title: fmt.Sprintf("Trading-Auswertung %02d/%d", sum.Month, sum.Year), Description: "Monatliche Zusammenfassung der erfassten Trading-Runs.", Color: 0x2ecc71, Fields: fields, Timestamp: time.Now().UTC().Format(time.RFC3339)} + return &discordgo.MessageEmbed{Title: fmt.Sprintf("Trading-Auswertung %02d/%d", sum.Month, sum.Year), Description: "Monatliche Zusammenfassung der erfassten Trading-Runs inklusive Vergleich zum Vormonat.", Color: 0x2ecc71, Fields: fields, Timestamp: time.Now().UTC().Format(time.RFC3339)} } func topTraderText(items []TradingTraderSummary) string {