adding project logic

This commit is contained in:
Ramiro Paz
2026-03-09 16:26:58 -03:00
parent f4ef52e154
commit 557c04436d
11 changed files with 461 additions and 15 deletions

15
fix.cfg Normal file
View File

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

View File

@ -36,6 +36,11 @@ type Service struct {
AuthorizedServices map[string]AuthorizedService `toml:"AuthorizedServices"` AuthorizedServices map[string]AuthorizedService `toml:"AuthorizedServices"`
APIBasePort string APIBasePort string
EnableJWTAuth bool // Enable JWT authentication for service-to-service communication 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 { type ExtAuth struct {

View File

@ -32,18 +32,22 @@ type Controller struct {
pool *redis.Pool pool *redis.Pool
userData app.UserDataProvider userData app.UserDataProvider
store *store.Store store *store.Store
orderStore domain.OrderStore
fixSender domain.FIXSender
config Config config Config
notify domain.Notifier notify domain.Notifier
authMutex deadlock.Mutex authMutex deadlock.Mutex
} }
func newController(pool *redis.Pool, userData app.UserDataProvider, 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 { ) *Controller {
return &Controller{ return &Controller{
pool: pool, pool: pool,
userData: userData, userData: userData,
store: s, store: s,
orderStore: orderStore,
fixSender: fixSender,
config: config, config: config,
notify: n, notify: n,
} }
@ -288,3 +292,51 @@ func allowed(origin string, config Config) bool {
return false 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"})
}

View File

@ -1,5 +1,11 @@
package rest package rest
import (
"fmt"
"github.com/shopspring/decimal"
)
type HTTPError struct { type HTTPError struct {
Error string Error string
} }
@ -16,3 +22,42 @@ type Credentials struct {
type Session struct { type Session struct {
Email string 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
}

View File

@ -21,6 +21,8 @@ func SetRoutes(api *API) {
qfixdpl := v1.Group("/") qfixdpl := v1.Group("/")
qfixdpl.Use(cont.AuthRequired) qfixdpl.Use(cont.AuthRequired)
qfixdpl.GET("/health", cont.HealthCheck) qfixdpl.GET("/health", cont.HealthCheck)
qfixdpl.GET("/orders", cont.GetOrders)
qfixdpl.POST("/quotes", cont.SendQuote)
backoffice := qfixdpl.Group("/backoffice") backoffice := qfixdpl.Group("/backoffice")
backoffice.Use(cont.BackOfficeUser) backoffice.Use(cont.BackOfficeUser)

View File

@ -32,7 +32,7 @@ type Config struct {
EnableJWTAuth bool 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 // Set up Gin
var engine *gin.Engine var engine *gin.Engine
if version.Environment() == version.EnvironmentTypeProd { if version.Environment() == version.EnvironmentTypeProd {
@ -58,7 +58,7 @@ func New(userData app.UserDataProvider, storeInstance *store.Store, config Confi
} }
api := &API{ api := &API{
Controller: newController(NewPool(), userData, storeInstance, config, notify), Controller: newController(NewPool(), userData, storeInstance, orderStore, fixSender, config, notify),
Router: engine, Router: engine,
Port: config.Port, Port: config.Port,
} }

41
src/client/data/orders.go Normal file
View File

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

View File

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

141
src/client/fix/manager.go Normal file
View File

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

View File

@ -8,6 +8,7 @@ import (
"quantex.com/qfixdpl/src/app" "quantex.com/qfixdpl/src/app"
"quantex.com/qfixdpl/src/client/api/rest" "quantex.com/qfixdpl/src/client/api/rest"
"quantex.com/qfixdpl/src/client/data" "quantex.com/qfixdpl/src/client/data"
"quantex.com/qfixdpl/src/client/fix"
googlechat "quantex.com/qfixdpl/src/client/notify/google" googlechat "quantex.com/qfixdpl/src/client/notify/google"
"quantex.com/qfixdpl/src/client/store" "quantex.com/qfixdpl/src/client/store"
"quantex.com/qfixdpl/src/client/store/external" "quantex.com/qfixdpl/src/client/store/external"
@ -37,6 +38,13 @@ func Runner(cfg app.Config) error {
} }
userData := data.New() 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{ apiConfig := rest.Config{
Port: cfg.APIBasePort, Port: cfg.APIBasePort,
@ -46,7 +54,7 @@ func Runner(cfg app.Config) error {
EnableJWTAuth: cfg.EnableJWTAuth, EnableJWTAuth: cfg.EnableJWTAuth,
} }
api := rest.New(userData, appStore, apiConfig, notify) api := rest.New(userData, appStore, orderStore, fixManager, apiConfig, notify)
api.Run() api.Run()
cmd.WaitForInterruptSignal(nil) cmd.WaitForInterruptSignal(nil)

32
src/domain/order.go Normal file
View File

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