From 70b63ea9cabf645a30d4f28ac876153cb7a1fd35 Mon Sep 17 00:00:00 2001 From: Gustavo Perin Date: Wed, 24 Dec 2025 22:36:26 -0300 Subject: [PATCH 1/4] Add realtime charts and clarify price labels --- cmd/server/main.go | 3 +- internal/httpserver/server.go | 56 ++- .../binance_historical_price_service.go | 182 ++++++++ templates/index.html | 416 +++++++++++++++++- 4 files changed, 643 insertions(+), 14 deletions(-) create mode 100644 internal/service/binance_historical_price_service.go diff --git a/cmd/server/main.go b/cmd/server/main.go index bd2e92e..df923e6 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -53,6 +53,7 @@ func main() { credentialService.InitializeCredentials(context.Background()) activeEnvironment := credentialService.GetActiveEnvironmentConfiguration() binancePriceService.UpdateEnvironmentConfiguration(activeEnvironment) + binanceHistoricalPriceService := service.NewBinanceHistoricalPriceService(activeEnvironment) binanceSymbolService := service.NewBinanceSymbolService(activeEnvironment) binanceTradingService := service.NewBinanceTradingService(activeEnvironment) dailyPurchaseAutomationService := service.NewDailyPurchaseAutomationService(dailyPurchaseSettingsService, binancePriceService, binanceTradingService, tradingOperationService, tradingScheduleService) @@ -78,7 +79,7 @@ func main() { TargetProfitPercent: applicationConfiguration.TargetProfitPercent, } - server := httpserver.NewServer(tradingOperationService, emailAlertService, automationService, dailyPurchaseSettingsService, credentialService, binanceSymbolService, binancePriceService, binanceTradingService, tradingScheduleService, dashboardSettingsSummary, parsedTemplates) + server := httpserver.NewServer(tradingOperationService, emailAlertService, automationService, dailyPurchaseSettingsService, credentialService, binanceSymbolService, binancePriceService, binanceHistoricalPriceService, binanceTradingService, tradingScheduleService, dashboardSettingsSummary, parsedTemplates) router := server.RegisterRoutes() applicationContext, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) diff --git a/internal/httpserver/server.go b/internal/httpserver/server.go index 2c42dc3..c3711df 100644 --- a/internal/httpserver/server.go +++ b/internal/httpserver/server.go @@ -24,13 +24,14 @@ type Server struct { CredentialService *service.CredentialService BinanceSymbolService *service.BinanceSymbolService BinancePriceService *service.BinancePriceService + BinanceHistoricalPriceService *service.BinanceHistoricalPriceService BinanceTradingService *service.BinanceTradingService TradingScheduleService *service.TradingScheduleService SettingsSummary DashboardSettingsSummary Templates *template.Template } -func NewServer(tradingOperationService *service.TradingOperationService, emailAlertService *service.EmailAlertService, automationService *service.TradingAutomationService, dailyPurchaseService *service.DailyPurchaseSettingsService, credentialService *service.CredentialService, binanceSymbolService *service.BinanceSymbolService, binancePriceService *service.BinancePriceService, binanceTradingService *service.BinanceTradingService, tradingScheduleService *service.TradingScheduleService, settingsSummary DashboardSettingsSummary, templates *template.Template) *Server { +func NewServer(tradingOperationService *service.TradingOperationService, emailAlertService *service.EmailAlertService, automationService *service.TradingAutomationService, dailyPurchaseService *service.DailyPurchaseSettingsService, credentialService *service.CredentialService, binanceSymbolService *service.BinanceSymbolService, binancePriceService *service.BinancePriceService, binanceHistoricalPriceService *service.BinanceHistoricalPriceService, binanceTradingService *service.BinanceTradingService, tradingScheduleService *service.TradingScheduleService, settingsSummary DashboardSettingsSummary, templates *template.Template) *Server { return &Server{ TradingOperationService: tradingOperationService, EmailAlertService: emailAlertService, @@ -39,6 +40,7 @@ func NewServer(tradingOperationService *service.TradingOperationService, emailAl CredentialService: credentialService, BinanceSymbolService: binanceSymbolService, BinancePriceService: binancePriceService, + BinanceHistoricalPriceService: binanceHistoricalPriceService, BinanceTradingService: binanceTradingService, TradingScheduleService: tradingScheduleService, SettingsSummary: settingsSummary, @@ -60,6 +62,7 @@ func (server *Server) RegisterRoutes() http.Handler { router.HandleFunc("/settings/daily-purchase", server.handleUpdateDailyPurchaseSettings) router.HandleFunc("/binance/symbols", server.handleBinanceSymbols) router.HandleFunc("/binance/price", server.handleBinancePrice) + router.HandleFunc("/binance/history", server.handleBinanceHistoricalPrices) router.HandleFunc("/operations/execute-next", server.handleExecuteNextOperation) return router } @@ -491,6 +494,48 @@ func (server *Server) handleBinancePrice(responseWriter http.ResponseWriter, req } } +func (server *Server) handleBinanceHistoricalPrices(responseWriter http.ResponseWriter, request *http.Request) { + if request.Method != http.MethodGet { + responseWriter.WriteHeader(http.StatusMethodNotAllowed) + return + } + + tradingPairSymbol := request.URL.Query().Get("symbol") + if tradingPairSymbol == "" { + http.Error(responseWriter, "Missing symbol parameter", http.StatusBadRequest) + return + } + + period := request.URL.Query().Get("period") + if period == "" { + http.Error(responseWriter, "Missing period parameter", http.StatusBadRequest) + return + } + + contextWithTimeout, cancel := context.WithTimeout(request.Context(), 12*time.Second) + defer cancel() + + priceSnapshot, priceError := server.BinanceHistoricalPriceService.GetHistoricalPriceSnapshot(contextWithTimeout, tradingPairSymbol, period) + if priceError != nil { + log.Printf("Could not fetch Binance historical prices for %s: %v", tradingPairSymbol, priceError) + http.Error(responseWriter, "Could not fetch historical price data", http.StatusBadGateway) + return + } + + responseWriter.Header().Set("Content-Type", "application/json") + encodeError := json.NewEncoder(responseWriter).Encode(BinanceHistoricalPriceResponse{ + Symbol: tradingPairSymbol, + Period: period, + MinimumPrice: priceSnapshot.MinimumPrice, + MaximumPrice: priceSnapshot.MaximumPrice, + PricePoints: priceSnapshot.PricePoints, + }) + if encodeError != nil { + log.Printf("Could not encode historical price data: %v", encodeError) + http.Error(responseWriter, "Failed to serialize historical price data", http.StatusInternalServerError) + } +} + func (server *Server) buildDashboardViewModelWithRequest(request *http.Request) (*DashboardViewModel, error) { dashboardViewModel, loadError := server.buildDashboardViewModel(request.Context()) if loadError != nil { @@ -811,6 +856,14 @@ type BinancePriceResponse struct { Price float64 `json:"price"` } +type BinanceHistoricalPriceResponse struct { + Symbol string `json:"symbol"` + Period string `json:"period"` + MinimumPrice float64 `json:"minimumPrice"` + MaximumPrice float64 `json:"maximumPrice"` + PricePoints []service.BinanceHistoricalPricePoint `json:"pricePoints"` +} + func (server *Server) reconcileFilledSellOrders(requestContext context.Context) { openOperationsContext, openOperationsCancel := context.WithTimeout(requestContext, 8*time.Second) defer openOperationsCancel() @@ -1000,6 +1053,7 @@ func (server *Server) fetchOpenOrders(requestContext context.Context) ([]service func (server *Server) refreshEnvironmentConfiguration() { activeEnvironment := server.CredentialService.GetActiveEnvironmentConfiguration() server.BinancePriceService.UpdateEnvironmentConfiguration(activeEnvironment) + server.BinanceHistoricalPriceService.UpdateEnvironmentConfiguration(activeEnvironment) server.BinanceSymbolService.UpdateEnvironmentConfiguration(activeEnvironment) server.BinanceTradingService.UpdateEnvironmentConfiguration(activeEnvironment) server.SettingsSummary.BinanceAPIBaseURL = activeEnvironment.RESTBaseURL diff --git a/internal/service/binance_historical_price_service.go b/internal/service/binance_historical_price_service.go new file mode 100644 index 0000000..a2c72de --- /dev/null +++ b/internal/service/binance_historical_price_service.go @@ -0,0 +1,182 @@ +package service + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "math" + "net/http" + "net/url" + "strconv" + "time" + + "coin-alert/internal/domain" +) + +const ( + HistoricalPricePeriodOneYear = "1Y" + HistoricalPricePeriodThreeMonths = "3M" + HistoricalPricePeriodOneMonth = "1M" + HistoricalPricePeriodOneWeek = "1W" + HistoricalPricePeriodOneDay = "1D" +) + +type BinanceHistoricalPriceService struct { + EnvironmentConfiguration domain.BinanceEnvironmentConfiguration + HTTPClient *http.Client +} + +type BinanceHistoricalPricePoint struct { + Timestamp time.Time `json:"timestamp"` + Price float64 `json:"price"` +} + +type HistoricalPriceSnapshot struct { + PricePoints []BinanceHistoricalPricePoint + MinimumPrice float64 + MaximumPrice float64 +} + +type historicalPriceRange struct { + Interval string + Start time.Time + End time.Time +} + +func NewBinanceHistoricalPriceService(environmentConfiguration domain.BinanceEnvironmentConfiguration) *BinanceHistoricalPriceService { + return &BinanceHistoricalPriceService{ + EnvironmentConfiguration: environmentConfiguration, + HTTPClient: &http.Client{Timeout: 12 * time.Second}, + } +} + +func (service *BinanceHistoricalPriceService) UpdateEnvironmentConfiguration(newConfiguration domain.BinanceEnvironmentConfiguration) { + service.EnvironmentConfiguration = newConfiguration +} + +func (service *BinanceHistoricalPriceService) GetHistoricalPriceSnapshot(requestContext context.Context, tradingPairSymbol string, period string) (HistoricalPriceSnapshot, error) { + requestRange, rangeError := buildHistoricalPriceRange(period, time.Now().UTC()) + if rangeError != nil { + return HistoricalPriceSnapshot{}, rangeError + } + + klinesEndpoint, urlBuildError := url.Parse(service.EnvironmentConfiguration.RESTBaseURL) + if urlBuildError != nil { + return HistoricalPriceSnapshot{}, urlBuildError + } + klinesEndpoint.Path = "/api/v3/klines" + + queryParameters := klinesEndpoint.Query() + queryParameters.Set("symbol", tradingPairSymbol) + queryParameters.Set("interval", requestRange.Interval) + queryParameters.Set("startTime", strconv.FormatInt(requestRange.Start.UnixMilli(), 10)) + queryParameters.Set("endTime", strconv.FormatInt(requestRange.End.UnixMilli(), 10)) + queryParameters.Set("limit", "1000") + klinesEndpoint.RawQuery = queryParameters.Encode() + + klinesRequest, requestBuildError := http.NewRequestWithContext(requestContext, http.MethodGet, klinesEndpoint.String(), nil) + if requestBuildError != nil { + return HistoricalPriceSnapshot{}, requestBuildError + } + + klinesResponse, responseError := service.HTTPClient.Do(klinesRequest) + if responseError != nil { + return HistoricalPriceSnapshot{}, responseError + } + defer klinesResponse.Body.Close() + + if klinesResponse.StatusCode != http.StatusOK { + return HistoricalPriceSnapshot{}, fmt.Errorf("Binance klines endpoint returned status %d", klinesResponse.StatusCode) + } + + var parsedResponse [][]json.RawMessage + decodeError := json.NewDecoder(klinesResponse.Body).Decode(&parsedResponse) + if decodeError != nil { + return HistoricalPriceSnapshot{}, decodeError + } + if len(parsedResponse) == 0 { + return HistoricalPriceSnapshot{}, errors.New("Binance klines response returned no data") + } + + pricePoints := make([]BinanceHistoricalPricePoint, 0, len(parsedResponse)) + minimumPrice := math.MaxFloat64 + maximumPrice := 0.0 + + for _, kline := range parsedResponse { + pricePoint, parseError := parseKlineToPricePoint(kline) + if parseError != nil { + return HistoricalPriceSnapshot{}, parseError + } + pricePoints = append(pricePoints, pricePoint) + if pricePoint.Price < minimumPrice { + minimumPrice = pricePoint.Price + } + if pricePoint.Price > maximumPrice { + maximumPrice = pricePoint.Price + } + } + + if minimumPrice == math.MaxFloat64 { + minimumPrice = 0 + } + + return HistoricalPriceSnapshot{ + PricePoints: pricePoints, + MinimumPrice: minimumPrice, + MaximumPrice: maximumPrice, + }, nil +} + +func buildHistoricalPriceRange(period string, referenceTime time.Time) (historicalPriceRange, error) { + switch period { + case HistoricalPricePeriodOneYear: + return historicalPriceRange{Interval: "1d", Start: referenceTime.AddDate(-1, 0, 0), End: referenceTime}, nil + case HistoricalPricePeriodThreeMonths: + return historicalPriceRange{Interval: "1d", Start: referenceTime.AddDate(0, -3, 0), End: referenceTime}, nil + case HistoricalPricePeriodOneMonth: + return historicalPriceRange{Interval: "1d", Start: referenceTime.AddDate(0, -1, 0), End: referenceTime}, nil + case HistoricalPricePeriodOneWeek: + return historicalPriceRange{Interval: "1h", Start: referenceTime.AddDate(0, 0, -7), End: referenceTime}, nil + case HistoricalPricePeriodOneDay: + return historicalPriceRange{Interval: "5m", Start: referenceTime.Add(-24 * time.Hour), End: referenceTime}, nil + default: + return historicalPriceRange{}, fmt.Errorf("unsupported historical period: %s", period) + } +} + +func parseKlineToPricePoint(kline []json.RawMessage) (BinanceHistoricalPricePoint, error) { + if len(kline) < 5 { + return BinanceHistoricalPricePoint{}, errors.New("Binance kline response did not include enough data") + } + openTime, openTimeError := parseRawMessageToInt64(kline[0]) + if openTimeError != nil { + return BinanceHistoricalPricePoint{}, openTimeError + } + closePrice, closePriceError := parseRawMessageToFloat(kline[4]) + if closePriceError != nil { + return BinanceHistoricalPricePoint{}, closePriceError + } + return BinanceHistoricalPricePoint{ + Timestamp: time.UnixMilli(openTime), + Price: closePrice, + }, nil +} + +func parseRawMessageToInt64(rawMessage json.RawMessage) (int64, error) { + var parsedValue int64 + parseError := json.Unmarshal(rawMessage, &parsedValue) + if parseError != nil { + return 0, parseError + } + return parsedValue, nil +} + +func parseRawMessageToFloat(rawMessage json.RawMessage) (float64, error) { + var parsedValue string + parseError := json.Unmarshal(rawMessage, &parsedValue) + if parseError != nil { + return 0, parseError + } + return parseDecimalStringToFloat(parsedValue) +} diff --git a/templates/index.html b/templates/index.html index 5bcc16c..6574dac 100644 --- a/templates/index.html +++ b/templates/index.html @@ -38,6 +38,20 @@ .dropdown-item.active { background: rgba(99,102,241,0.2); } .loading-indicator { display: inline-flex; align-items: center; gap: 8px; color: #94a3b8; font-size: 0.9em; } .loading-dot { width: 8px; height: 8px; border-radius: 50%; background: #22c55e; animation: pulse 1s infinite; } + .current-price-panel { margin-top: 10px; display: flex; align-items: center; gap: 12px; background: rgba(34,197,94,0.08); border: 1px solid rgba(34,197,94,0.4); padding: 10px 12px; border-radius: 12px; font-weight: 700; } + .current-price-panel .price-label { color: #e2e8f0; } + .current-price-panel .price-value { color: #22c55e; } + .icon-button { background: linear-gradient(120deg, #38bdf8, #6366f1); border: none; border-radius: 10px; padding: 8px 12px; color: white; font-weight: 800; cursor: pointer; width: auto; } + .icon-button.secondary { background: linear-gradient(120deg, #f97316, #ef4444); } + .chart-section { margin-top: 28px; } + .chart-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); gap: 16px; } + .full-width-card { grid-column: 1 / -1; } + .chart-add-header { display: flex; align-items: center; justify-content: space-between; gap: 12px; } + .chart-card-header { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; flex-wrap: wrap; } + .chart-card-header h3 { margin: 0; } + .chart-card canvas { width: 100%; background: #0b1220; border-radius: 12px; border: 1px solid #1f2937; } + .chart-card-footer { display: flex; justify-content: space-between; align-items: center; margin-top: 12px; gap: 12px; flex-wrap: wrap; } + .chart-pill { padding: 6px 10px; border-radius: 999px; background: rgba(99,102,241,0.15); color: #a5b4fc; font-weight: 700; } @keyframes pulse { 0% { opacity: 0.2; } 50% { opacity: 1; } 100% { opacity: 0.2; } } @@ -114,8 +128,14 @@

Record purchase

-
Loading current price...
-
+
+ + Loading current price... +
+
+ + +
@@ -144,8 +164,14 @@

Daily buy

-
Loading current price...
-
+
+ + Loading current price... +
+
+ + +
@@ -172,8 +198,14 @@

Send email alert

-
Loading current price...
-
+
+ + Loading current price... +
+
+ + +
@@ -198,6 +230,57 @@

Send email alert

+
+
+
+
+
+

Real-time charts

+

Add live charts for any trading pair and monitor them side by side.

+
+ +
+ +
+
+ +
+

Open orders (Binance)

@@ -379,6 +462,60 @@

Failed operations

From 8749e17c1230a2df8f189b2167fd73ebe07709c8 Mon Sep 17 00:00:00 2001 From: Gustavo Perin Date: Wed, 24 Dec 2025 23:01:29 -0300 Subject: [PATCH 2/4] Improve chart value indicators and hover tooltip --- templates/index.html | 147 ++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 145 insertions(+), 2 deletions(-) diff --git a/templates/index.html b/templates/index.html index 6574dac..b29e428 100644 --- a/templates/index.html +++ b/templates/index.html @@ -49,7 +49,11 @@ .chart-add-header { display: flex; align-items: center; justify-content: space-between; gap: 12px; } .chart-card-header { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; flex-wrap: wrap; } .chart-card-header h3 { margin: 0; } + .chart-canvas-layout { display: flex; gap: 16px; align-items: stretch; } .chart-card canvas { width: 100%; background: #0b1220; border-radius: 12px; border: 1px solid #1f2937; } + .chart-axis-values { min-width: 140px; display: flex; flex-direction: column; justify-content: space-between; padding: 8px 0; } + .chart-axis-values .axis-label { font-weight: 700; color: #e2e8f0; } + .chart-axis-values .axis-value { color: #38bdf8; font-weight: 700; } .chart-card-footer { display: flex; justify-content: space-between; align-items: center; margin-top: 12px; gap: 12px; flex-wrap: wrap; } .chart-pill { padding: 6px 10px; border-radius: 999px; background: rgba(99,102,241,0.15); color: #a5b4fc; font-weight: 700; } @keyframes pulse { 0% { opacity: 0.2; } 50% { opacity: 1; } 100% { opacity: 0.2; } } @@ -271,7 +275,19 @@

- +
+ +
+
+
Y max
+
+
+
+
Y min
+
+
+
+
- +
Y max
@@ -556,6 +556,25 @@

Failed operations

return pointTimestamp.toLocaleDateString('pt-BR', { month: 'short', year: '2-digit' }); } + function calculateMinAndMaxPrices(pricePoints) { + if (!pricePoints || !pricePoints.length) { + return { minimumPrice: 0, maximumPrice: 0 }; + } + let minimumPrice = Number.POSITIVE_INFINITY; + let maximumPrice = Number.NEGATIVE_INFINITY; + pricePoints.forEach(point => { + if (!Number.isFinite(point.price)) { + return; + } + minimumPrice = Math.min(minimumPrice, point.price); + maximumPrice = Math.max(maximumPrice, point.price); + }); + if (!Number.isFinite(minimumPrice) || !Number.isFinite(maximumPrice)) { + return { minimumPrice: 0, maximumPrice: 0 }; + } + return { minimumPrice, maximumPrice }; + } + function createChartIdentifier() { return `${Date.now()}-${Math.floor(Math.random() * 100000)}`; } @@ -855,8 +874,11 @@

Failed operations

price: Number(point.price), })); chartCard.pricePoints = parsedPoints.slice(-this.chartWindowSize); - chartCard.minimumPrice = Number.isFinite(Number(body.minimumPrice)) ? Number(body.minimumPrice) : 0; - chartCard.maximumPrice = Number.isFinite(Number(body.maximumPrice)) ? Number(body.maximumPrice) : 0; + const serverMinimumPrice = Number.isFinite(Number(body.minimumPrice)) ? Number(body.minimumPrice) : 0; + const serverMaximumPrice = Number.isFinite(Number(body.maximumPrice)) ? Number(body.maximumPrice) : 0; + const computedRange = calculateMinAndMaxPrices(parsedPoints); + chartCard.minimumPrice = serverMinimumPrice > 0 ? serverMinimumPrice : computedRange.minimumPrice; + chartCard.maximumPrice = serverMaximumPrice > 0 ? serverMaximumPrice : computedRange.maximumPrice; chartCard.rangeLabel = buildRangeLabel(chartCard.tradingPairSymbol, chartCard.minimumPrice, chartCard.maximumPrice); chartCard.minimumPriceLabel = buildAxisValueLabel(chartCard.tradingPairSymbol, 'Min', chartCard.minimumPrice); chartCard.maximumPriceLabel = buildAxisValueLabel(chartCard.tradingPairSymbol, 'Max', chartCard.maximumPrice); @@ -983,9 +1005,9 @@

Failed operations

const maximumPrice = chartCard.maximumPrice === minimumPrice ? minimumPrice + 1 : chartCard.maximumPrice; const xAxisDivisions = 4; - context.strokeStyle = 'rgba(148, 163, 184, 0.25)'; - context.lineWidth = 1; - context.font = '13px Segoe UI'; + context.strokeStyle = 'rgba(148, 163, 184, 0.35)'; + context.lineWidth = 1.2; + context.font = '14px Segoe UI'; context.fillStyle = '#94a3b8'; for (let divisionIndex = 0; divisionIndex <= xAxisDivisions; divisionIndex += 1) { const divisionX = padding + (usableWidth * divisionIndex) / xAxisDivisions; @@ -997,7 +1019,7 @@

Failed operations

const point = points[pointIndex]; const label = buildXAxisLabelForPoint(point.timestamp, chartCard.selectedPeriod); if (label) { - context.fillText(label, divisionX - 22, height - 6); + context.fillText(label, divisionX - 26, height - 6); } } @@ -1036,7 +1058,7 @@

Failed operations

const quoteAsset = determineQuoteAssetFromSymbol(chartCard.tradingPairSymbol); const currentValueLabel = formatPriceForQuoteAsset(latestPoint.price, quoteAsset); context.fillStyle = '#22c55e'; - context.font = '14px Segoe UI'; + context.font = '15px Segoe UI'; context.fillText(currentValueLabel, width - rightPadding + 8, latestYPosition + 4); context.fillStyle = '#22c55e'; @@ -1046,7 +1068,7 @@

Failed operations

if (chartCard.isHoveringCurrentPoint && chartCard.hoveredPriceLabel) { const tooltipPadding = 6; - context.font = '14px Segoe UI'; + context.font = '15px Segoe UI'; const tooltipText = chartCard.hoveredPriceLabel; const textWidth = context.measureText(tooltipText).width; const tooltipWidth = textWidth + tooltipPadding * 2;