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