From 557c04436dd0e612d630403b6e0f097fd29ed79c Mon Sep 17 00:00:00 2001 From: Ramiro Paz Date: Mon, 9 Mar 2026 16:26:58 -0300 Subject: [PATCH] adding project logic --- fix.cfg | 15 ++++ src/app/model.go | 5 ++ src/client/api/rest/controller.go | 76 +++++++++++++--- src/client/api/rest/model.go | 45 ++++++++++ src/client/api/rest/routes.go | 2 + src/client/api/rest/server.go | 4 +- src/client/data/orders.go | 41 +++++++++ src/client/fix/application.go | 105 ++++++++++++++++++++++ src/client/fix/manager.go | 141 ++++++++++++++++++++++++++++++ src/cmd/service/service.go | 10 ++- src/domain/order.go | 32 +++++++ 11 files changed, 461 insertions(+), 15 deletions(-) create mode 100644 fix.cfg create mode 100644 src/client/data/orders.go create mode 100644 src/client/fix/application.go create mode 100644 src/client/fix/manager.go create mode 100644 src/domain/order.go diff --git a/fix.cfg b/fix.cfg new file mode 100644 index 0000000..d45ecfe --- /dev/null +++ b/fix.cfg @@ -0,0 +1,15 @@ +[DEFAULT] +ConnectionType=acceptor +HeartBtInt=30 +SenderCompID=QUANTEX +ResetOnLogon=Y +FileStorePath=fix_store +FileLogPath=fix_logs + +[SESSION] +BeginString=FIXT.1.1 +DefaultApplVerID=FIX.5.0SP2 +TargetCompID=CLIENT +StartTime=00:00:00 +EndTime=00:00:00 +SocketAcceptPort=5001 diff --git a/src/app/model.go b/src/app/model.go index c01c112..63f9496 100644 --- a/src/app/model.go +++ b/src/app/model.go @@ -36,6 +36,11 @@ type Service struct { AuthorizedServices map[string]AuthorizedService `toml:"AuthorizedServices"` APIBasePort string EnableJWTAuth bool // Enable JWT authentication for service-to-service communication + FIX FIXConfig +} + +type FIXConfig struct { + SettingsFile string // path to fix.cfg file } type ExtAuth struct { diff --git a/src/client/api/rest/controller.go b/src/client/api/rest/controller.go index 711225a..cc53088 100644 --- a/src/client/api/rest/controller.go +++ b/src/client/api/rest/controller.go @@ -29,23 +29,27 @@ const ( ) type Controller struct { - pool *redis.Pool - userData app.UserDataProvider - store *store.Store - config Config - notify domain.Notifier - authMutex deadlock.Mutex + pool *redis.Pool + userData app.UserDataProvider + store *store.Store + orderStore domain.OrderStore + fixSender domain.FIXSender + config Config + notify domain.Notifier + authMutex deadlock.Mutex } func newController(pool *redis.Pool, userData app.UserDataProvider, - s *store.Store, config Config, n domain.Notifier, + s *store.Store, orderStore domain.OrderStore, fixSender domain.FIXSender, config Config, n domain.Notifier, ) *Controller { return &Controller{ - pool: pool, - userData: userData, - store: s, - config: config, - notify: n, + pool: pool, + userData: userData, + store: s, + orderStore: orderStore, + fixSender: fixSender, + config: config, + notify: n, } } @@ -288,3 +292,51 @@ func allowed(origin string, config Config) bool { return false } + +// GetOrders godoc +// @Summary List received FIX orders +// @Description Returns all NewOrderSingle messages received via FIX +// @Tags fix +// @Produce json +// @Success 200 {array} domain.Order +// @Router /qfixdpl/v1/orders [get] +func (cont *Controller) GetOrders(ctx *gin.Context) { + orders := cont.orderStore.GetOrders() + ctx.JSON(http.StatusOK, orders) +} + +// SendQuote godoc +// @Summary Send a FIX Quote +// @Description Sends a Quote (MsgType S) back to the FIX client for a given order +// @Tags fix +// @Accept json +// @Produce json +// @Param quote body QuoteRequest true "Quote details" +// @Success 200 {object} Msg +// @Failure 400 {object} HTTPError +// @Failure 404 {object} HTTPError +// @Failure 500 {object} HTTPError +// @Router /qfixdpl/v1/quotes [post] +func (cont *Controller) SendQuote(ctx *gin.Context) { + var req QuoteRequest + if err := ctx.ShouldBindJSON(&req); err != nil { + ctx.JSON(http.StatusBadRequest, HTTPError{Error: err.Error()}) + + return + } + + bidPx, offerPx, bidSize, offerSize, err := req.toDecimals() + if err != nil { + ctx.JSON(http.StatusBadRequest, HTTPError{Error: err.Error()}) + + return + } + + if err = cont.fixSender.SendQuote(req.ClOrdID, req.QuoteID, req.Symbol, req.Currency, bidPx, offerPx, bidSize, offerSize); err != nil { + ctx.JSON(http.StatusInternalServerError, HTTPError{Error: err.Error()}) + + return + } + + ctx.JSON(http.StatusOK, Msg{Text: "quote sent"}) +} diff --git a/src/client/api/rest/model.go b/src/client/api/rest/model.go index 26f90dd..e7cfb9c 100644 --- a/src/client/api/rest/model.go +++ b/src/client/api/rest/model.go @@ -1,5 +1,11 @@ package rest +import ( + "fmt" + + "github.com/shopspring/decimal" +) + type HTTPError struct { Error string } @@ -16,3 +22,42 @@ type Credentials struct { type Session struct { Email string } + +type QuoteRequest struct { + ClOrdID string `json:"cl_ord_id" binding:"required"` + QuoteID string `json:"quote_id" binding:"required"` + Symbol string `json:"symbol" binding:"required"` + Currency string `json:"currency"` + BidPx string `json:"bid_px" binding:"required"` + OfferPx string `json:"offer_px" binding:"required"` + BidSize string `json:"bid_size"` + OfferSize string `json:"offer_size"` +} + +func (r QuoteRequest) toDecimals() (bidPx, offerPx, bidSize, offerSize decimal.Decimal, err error) { + bidPx, err = decimal.NewFromString(r.BidPx) + if err != nil { + return bidPx, offerPx, bidSize, offerSize, fmt.Errorf("invalid bid_px: %w", err) + } + + offerPx, err = decimal.NewFromString(r.OfferPx) + if err != nil { + return bidPx, offerPx, bidSize, offerSize, fmt.Errorf("invalid offer_px: %w", err) + } + + if r.BidSize != "" { + bidSize, err = decimal.NewFromString(r.BidSize) + if err != nil { + return bidPx, offerPx, bidSize, offerSize, fmt.Errorf("invalid bid_size: %w", err) + } + } + + if r.OfferSize != "" { + offerSize, err = decimal.NewFromString(r.OfferSize) + if err != nil { + return bidPx, offerPx, bidSize, offerSize, fmt.Errorf("invalid offer_size: %w", err) + } + } + + return bidPx, offerPx, bidSize, offerSize, nil +} diff --git a/src/client/api/rest/routes.go b/src/client/api/rest/routes.go index 767c7fc..7e3d346 100644 --- a/src/client/api/rest/routes.go +++ b/src/client/api/rest/routes.go @@ -21,6 +21,8 @@ func SetRoutes(api *API) { qfixdpl := v1.Group("/") qfixdpl.Use(cont.AuthRequired) qfixdpl.GET("/health", cont.HealthCheck) + qfixdpl.GET("/orders", cont.GetOrders) + qfixdpl.POST("/quotes", cont.SendQuote) backoffice := qfixdpl.Group("/backoffice") backoffice.Use(cont.BackOfficeUser) diff --git a/src/client/api/rest/server.go b/src/client/api/rest/server.go index 3fa36aa..de5c3f8 100644 --- a/src/client/api/rest/server.go +++ b/src/client/api/rest/server.go @@ -32,7 +32,7 @@ type Config struct { EnableJWTAuth bool } -func New(userData app.UserDataProvider, storeInstance *store.Store, config Config, notify domain.Notifier) *API { +func New(userData app.UserDataProvider, storeInstance *store.Store, orderStore domain.OrderStore, fixSender domain.FIXSender, config Config, notify domain.Notifier) *API { // Set up Gin var engine *gin.Engine if version.Environment() == version.EnvironmentTypeProd { @@ -58,7 +58,7 @@ func New(userData app.UserDataProvider, storeInstance *store.Store, config Confi } api := &API{ - Controller: newController(NewPool(), userData, storeInstance, config, notify), + Controller: newController(NewPool(), userData, storeInstance, orderStore, fixSender, config, notify), Router: engine, Port: config.Port, } diff --git a/src/client/data/orders.go b/src/client/data/orders.go new file mode 100644 index 0000000..0d56a69 --- /dev/null +++ b/src/client/data/orders.go @@ -0,0 +1,41 @@ +package data + +import ( + "sync" + + "quantex.com/qfixdpl/src/domain" +) + +type InMemoryOrderStore struct { + mu sync.RWMutex + orders []domain.Order +} + +func NewOrderStore() *InMemoryOrderStore { + return &InMemoryOrderStore{} +} + +func (s *InMemoryOrderStore) SaveOrder(order domain.Order) { + s.mu.Lock() + defer s.mu.Unlock() + s.orders = append(s.orders, order) +} + +func (s *InMemoryOrderStore) GetOrders() []domain.Order { + s.mu.RLock() + defer s.mu.RUnlock() + result := make([]domain.Order, len(s.orders)) + copy(result, s.orders) + return result +} + +func (s *InMemoryOrderStore) GetOrderByClOrdID(id string) (domain.Order, bool) { + s.mu.RLock() + defer s.mu.RUnlock() + for _, o := range s.orders { + if o.ClOrdID == id { + return o, true + } + } + return domain.Order{}, false +} diff --git a/src/client/fix/application.go b/src/client/fix/application.go new file mode 100644 index 0000000..de4c204 --- /dev/null +++ b/src/client/fix/application.go @@ -0,0 +1,105 @@ +// Package fix implements the QuickFIX acceptor application. +package fix + +import ( + "log/slog" + "time" + + "quantex.com/qfixdpl/quickfix" + "quantex.com/qfixdpl/quickfix/gen/fix50sp2/newordersingle" + "quantex.com/qfixdpl/src/domain" +) + +type application struct { + router *quickfix.MessageRouter + orderStore domain.OrderStore + onLogon func(quickfix.SessionID) + onLogout func(quickfix.SessionID) +} + +func newApplication(orderStore domain.OrderStore) *application { + app := &application{ + router: quickfix.NewMessageRouter(), + orderStore: orderStore, + } + + app.router.AddRoute(newordersingle.Route(app.handleNewOrderSingle)) + + return app +} + +func (a *application) OnCreate(sessionID quickfix.SessionID) { + slog.Info("FIX session created", "session", sessionID.String()) +} + +func (a *application) OnLogon(sessionID quickfix.SessionID) { + slog.Info("FIX client logged on", "session", sessionID.String()) + if a.onLogon != nil { + a.onLogon(sessionID) + } +} + +func (a *application) OnLogout(sessionID quickfix.SessionID) { + slog.Info("FIX client logged out", "session", sessionID.String()) + if a.onLogout != nil { + a.onLogout(sessionID) + } +} + +func (a *application) ToAdmin(_ *quickfix.Message, _ quickfix.SessionID) {} + +func (a *application) ToApp(_ *quickfix.Message, _ quickfix.SessionID) error { return nil } + +func (a *application) FromAdmin(_ *quickfix.Message, _ quickfix.SessionID) quickfix.MessageRejectError { + return nil +} + +func (a *application) FromApp(msg *quickfix.Message, sessionID quickfix.SessionID) quickfix.MessageRejectError { + return a.router.Route(msg, sessionID) +} + +func (a *application) handleNewOrderSingle(msg newordersingle.NewOrderSingle, sessionID quickfix.SessionID) quickfix.MessageRejectError { + clOrdID, err := msg.GetClOrdID() + if err != nil { + return err + } + + symbol, err := msg.GetSymbol() + if err != nil { + return err + } + + side, err := msg.GetSide() + if err != nil { + return err + } + + ordType, err := msg.GetOrdType() + if err != nil { + return err + } + + orderQty, err := msg.GetOrderQty() + if err != nil { + return err + } + + price, _ := msg.GetPrice() // Price is optional for some OrdTypes + + order := domain.Order{ + ClOrdID: clOrdID, + Symbol: symbol, + Side: string(side), + OrdType: string(ordType), + OrderQty: orderQty, + Price: price, + SessionID: sessionID.String(), + ReceivedAt: time.Now(), + } + + a.orderStore.SaveOrder(order) + + slog.Info("NewOrderSingle received", "clOrdID", clOrdID, "symbol", symbol, "side", order.Side) + + return nil +} diff --git a/src/client/fix/manager.go b/src/client/fix/manager.go new file mode 100644 index 0000000..dba697b --- /dev/null +++ b/src/client/fix/manager.go @@ -0,0 +1,141 @@ +package fix + +import ( + "fmt" + "log/slog" + "os" + "sync" + "time" + + "github.com/shopspring/decimal" + + "quantex.com/qfixdpl/quickfix" + "quantex.com/qfixdpl/quickfix/gen/enum" + "quantex.com/qfixdpl/quickfix/gen/field" + "quantex.com/qfixdpl/quickfix/gen/fix50sp2/quote" + "quantex.com/qfixdpl/src/app" + "quantex.com/qfixdpl/src/domain" +) + +// Manager wraps the QuickFIX acceptor and implements domain.FIXSender. +type Manager struct { + acceptor *quickfix.Acceptor + app *application + sessionsMu sync.RWMutex + sessions map[string]quickfix.SessionID + orderStore domain.OrderStore + notify domain.Notifier + cfg app.FIXConfig +} + +func NewManager(cfg app.FIXConfig, orderStore domain.OrderStore, notify domain.Notifier) *Manager { + return &Manager{ + sessions: make(map[string]quickfix.SessionID), + orderStore: orderStore, + notify: notify, + cfg: cfg, + } +} + +func (m *Manager) Start() error { + fixApp := newApplication(m.orderStore) + fixApp.onLogon = m.onLogon + fixApp.onLogout = m.onLogout + m.app = fixApp + + f, err := os.Open(m.cfg.SettingsFile) + if err != nil { + return fmt.Errorf("opening FIX settings file %q: %w", m.cfg.SettingsFile, err) + } + defer f.Close() + + settings, err := quickfix.ParseSettings(f) + if err != nil { + return fmt.Errorf("parsing FIX settings: %w", err) + } + + storeFactory := quickfix.NewMemoryStoreFactory() + logFactory := quickfix.NewNullLogFactory() + + acceptor, err := quickfix.NewAcceptor(fixApp, storeFactory, settings, logFactory) + if err != nil { + return fmt.Errorf("creating FIX acceptor: %w", err) + } + + m.acceptor = acceptor + + if err = m.acceptor.Start(); err != nil { + return fmt.Errorf("starting FIX acceptor: %w", err) + } + + slog.Info("FIX acceptor started", "settings", m.cfg.SettingsFile) + + return nil +} + +func (m *Manager) Stop() { + if m.acceptor != nil { + m.acceptor.Stop() + slog.Info("FIX acceptor stopped") + } +} + +func (m *Manager) onLogon(sessionID quickfix.SessionID) { + m.sessionsMu.Lock() + m.sessions[sessionID.String()] = sessionID + m.sessionsMu.Unlock() +} + +func (m *Manager) onLogout(sessionID quickfix.SessionID) { + m.sessionsMu.Lock() + delete(m.sessions, sessionID.String()) + m.sessionsMu.Unlock() +} + +// SendQuote implements domain.FIXSender. +func (m *Manager) SendQuote(clOrdID, quoteID, symbol, currency string, bidPx, offerPx, bidSize, offerSize decimal.Decimal) error { + order, ok := m.orderStore.GetOrderByClOrdID(clOrdID) + if !ok { + return fmt.Errorf("order not found: %s", clOrdID) + } + + m.sessionsMu.RLock() + sessionID, ok := m.sessions[order.SessionID] + m.sessionsMu.RUnlock() + + if !ok { + return fmt.Errorf("session not active for order %s (session %s)", clOrdID, order.SessionID) + } + + q := quote.New( + field.NewQuoteID(quoteID), + field.NewQuoteType(enum.QuoteType_INDICATIVE), + field.NewTransactTime(time.Now()), + ) + + q.SetSymbol(symbol) + q.SetQuoteID(quoteID) + + if currency != "" { + q.SetCurrency(currency) + } + + q.SetBidPx(bidPx, 8) + q.SetOfferPx(offerPx, 8) + + if !bidSize.IsZero() { + q.SetBidSize(bidSize, 8) + } + + if !offerSize.IsZero() { + q.SetOfferSize(offerSize, 8) + } + + if err := quickfix.SendToTarget(q, sessionID); err != nil { + return fmt.Errorf("sending FIX quote: %w", err) + } + + slog.Info("Quote sent", "clOrdID", clOrdID, "quoteID", quoteID, "symbol", symbol) + + return nil +} diff --git a/src/cmd/service/service.go b/src/cmd/service/service.go index 0ddaa30..e948375 100644 --- a/src/cmd/service/service.go +++ b/src/cmd/service/service.go @@ -8,6 +8,7 @@ import ( "quantex.com/qfixdpl/src/app" "quantex.com/qfixdpl/src/client/api/rest" "quantex.com/qfixdpl/src/client/data" + "quantex.com/qfixdpl/src/client/fix" googlechat "quantex.com/qfixdpl/src/client/notify/google" "quantex.com/qfixdpl/src/client/store" "quantex.com/qfixdpl/src/client/store/external" @@ -37,6 +38,13 @@ func Runner(cfg app.Config) error { } userData := data.New() + orderStore := data.NewOrderStore() + + fixManager := fix.NewManager(cfg.FIX, orderStore, notify) + if err = fixManager.Start(); err != nil { + return fmt.Errorf("error starting FIX acceptor: %w", err) + } + defer fixManager.Stop() apiConfig := rest.Config{ Port: cfg.APIBasePort, @@ -46,7 +54,7 @@ func Runner(cfg app.Config) error { EnableJWTAuth: cfg.EnableJWTAuth, } - api := rest.New(userData, appStore, apiConfig, notify) + api := rest.New(userData, appStore, orderStore, fixManager, apiConfig, notify) api.Run() cmd.WaitForInterruptSignal(nil) diff --git a/src/domain/order.go b/src/domain/order.go new file mode 100644 index 0000000..c386727 --- /dev/null +++ b/src/domain/order.go @@ -0,0 +1,32 @@ +// Package domain defines all the domain models +package domain + +import ( + "time" + + "github.com/shopspring/decimal" +) + +// Order represents a FIX NewOrderSingle message received from a client. +type Order struct { + ClOrdID string + Symbol string + Side string + OrdType string + OrderQty decimal.Decimal + Price decimal.Decimal + SessionID string + ReceivedAt time.Time +} + +// OrderStore is the port for persisting and retrieving orders. +type OrderStore interface { + SaveOrder(order Order) + GetOrders() []Order + GetOrderByClOrdID(id string) (Order, bool) +} + +// FIXSender is the port for sending FIX messages back to clients. +type FIXSender interface { + SendQuote(clOrdID, quoteID, symbol, currency string, bidPx, offerPx, bidSize, offerSize decimal.Decimal) error +}