diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..f6bf1c4 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,20 @@ +# To get started with Dependabot version updates, you'll need to specify which +# package ecosystems to update and where the package manifests are located. +# Please see the documentation for all configuration options: +# https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates + +version: 2 +updates: + +# update go modules + - package-ecosystem: "gomod" # See documentation for possible values + directory: "/fix/golang" # Location of package manifests + schedule: + interval: "weekly" + +# Set update schedule for GitHub Actions + - package-ecosystem: "github-actions" + directory: "/fix/golang" + schedule: + # Check for updates to GitHub Actions every week + interval: "weekly" diff --git a/.github/workflows/bump-version.yml b/.github/workflows/bump-version.yml new file mode 100644 index 0000000..a7b541c --- /dev/null +++ b/.github/workflows/bump-version.yml @@ -0,0 +1,23 @@ +name: Bump version +on: + workflow_run: + workflows: ["CI.build-test-lint"] + branches: + - master + - main + types: + - completed + +jobs: + build: + if: ${{ github.event.workflow_run.conclusion == 'success' }} + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - name: Bump version and push tag + id: tag_version + uses: mathieudutour/github-tag-action@v6.1 + with: + github_token: ${{ secrets.GITHUB_TOKEN }} + + # check https://github.com/marketplace/actions/github-tag for creating auto-releases diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml new file mode 100644 index 0000000..cfb9550 --- /dev/null +++ b/.github/workflows/ci.yaml @@ -0,0 +1,50 @@ +name: CI.build-test-lint +on: + push: + tags: + - v* + branches: + - master + - main + pull_request: + branches: + - master + - main +permissions: + contents: read + +jobs: + build-test-lint: + permissions: + contents: read # for actions/checkout to fetch code + pull-requests: read # for golangci/golangci-lint-action to fetch pull requests + name: Build + runs-on: ubuntu-latest + strategy: + matrix: + go1_18_project: + - fix/golang + steps: + - name: Checkout source code + uses: actions/checkout@v3 + - name: Setup Go + uses: actions/setup-go@v3 + with: + go-version: '1.18' + - name: Install golangci-lint + run: | + curl -sSLO https://github.com/golangci/golangci-lint/releases/download/v$GOLANGCI_LINT_VERSION/golangci-lint-$GOLANGCI_LINT_VERSION-linux-amd64.tar.gz + shasum -a 256 golangci-lint-$GOLANGCI_LINT_VERSION-linux-amd64.tar.gz | grep "^$GOLANGCI_LINT_SHA256 " > /dev/null + tar -xf golangci-lint-$GOLANGCI_LINT_VERSION-linux-amd64.tar.gz + sudo mv golangci-lint-$GOLANGCI_LINT_VERSION-linux-amd64/golangci-lint /usr/local/bin/golangci-lint + rm -rf golangci-lint-$GOLANGCI_LINT_VERSION-linux-amd64* + env: + GOLANGCI_LINT_VERSION: '1.50.1' + GOLANGCI_LINT_SHA256: '4ba1dc9dbdf05b7bdc6f0e04bdfe6f63aa70576f51817be1b2540bbce017b69a' + - name: Build + run: cd ${{ matrix.go1_18_project }} && make build + - name: Test + run: cd ${{ matrix.go1_18_project }} && make test + - name: Lint + run: cd ${{ matrix.go1_18_project }} && make lint + diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml new file mode 100644 index 0000000..0516b58 --- /dev/null +++ b/.github/workflows/codeql-analysis.yml @@ -0,0 +1,75 @@ +# For most projects, this workflow file will not need changing; you simply need +# to commit it to your repository. +# +# You may wish to alter this file to override the set of languages analyzed, +# or to provide custom queries or build logic. +# +# ******** NOTE ******** +# We have attempted to detect the languages in your repository. Please check +# the `language` matrix defined below to confirm you have the correct set of +# supported CodeQL languages. +# +name: "CodeQL" + +on: + push: + branches: + - master + - main + pull_request: + # The branches below must be a subset of the branches above + branches: + - master + - main + schedule: + - cron: '55 05 * * 6' + +jobs: + analyze: + name: Analyze + runs-on: ubuntu-latest + permissions: + actions: read + contents: read + security-events: write + + strategy: + fail-fast: false + matrix: + language: [ 'go' ] + # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python' ] + # Learn more: + # https://docs.github.com/en/free-pro-team@latest/github/finding-security-vulnerabilities-and-errors-in-your-code/configuring-code-scanning#changing-the-languages-that-are-analyzed + + steps: + - name: Checkout repository + uses: actions/checkout@v3 + + # Initializes the CodeQL tools for scanning. + - name: Initialize CodeQL + uses: github/codeql-action/init@v2 + with: + languages: ${{ matrix.language }} + # If you wish to specify custom queries, you can do so here or in a config file. + # By default, queries listed here will override any specified in a config file. + # Prefix the list here with "+" to use these queries and those in the config file. + # queries: ./path/to/local/query, your-org/your-repo/queries@main + + # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). + # If this step fails, then you should remove it and run the build manually (see below) + - name: Autobuild + uses: github/codeql-action/autobuild@v2 + + # â„šī¸ Command-line programs to run using the OS shell. + # 📚 https://git.io/JvXDl + + # âœī¸ If the Autobuild fails above, remove it and uncomment the following three lines + # and modify them (or add more) to build your code if your project + # uses a compiled language + + #- run: | + # make bootstrap + # make release + + - name: Perform CodeQL Analysis + uses: github/codeql-action/analyze@v2 diff --git a/.gitignore b/.gitignore index 421af61..c73ebe0 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,4 @@ dist .sass-cache bower_components target +bin diff --git a/README.md b/README.md index e89e54a..5d69761 100644 --- a/README.md +++ b/README.md @@ -3,15 +3,17 @@ https://exmo.com/api_doc ### Supported languages: -- php. -- js. -- nodejs. -- c#. -- c++11. -- python. -- objective c. -- swift. -- java. -- ruby. -- golang. -- R. +| | rest | ws | fix | +|-------------|------------------------|---------------------------------------------------|-------------------| +| php | [yes](rest/php) | | | +| js | [yes](rest/js) | [yes](ws/js) | | +| nodejs | [yes](rest/nodejs) | | | +| c# | [yes](rest/c%23) | [yes](ws/.net) | | +| c++11 | [yes](rest/ҁ++) | | | +| python | [yes](rest/python) | [python2](ws/python2)
[python3](ws/python3) | | +| objective c | [yes](rest/objectivec) | | | +| swift | [yes](rest/swift) | | | +| java | [yes](rest/java) | [yes](ws/java) | | +| ruby | [yes](rest/ruby) | | | +| golang | [yes](rest/golang) | [yes](ws/golang) | [yes](fix/golang) | +| R | [yes](rest/r) | | | diff --git a/fix/golang/.golangci.yml b/fix/golang/.golangci.yml new file mode 100644 index 0000000..09e1646 --- /dev/null +++ b/fix/golang/.golangci.yml @@ -0,0 +1,102 @@ +run: + timeout: 30s + +linters: + enable: + - errcheck + - gosimple + - govet + - ineffassign + - staticcheck + - typecheck + - unused + - asasalint + - asciicheck + - bidichk + - bodyclose + - containedctx + - contextcheck + - cyclop + - decorder + - depguard + - dogsled + - dupl + - dupword + - durationcheck + - errchkjson + - errname + - errorlint + - execinquery + - exhaustive + - exhaustruct + - exportloopref + - forbidigo + - forcetypeassert + - funlen +# - gci + - gochecknoglobals + - gochecknoinits + - gocognit + - goconst + - gocritic + - gocyclo + - godot + - godox + - goerr113 + - gofmt + - gofumpt + - goheader + - goimports + - gomnd + - gomoddirectives + - gomodguard + - goprintffuncname + - gosec + - grouper + - importas + - interfacebloat +# - ireturn + - lll + - loggercheck + - maintidx + - makezero + - misspell + - nakedret + - nestif + - nilerr + - nilnil + - nlreturn + - noctx + - nolintlint + - nonamedreturns + - nosprintfhostport +# - paralleltest + - prealloc + - predeclared + - promlinter + - reassign + - revive + - rowserrcheck + - sqlclosecheck + - stylecheck + - tagliatelle + - tenv + - testableexamples + - testpackage + - thelper + - tparallel + - unconvert + - unparam + - usestdlibvars + - varnamelen + - wastedassign + - whitespace + - wrapcheck + - wsl + +linters-settings: + gofmt: + simplify: true + goimports: + local-prefixes: gitlab.exmoney.com/golang/exmo_api_lib/fix/golang + diff --git a/fix/golang/Makefile b/fix/golang/Makefile new file mode 100644 index 0000000..84fe8a3 --- /dev/null +++ b/fix/golang/Makefile @@ -0,0 +1,17 @@ + +dep: ; go mod tidy + +.PHONY: run +run: dep ; go run *.go + +.PHONY: all +all: build test lint + +.PHONY: build +build: dep ; go build -o bin/exmo-fix-client ./... + +.PHONY: test +test: dep ; go test ./... + +.PHONY: lint +lint: ; golangci-lint run diff --git a/fix/golang/auth.go b/fix/golang/auth.go new file mode 100644 index 0000000..f9fe860 --- /dev/null +++ b/fix/golang/auth.go @@ -0,0 +1,176 @@ +package main + +import ( + "bytes" + "crypto/hmac" + "crypto/sha512" + "encoding/base64" + "encoding/binary" + "log" + "strconv" + "time" + + "github.com/pkg/errors" + "github.com/quickfixgo/quickfix" + "github.com/quickfixgo/quickfix/enum" + "github.com/quickfixgo/quickfix/tag" +) + +const ( + delimiter = 0x01 +) + +type Presign struct { + SendingTime int64 + MsgSeqNum int + SenderCompID string + TargetCompID string + Password string +} + +func SignLogonMsg(msg *quickfix.Message, secret APISecret) { + if msg.IsMsgTypeOf(enum.MsgType_LOGON) { + // set passPhrase + msg.Body.SetString(tag.Password, strconv.FormatInt(time.Now().UTC().Unix(), 10)) + + // extract presign struct + presign, err := getMsgPresign(msg) + if err != nil { + log.Println(err) + + return + } + + // make preSignByte from presign + preSignByte, err := makePresignByte(presign) + if err != nil { + log.Println(err) + + return + } + + // sign is base64-encoded HMAC-hashed (with sha512-hash, and secret) preSignByte + sign := createSignFromBodyAndSecret(preSignByte, []byte(secret)) + + // sign the logonMessage + msg.Body.SetString(tag.RawData, sign) + } +} + +func getMsgPresign(msg *quickfix.Message) (*Presign, error) { + var senderCompID quickfix.FIXString + + err := msg.Header.GetField(tag.SenderCompID, &senderCompID) + if err != nil { + return nil, err + } + + var targetCompID quickfix.FIXString + + err = msg.Header.GetField(tag.TargetCompID, &targetCompID) + if err != nil { + return nil, err + } + + var msgSeqNum quickfix.FIXInt + + err = msg.Header.GetField(tag.MsgSeqNum, &msgSeqNum) + if err != nil { + return nil, err + } + + var sendingTime quickfix.FIXUTCTimestamp + + err = msg.Header.GetField(tag.SendingTime, &sendingTime) + if err != nil { + return nil, err + } + + var password quickfix.FIXString + + err = msg.Body.GetField(tag.Password, &password) + if err != nil { + return nil, err + } + + return &Presign{ + SendingTime: sendingTime.UTC().Unix(), + MsgSeqNum: msgSeqNum.Int(), + SenderCompID: senderCompID.String(), + TargetCompID: targetCompID.String(), + Password: password.String(), + }, nil +} + +func makePresignByte(msg *Presign) ([]byte, error) { + presignByte := new(bytes.Buffer) + + // sendingTime + binaryWriteErr := addToPresign(presignByte, msg.SendingTime, true) + if binaryWriteErr != nil { + return nil, binaryWriteErr + } + // msgSeqNum + binaryWriteErr = addToPresign(presignByte, int64(msg.MsgSeqNum), true) + if binaryWriteErr != nil { + return nil, binaryWriteErr + } + // senderCompID + binaryWriteErr = addToPresign(presignByte, msg.SenderCompID, true) + if binaryWriteErr != nil { + return nil, binaryWriteErr + } + // targetCompID + binaryWriteErr = addToPresign(presignByte, msg.TargetCompID, true) + if binaryWriteErr != nil { + return nil, binaryWriteErr + } + // password + binaryWriteErr = addToPresign(presignByte, msg.Password, false) + if binaryWriteErr != nil { + return nil, binaryWriteErr + } + + return presignByte.Bytes(), nil +} + +func addToPresign[Field int64 | string]( + presignByte *bytes.Buffer, + field Field, + withDelimeter bool, +) error { + switch typedField := any(field).(type) { + case string: + _, binaryWriteErr := presignByte.WriteString(typedField) + if binaryWriteErr != nil { + return errors.Wrap(binaryWriteErr, "unable to write presignString") + } + default: + binaryWriteErr := binary.Write(presignByte, binary.LittleEndian, typedField) + if binaryWriteErr != nil { + return errors.Wrap(binaryWriteErr, "unable to write presignByte") + } + } + + if withDelimeter { + return addDelimiter(presignByte) + } + + return nil +} + +func addDelimiter(presignByte *bytes.Buffer) error { + binaryWriteErr := presignByte.WriteByte(delimiter) + if binaryWriteErr != nil { + return errors.Wrap(binaryWriteErr, "unable to write delimiter") + } + + return nil +} + +func createSignFromBodyAndSecret(body, secret []byte) string { + mac := hmac.New(sha512.New, secret) + _, _ = mac.Write(body) + + return base64.StdEncoding.EncodeToString(mac.Sum(nil)) +} diff --git a/fix/golang/config/config.ini b/fix/golang/config/config.ini new file mode 100644 index 0000000..f8b0caf --- /dev/null +++ b/fix/golang/config/config.ini @@ -0,0 +1,22 @@ +[DEFAULT] +SocketConnectHost=127.0.0.1 +SocketConnectPort=5001 +HeartBtInt=30 +TargetCompID=EXMO +ResetOnLogon=Y +ResetOnDisconnect="Y" +FileLogPath=tmp + +[SESSION] +BeginString=FIX.4.4 +# this is your Api key obtained from https://exmo.com/profile/api +SenderCompID=K-1a07dcb30e6f6aea5421f5a24a321c5d5b78a952 +# this is your Api secret obtained from https://exmo.com/profile/api +Password=S-1b32ef93597b809a96a44e473a8413aacaaa9ac8 + +[SESSION] +BeginString=FIX.4.4 +# this is your Api key obtained from https://exmo.com/profile/api +SenderCompID=K-c3f69c0233b84eebd659ff58a6118bb2a704a628 +# this is your Api secret obtained from https://exmo.com/profile/api +Password=S-e3ff449f6b5772d41cff26da594679d1beca9631 diff --git a/fix/golang/go.mod b/fix/golang/go.mod new file mode 100644 index 0000000..3154f3b --- /dev/null +++ b/fix/golang/go.mod @@ -0,0 +1,20 @@ +module gitlab.exmoney.com/golang/exmo_api_lib/fix/golang + +go 1.18 + +require ( + github.com/fatih/color v1.13.0 + github.com/mikhalytch/eggs v0.0.37 + github.com/pkg/errors v0.9.1 + github.com/quickfixgo/quickfix v0.6.0 + github.com/shopspring/decimal v1.3.1 +) + +require ( + github.com/mattn/go-colorable v0.1.9 // indirect + github.com/mattn/go-isatty v0.0.14 // indirect + github.com/mattn/go-sqlite3 v1.14.16 // indirect + github.com/stretchr/objx v0.5.0 // indirect + golang.org/x/exp v0.0.0-20221114191408-850992195362 // indirect + golang.org/x/sys v0.1.0 // indirect +) diff --git a/fix/golang/go.sum b/fix/golang/go.sum new file mode 100644 index 0000000..bbee145 --- /dev/null +++ b/fix/golang/go.sum @@ -0,0 +1,40 @@ +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/fatih/color v1.13.0 h1:8LOYc1KYPPmyKMuN8QV2DNRWNbLo6LZ0iLs8+mlH53w= +github.com/fatih/color v1.13.0/go.mod h1:kLAiJbzzSOZDVNGyDpeOxJ47H46qBXwg5ILebYFFOfk= +github.com/mattn/go-colorable v0.1.9 h1:sqDoxXbdeALODt0DAeJCVp38ps9ZogZEAXjus69YV3U= +github.com/mattn/go-colorable v0.1.9/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= +github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU= +github.com/mattn/go-isatty v0.0.14 h1:yVuAays6BHfxijgZPzw+3Zlu5yQgKGP2/hcQbHb7S9Y= +github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94= +github.com/mattn/go-sqlite3 v1.14.16 h1:yOQRA0RpS5PFz/oikGwBEqvAWhWg5ufRz4ETLjwpU1Y= +github.com/mattn/go-sqlite3 v1.14.16/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg= +github.com/mikhalytch/eggs v0.0.37 h1:6BtOOz6NzdDHTn/Y9c+QiH2hbb/Yb4wZI/9U/8FJgSs= +github.com/mikhalytch/eggs v0.0.37/go.mod h1:fsVa/VF1bUEU8NSiwdKcc1p/rpv87iBnm2yTHhcJwl8= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/quickfixgo/quickfix v0.6.0 h1:sSUFaKiMVaaFLGgWaK1ZmwFNZeQ0/awu+IzEu3cJWJE= +github.com/quickfixgo/quickfix v0.6.0/go.mod h1:RuN5MIPnzolPNDYibgBXHhgMoTEjjPzcCN3rLFcODS4= +github.com/shopspring/decimal v1.3.1 h1:2Usl1nmF/WZucqkFZhnfFYxxxu8LG21F6nPQBE5gKV8= +github.com/shopspring/decimal v1.3.1/go.mod h1:DKyhrW/HYNuLGql+MJL6WCR6knT2jwCFRcu2hWCYk4o= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0 h1:1zr/of2m5FGMsad5YfcqgdqdWrIhu+EBEJRhR1U7z/c= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk= +golang.org/x/exp v0.0.0-20221114191408-850992195362 h1:NoHlPRbyl1VFI6FjwHtPQCN7wAMXI6cKcqrmXhOOfBQ= +golang.org/x/exp v0.0.0-20221114191408-850992195362/go.mod h1:CxIveKay+FTh1D0yPZemJVgC/95VzuuOLq5Qi4xnoYc= +golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.1.0 h1:kunALQeHf1/185U1i0GOB/fy1IPRDDpuoOOqRReG57U= +golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/fix/golang/main.go b/fix/golang/main.go new file mode 100644 index 0000000..121412c --- /dev/null +++ b/fix/golang/main.go @@ -0,0 +1,162 @@ +package main + +import ( + "bufio" + "bytes" + "context" + "fmt" + "io" + "log" + "os" + "os/signal" + "path" + "syscall" + + "github.com/fatih/color" + "github.com/pkg/errors" + "github.com/quickfixgo/quickfix" + "github.com/quickfixgo/quickfix/config" +) + +func main() { + if err := main0(); err != nil { + log.Fatalln(err) + } +} + +func main0() error { + sessionsCh := make(chan quickfix.SessionID) + defer close(sessionsCh) + + scenarioFinishedCh := make(chan struct{}) + defer close(scenarioFinishedCh) + + clientCloser, err := startClient(sessionsCh) + if err != nil { + return err + } + + defer clientCloser() + + ctx, ctxCloser := getNotifiedCtx() + defer ctxCloser() + + go func() { + defer func() { scenarioFinishedCh <- struct{}{} }() + + session, err := awaitLogon(ctx, sessionsCh) + if err != nil { + log.Println(err) + + return + } + + err = scenario(session) + if err != nil { + log.Println(err) + } + }() + + select { + case <-ctx.Done(): + case <-scenarioFinishedCh: + } + + return nil +} + +func startClient(logonCh chan quickfix.SessionID) (func(), error) { + configContents, err := os.ReadFile(path.Join("config", "config.ini")) + if err != nil { + return nil, errors.Wrap(err, "unable to read config") + } + + appSettings, err := quickfix.ParseSettings(bytes.NewReader(configContents)) + if err != nil { + return nil, fmt.Errorf("error reading ini: %w", err) + } + + sessions := appSettings.SessionSettings() + apiKeys := make(map[APIKey]APISecret, len(sessions)) + + for _, sessionSettings := range sessions { + sci, errS := sessionSettings.Setting(config.SenderCompID) + sec, errP := sessionSettings.Setting("Password") + + if errS == nil && errP == nil { + apiKeys[APIKey(sci)] = APISecret(sec) + } + } + + app := NewQuickFixApp(apiKeys, logonCh) + screenLogFactory := quickfix.NewScreenLogFactory() + + if err != nil { + return nil, fmt.Errorf("error creating file log factory: %w", err) + } + + initiator, err := quickfix.NewInitiator(app, quickfix.NewMemoryStoreFactory(), appSettings, screenLogFactory) + if err != nil { + return nil, fmt.Errorf("unable to create Initiator: %w", err) + } + + err = initiator.Start() + if err != nil { + return nil, fmt.Errorf("unable to start Initiator: %w", err) + } + + printConfig(bytes.NewReader(configContents)) + + return initiator.Stop, nil +} + +func printConfig(reader io.Reader) { + scanner := bufio.NewScanner(reader) + + color.Set(color.Bold) + log.Println("Started FIX initiator with config:") + color.Unset() + + color.Set(color.FgHiMagenta) + + for scanner.Scan() { + line := scanner.Text() + log.Println(line) + } + + color.Unset() +} + +func getNotifiedCtx() (context.Context, func()) { + // Listen to interrupt signal + ctx, stop := signal.NotifyContext(context.Background(), + syscall.SIGHUP, syscall.SIGINT, syscall.SIGTERM, syscall.SIGQUIT) + + return ctx, func() { + stop() + + if errors.Is(ctx.Err(), context.Canceled) { + log.Println("FIX initiator is stopped") + + return + } + + log.Println(ctx.Err()) + } +} + +func awaitLogon(ctx context.Context, sessionsCh chan quickfix.SessionID) (quickfix.SessionID, error) { + log.Println("awaiting logon...") + select { + case <-ctx.Done(): + return quickfix.SessionID{}, errors.Wrap(ctx.Err(), "context was closed while awaiting for logon") + case session, open := <-sessionsCh: + if !open { + return quickfix.SessionID{}, errors.New("no more sessions, closing") + } + + log.Println("logged on:", session) + + return session, nil + } +} diff --git a/fix/golang/quickfixapp.go b/fix/golang/quickfixapp.go new file mode 100644 index 0000000..036e1db --- /dev/null +++ b/fix/golang/quickfixapp.go @@ -0,0 +1,105 @@ +// Copyright (c) quickfixengine.org All rights reserved. +// +// This file may be distributed under the terms of the quickfixengine.org +// license as defined by quickfixengine.org and appearing in the file +// LICENSE included in the packaging of this file. +// +// This file is provided AS IS with NO WARRANTY OF ANY KIND, INCLUDING +// THE WARRANTY OF DESIGN, MERCHANTABILITY AND FITNESS FOR A +// PARTICULAR PURPOSE. +// +// See http://www.quickfixengine.org/LICENSE for licensing information. +// +// Contact ask@quickfixengine.org if any conditions of this licensing +// are not clear to you. + +package main + +import ( + "log" + + "github.com/pkg/errors" + "github.com/quickfixgo/quickfix" + "github.com/quickfixgo/quickfix/enum" +) + +type ( + APIKey string + APISecret string +) + +// QuickFixApp implements the quickfix.Application interface. +type QuickFixApp struct { + *quickfix.MessageRouter + keys map[APIKey]APISecret + logonCh chan quickfix.SessionID +} + +func NewQuickFixApp(keys map[APIKey]APISecret, logonSessionsCh chan quickfix.SessionID) *QuickFixApp { + res := &QuickFixApp{ + MessageRouter: quickfix.NewMessageRouter(), + keys: keys, + logonCh: logonSessionsCh, + } + setupRoutes(res.MessageRouter) + + return res +} + +// OnCreate implemented as part of Application interface. +func (e *QuickFixApp) OnCreate(quickfix.SessionID) {} + +// OnLogon implemented as part of Application interface. +func (e *QuickFixApp) OnLogon(sessionID quickfix.SessionID) { e.logonCh <- sessionID } + +// OnLogout implemented as part of Application interface. +func (e *QuickFixApp) OnLogout(quickfix.SessionID) {} + +// FromAdmin implemented as part of Application interface. +func (e *QuickFixApp) FromAdmin( + msg *quickfix.Message, + sessionID quickfix.SessionID, +) quickfix.MessageRejectError { + if msg.IsMsgTypeOf(enum.MsgType_LOGON) { + log.Printf("api key [%s] is logged on", sessionID.SenderCompID) + } + + return nil +} + +// ToAdmin implemented as part of Application interface. +func (e *QuickFixApp) ToAdmin(msg *quickfix.Message, sessionID quickfix.SessionID) { + apiKey := APIKey(sessionID.SenderCompID) + + secret, exist := e.keys[apiKey] + if !exist { + log.Fatalf("unknown api-key [%s] in sessionID", apiKey) + } + + SignLogonMsg(msg, secret) +} + +// ToApp implemented as part of Application interface. +func (e *QuickFixApp) ToApp(msg *quickfix.Message, _ quickfix.SessionID) error { + log.Printf("Sending %s\n", msg) + + return nil +} + +// FromApp implemented as part of Application interface. +// This is the callback for all Application level messages from the counterparty. +func (e *QuickFixApp) FromApp( + msg *quickfix.Message, + sessionID quickfix.SessionID, +) quickfix.MessageRejectError { + err := e.Route(msg, sessionID) + if err != nil { + if errors.Is(err, quickfix.UnsupportedMessageType()) { + log.Printf("FromApp: %s\n", msg.String()) + } else { + log.Fatalln(err) + } + } + + return nil +} diff --git a/fix/golang/routes.go b/fix/golang/routes.go new file mode 100644 index 0000000..7af993b --- /dev/null +++ b/fix/golang/routes.go @@ -0,0 +1,67 @@ +package main + +import ( + "log" + + "github.com/mikhalytch/eggs/math" + "github.com/quickfixgo/quickfix" + "github.com/quickfixgo/quickfix/fix44/executionreport" + "github.com/quickfixgo/quickfix/fix44/marketdatarequestreject" + "github.com/quickfixgo/quickfix/fix44/marketdatasnapshotfullrefresh" + "github.com/quickfixgo/quickfix/fix44/securitydefinition" + "github.com/quickfixgo/quickfix/fix44/securitylist" +) + +func setupRoutes(router *quickfix.MessageRouter) { + router.AddRoute(securitylist.Route(onSecurityList)) + router.AddRoute(securitydefinition.Route(onSecurityDefinition)) + router.AddRoute(marketdatasnapshotfullrefresh.Route(onMarketData)) + router.AddRoute(marketdatarequestreject.Route(onMarketDataReject)) + router.AddRoute(executionreport.Route(onExecutionReport)) +} + +func onExecutionReport(msg executionreport.ExecutionReport, _ quickfix.SessionID) quickfix.MessageRejectError { + log.Println("execution report received:", limitMsg(msg)) + + return nil +} + +func onMarketDataReject( + msg marketdatarequestreject.MarketDataRequestReject, + _ quickfix.SessionID, +) quickfix.MessageRejectError { + log.Println("market data reject received:", limitMsg(msg)) + + return nil +} + +func onMarketData( + msg marketdatasnapshotfullrefresh.MarketDataSnapshotFullRefresh, + _ quickfix.SessionID, +) quickfix.MessageRejectError { + log.Println("market data received:", limitMsg(msg)) + + return nil +} + +func onSecurityDefinition(msg securitydefinition.SecurityDefinition, _ quickfix.SessionID) quickfix.MessageRejectError { + log.Println("security definition received:", limitMsg(msg)) + + return nil +} + +func onSecurityList(msg securitylist.SecurityList, _ quickfix.SessionID) quickfix.MessageRejectError { + log.Println("security list received:", limitMsg(msg)) + + return nil +} + +// ----- + +func limitMsg(msg quickfix.Messagable) string { + const lengthLimit = 20 + + str := msg.ToMessage().String() + + return str[:math.Min(len(str), lengthLimit)] + "..." +} diff --git a/fix/golang/scenario.go b/fix/golang/scenario.go new file mode 100644 index 0000000..a829ca2 --- /dev/null +++ b/fix/golang/scenario.go @@ -0,0 +1,177 @@ +package main + +import ( + "log" + "strconv" + "time" + + "github.com/pkg/errors" + "github.com/quickfixgo/quickfix" + "github.com/quickfixgo/quickfix/enum" + "github.com/quickfixgo/quickfix/field" + "github.com/quickfixgo/quickfix/fix44/marketdatarequest" + "github.com/quickfixgo/quickfix/fix44/newordersingle" + "github.com/quickfixgo/quickfix/fix44/ordercancelrequest" + "github.com/quickfixgo/quickfix/fix44/securitydefinitionrequest" + "github.com/quickfixgo/quickfix/fix44/securitylistrequest" + "github.com/shopspring/decimal" +) + +type Sender func(m quickfix.Messagable) error + +// scenario performs imaginary actions sequence. +func scenario(session quickfix.SessionID) error { + const pair = "BTC_USDT" + + log.Println("scenario started") + + sender := func(m quickfix.Messagable) error { + err := quickfix.SendToTarget(m, session) + if err == nil { + time.Sleep(time.Second) // since the scenario is way no interactive, and due to async implementation nature + } + + return errors.Wrap(err, "unable to send quickfix msg") + } + + if err := requestSecurityList(sender); err != nil { + return errors.Wrap(err, "security list request") + } + + if err := requestSecurityDefinition(sender, pair); err != nil { + return errors.Wrap(err, "security definition request") + } + + mdRequestID, err := requestMarketData(sender, pair) + if err != nil { + return errors.Wrap(err, "market data request") + } + + orderID, err := createBuyOrder(sender, pair) + if err != nil { + return errors.Wrap(err, "create buy order") + } + + if err := cancelOrder(sender, orderID); err != nil { + return errors.Wrap(err, "order cancellation") + } + + if err := unsubscribeFromMarketData(sender, mdRequestID); err != nil { + return errors.Wrap(err, "market data unsubscription") + } + + if _, err := createSellOrder(sender, pair); err != nil { + return errors.Wrap(err, "create sell order") + } + + log.Println("scenario finished") + + return nil +} + +func cancelOrder(sender Sender, orderID string) error { + log.Println("order cancellation") + + cancelOrderRequest := ordercancelrequest.New( + field.NewOrigClOrdID(orderID), + field.NewClOrdID(""), // ignored + field.NewSide(""), // ignored + field.NewTransactTime(time.Now().UTC()), // ignored + ) + + return sender(cancelOrderRequest) +} + +func unsubscribeFromMarketData(sender Sender, mdRequestID string) error { + log.Println("market data unsubscription") + + return sender(marketdatarequest.New( + field.NewMDReqID(mdRequestID), + field.NewSubscriptionRequestType(enum.SubscriptionRequestType_DISABLE_PREVIOUS_SNAPSHOT_PLUS_UPDATE_REQUEST), + field.NewMarketDepth(0), // ignored + )) +} + +func createBuyOrder(sender Sender, pair string) (string, error) { + log.Println("creating buy order") + + return createOrder(sender, pair, enum.Side_BUY) +} + +func createSellOrder(sender Sender, pair string) (string, error) { + log.Println("creating sell order") + + return createOrder(sender, pair, enum.Side_SELL) +} + +func createOrder(sender Sender, pair string, side enum.Side) (string, error) { + const ( + quantityScale = 8 + priceScale = 8 + price = 15899 + ) + + orderID := strconv.Itoa(int(time.Now().UTC().Unix())) + + singleOrderRequest := newordersingle.New( + field.NewClOrdID(orderID), + field.NewSide(side), + field.NewTransactTime(time.Now().UTC()), + field.NewOrdType(enum.OrdType_LIMIT), + ) + singleOrderRequest.SetSymbol(pair) + singleOrderRequest.SetOrderQty(decimal.New(1, -2), quantityScale) // 0.01 + singleOrderRequest.SetTimeInForce(enum.TimeInForce_GOOD_TILL_CANCEL) + singleOrderRequest.SetExecInst("") + singleOrderRequest.SetPrice(decimal.NewFromInt(price), priceScale) + + return orderID, sender(singleOrderRequest) +} + +func requestMarketData(sender Sender, pair string) (string, error) { + log.Println("requesting market data") + + mdRequestID := strconv.Itoa(int(time.Now().UTC().Unix())) + mdReq := marketdatarequest.New( + field.NewMDReqID(mdRequestID), + field.NewSubscriptionRequestType(enum.SubscriptionRequestType_SNAPSHOT_PLUS_UPDATES), + field.NewMarketDepth(0), // ignored + ) + mdEntryTypesGrp := marketdatarequest.NewNoMDEntryTypesRepeatingGroup() + mdEntryTypes := mdEntryTypesGrp.Add() + mdEntryTypes.SetMDEntryType(enum.MDEntryType_BID) + mdEntryTypes = mdEntryTypesGrp.Add() + mdEntryTypes.SetMDEntryType(enum.MDEntryType_OFFER) + mdReq.SetNoMDEntryTypes(mdEntryTypesGrp) + + relatedSymGrp := marketdatarequest.NewNoRelatedSymRepeatingGroup() + relatedSym := relatedSymGrp.Add() + relatedSym.SetSymbol(pair) + mdReq.SetNoRelatedSym(relatedSymGrp) + + return mdRequestID, sender(mdReq) +} + +func requestSecurityDefinition(sender Sender, pair string) error { + log.Println("requesting security definition") + + requestID := strconv.Itoa(int(time.Now().UTC().Unix())) + securityDefinitionRequest := securitydefinitionrequest.New( + field.NewSecurityReqID(requestID), + field.NewSecurityRequestType(enum.SecurityRequestType_REQUEST_LIST_SECURITIES), + ) + securityDefinitionRequest.SetSymbol(pair) + + return sender(securityDefinitionRequest) +} + +func requestSecurityList(sender Sender) error { + log.Println("requesting security list") + + requestID := strconv.Itoa(int(time.Now().UTC().Unix())) + + return sender(securitylistrequest.New( + field.NewSecurityReqID(requestID), + field.NewSecurityListRequestType(enum.SecurityListRequestType_ALL_SECURITIES)), + ) +}