Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion cmd/server/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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)
Expand Down
56 changes: 55 additions & 1 deletion internal/httpserver/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -39,6 +40,7 @@ func NewServer(tradingOperationService *service.TradingOperationService, emailAl
CredentialService: credentialService,
BinanceSymbolService: binanceSymbolService,
BinancePriceService: binancePriceService,
BinanceHistoricalPriceService: binanceHistoricalPriceService,
BinanceTradingService: binanceTradingService,
TradingScheduleService: tradingScheduleService,
SettingsSummary: settingsSummary,
Expand All @@ -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
}
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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
Expand Down
182 changes: 182 additions & 0 deletions internal/service/binance_historical_price_service.go
Original file line number Diff line number Diff line change
@@ -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)
}
Loading