Charts-Update
All checks were successful
release-tag / release-image (push) Successful in 2m9s

This commit is contained in:
2026-05-31 16:04:59 +02:00
parent 25b42c8335
commit 4864574c56
4 changed files with 387 additions and 5 deletions

3
go.mod
View File

@@ -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 (

2
go.sum
View File

@@ -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=

356
trading_charts.go Normal file
View File

@@ -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
}

View File

@@ -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 {