Files
sftrading/trading_charts.go
jbergner 4864574c56
All checks were successful
release-tag / release-image (push) Successful in 2m9s
Charts-Update
2026-05-31 16:04:59 +02:00

357 lines
11 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}