├── .gitignore ├── web ├── .prettierrc ├── static │ ├── v.png │ ├── favicon.ico │ └── vuetify-logo.svg ├── assets │ └── variables.scss ├── .editorconfig ├── stylelint.config.js ├── .eslintrc.js ├── plugins │ └── filters.js ├── components │ ├── VuetifyLogo.vue │ ├── fields │ │ ├── FieldVariable.vue │ │ ├── FieldInteger.vue │ │ ├── FieldString.vue │ │ ├── FieldBoolean.vue │ │ ├── FieldDecimal.vue │ │ ├── ExFieldFigiSelect.vue │ │ └── AnyField.vue │ ├── SelectFunc.vue │ ├── NuxtLogo.vue │ ├── StackFunc.vue │ ├── selects │ │ ├── SelectStack.vue │ │ └── SelectAccount.vue │ ├── DlgTestStack.vue │ ├── DlgCreateStack.vue │ └── StackLine.vue ├── store │ └── README.md ├── pages │ ├── inspire.vue │ ├── index.vue │ ├── stacks.vue │ ├── strategies.vue │ └── sandbox.vue ├── grpc │ ├── instance.js │ └── mappers.js ├── layouts │ ├── error.vue │ └── default.vue ├── .gitignore ├── package.json ├── .prettierignore ├── nuxt.config.js └── README.md ├── docs └── TraderStack.gif ├── internal ├── engine │ ├── baseoption │ │ └── variable.go │ ├── stackitem.go │ ├── stack.go │ ├── stackfunc.go │ ├── variable.go │ ├── stackfuncrepository.go │ ├── teststack.go │ ├── options.go │ └── stackmanager.go ├── domain │ ├── utils.go │ ├── price.go │ ├── strategy.go │ ├── share.go │ ├── candle.go │ ├── users.go │ ├── operations.go │ ├── common.go │ └── order.go ├── algofunc │ ├── ema.go │ ├── filter.go │ ├── rsa.go │ └── rsa_test.go ├── grpcsrv │ ├── proto │ │ ├── prototool.yaml │ │ └── liderman │ │ │ └── traderstack │ │ │ ├── info │ │ │ └── v1 │ │ │ │ ├── info.proto │ │ │ │ └── info_api.proto │ │ │ ├── strategy │ │ │ └── v1 │ │ │ │ ├── strategy.proto │ │ │ │ └── strategy_api.proto │ │ │ └── stack │ │ │ └── v1 │ │ │ ├── stack.proto │ │ │ └── stack_api.proto │ ├── strategy_to_domain_mapper.go │ ├── info_to_protobuf_mapper.go │ ├── strategy_to_protobuf_mapper.go │ ├── server_strategy.go │ ├── to_domain_mapper.go │ ├── server_stack.go │ ├── server_info.go │ └── to_protobuf_mapper.go ├── apiclient │ ├── users.go │ ├── limits.go │ ├── instrument.go │ ├── operations.go │ ├── marketdata.go │ ├── order.go │ ├── client.go │ └── sandbox.go ├── stackfuncs │ ├── boolean_test.go │ ├── string_test.go │ ├── integer_test.go │ ├── decimal_test.go │ ├── string.go │ ├── integer.go │ ├── boolean.go │ ├── abs.go │ ├── decimal.go │ ├── figibyticker.go │ ├── eq_test.go │ ├── ne_test.go │ ├── ge_test.go │ ├── le_test.go │ ├── eq.go │ ├── ne.go │ ├── add.go │ ├── div.go │ ├── mod.go │ ├── mul.go │ ├── sub.go │ ├── ge.go │ ├── le.go │ ├── abs_test.go │ ├── div_test.go │ ├── round.go │ ├── round_test.go │ ├── add_test.go │ ├── sub_test.go │ ├── mod_test.go │ ├── mul_test.go │ ├── portfoliolots.go │ ├── inordersbuymarketlots.go │ ├── inorderssellmarketlots.go │ ├── rsi.go │ ├── actionbuymarket.go │ ├── actionsellmarket.go │ └── actiontakeprofit.go ├── grpc │ ├── mapper │ │ ├── fromdomain_test.go │ │ ├── todomain_test.go │ │ └── fromdomain.go │ └── proto │ │ └── tinkoff │ │ └── investapi │ │ ├── common.proto │ │ └── sandbox.proto └── datamanager │ ├── instruments.go │ ├── marketdata_test.go │ └── marketdata.go ├── .github └── workflows │ ├── codecov.yml │ ├── golangci-lint.yml │ └── test.yml ├── .golangci.yml ├── Dockerfile ├── cmd └── traderstack │ ├── grpc_options.go │ └── config.go └── go.mod /.gitignore: -------------------------------------------------------------------------------- 1 | cmd/traderstack/traderstack 2 | .idea 3 | vendor 4 | -------------------------------------------------------------------------------- /web/.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "semi": false, 3 | "singleQuote": true 4 | } 5 | -------------------------------------------------------------------------------- /web/static/v.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/liderman/traderstack/HEAD/web/static/v.png -------------------------------------------------------------------------------- /docs/TraderStack.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/liderman/traderstack/HEAD/docs/TraderStack.gif -------------------------------------------------------------------------------- /web/static/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/liderman/traderstack/HEAD/web/static/favicon.ico -------------------------------------------------------------------------------- /internal/engine/baseoption/variable.go: -------------------------------------------------------------------------------- 1 | package baseoption 2 | 3 | type Variable struct { 4 | Name string 5 | } 6 | -------------------------------------------------------------------------------- /web/assets/variables.scss: -------------------------------------------------------------------------------- 1 | // Ref: https://github.com/nuxt-community/vuetify-module#customvariables 2 | // 3 | // The variables you want to modify 4 | // $font-size-root: 20px; 5 | -------------------------------------------------------------------------------- /internal/engine/stackitem.go: -------------------------------------------------------------------------------- 1 | package engine 2 | 3 | type StackItem struct { 4 | Variable string 5 | StackFunc *StackFunc 6 | } 7 | 8 | type SetStackItem struct { 9 | Variable string 10 | StackFunc *SetStackFunc 11 | } 12 | -------------------------------------------------------------------------------- /web/.editorconfig: -------------------------------------------------------------------------------- 1 | # editorconfig.org 2 | root = true 3 | 4 | [*] 5 | indent_style = space 6 | indent_size = 2 7 | end_of_line = lf 8 | charset = utf-8 9 | trim_trailing_whitespace = true 10 | insert_final_newline = true 11 | 12 | [*.md] 13 | trim_trailing_whitespace = false 14 | -------------------------------------------------------------------------------- /internal/domain/utils.go: -------------------------------------------------------------------------------- 1 | package domain 2 | 3 | import "github.com/shopspring/decimal" 4 | 5 | func HistoricCandleToSliceClose(candles []*HistoricCandle) []decimal.Decimal { 6 | ret := make([]decimal.Decimal, 0, len(candles)) 7 | for _, v := range candles { 8 | ret = append(ret, v.Close) 9 | } 10 | return ret 11 | } 12 | -------------------------------------------------------------------------------- /web/stylelint.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | customSyntax: 'postcss-html', 3 | extends: [ 4 | 'stylelint-config-standard', 5 | 'stylelint-config-recommended-vue', 6 | 'stylelint-config-prettier', 7 | ], 8 | // add your custom config here 9 | // https://stylelint.io/user-guide/configuration 10 | rules: {}, 11 | } 12 | -------------------------------------------------------------------------------- /web/.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | env: { 4 | browser: true, 5 | node: true, 6 | }, 7 | parserOptions: { 8 | parser: '@babel/eslint-parser', 9 | requireConfigFile: false, 10 | }, 11 | extends: ['@nuxtjs', 'plugin:nuxt/recommended', 'prettier'], 12 | plugins: [], 13 | // add your custom rules here 14 | rules: {}, 15 | } 16 | -------------------------------------------------------------------------------- /web/plugins/filters.js: -------------------------------------------------------------------------------- 1 | import Vue from 'vue'; 2 | import { format } from 'date-fns'; 3 | 4 | Vue.filter('datetime', (val) => { 5 | if (!val) { 6 | return ''; 7 | } 8 | 9 | if (typeof val == 'object') { 10 | return format(new Date(val.seconds*1000 + val.nanos/1000), 'yyyy.MM.dd HH:mm:SS'); 11 | } 12 | 13 | return format(val, 'yyyy.MM.dd HH:mm:SS'); 14 | }); 15 | -------------------------------------------------------------------------------- /internal/algofunc/ema.go: -------------------------------------------------------------------------------- 1 | package algofunc 2 | 3 | func CalcEma(numbers []float64, n int) []float64 { 4 | m := len(numbers) 5 | a := float64(2) / float64(n+1) 6 | var ema []float64 7 | 8 | ema = append(ema, numbers[0]) 9 | 10 | for i := 1; i < m; i++ { 11 | ema = append( 12 | ema, 13 | (a*numbers[i])+((1-a)*ema[i-1]), 14 | ) 15 | } 16 | 17 | return ema 18 | } 19 | -------------------------------------------------------------------------------- /internal/engine/stack.go: -------------------------------------------------------------------------------- 1 | package engine 2 | 3 | import ( 4 | "github.com/google/uuid" 5 | ) 6 | 7 | // Stack Стек операций. 8 | type Stack struct { 9 | Id string 10 | Name string 11 | Items []*StackItem 12 | } 13 | 14 | func NewStack(name string) *Stack { 15 | return &Stack{ 16 | Id: uuid.New().String(), 17 | Name: name, 18 | Items: []*StackItem{}, 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /web/components/VuetifyLogo.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 19 | -------------------------------------------------------------------------------- /web/store/README.md: -------------------------------------------------------------------------------- 1 | # STORE 2 | 3 | **This directory is not required, you can delete it if you don't want to use it.** 4 | 5 | This directory contains your Vuex Store files. 6 | Vuex Store option is implemented in the Nuxt.js framework. 7 | 8 | Creating a file in this directory automatically activates the option in the framework. 9 | 10 | More information about the usage of this directory in [the documentation](https://nuxtjs.org/guide/vuex-store). 11 | -------------------------------------------------------------------------------- /internal/domain/price.go: -------------------------------------------------------------------------------- 1 | package domain 2 | 3 | import ( 4 | "github.com/shopspring/decimal" 5 | "time" 6 | ) 7 | 8 | // LastPrice Информация о цене. 9 | type LastPrice struct { 10 | // Идентификатор инструмента. 11 | Figi string 12 | // Последняя цена за 1 инструмент. Для получения стоимости лота требуется умножить на лотность инструмента. 13 | Price decimal.Decimal 14 | // Время получения последней цены в часовом поясе UTC по времени биржи. 15 | Time time.Time 16 | } 17 | -------------------------------------------------------------------------------- /internal/grpcsrv/proto/prototool.yaml: -------------------------------------------------------------------------------- 1 | protoc: 2 | version: 3.8.0 3 | lint: 4 | group: uber2 5 | generate: 6 | go_options: 7 | import_path: github.com/liderman/traderstack/internal/grpcsrv/proto 8 | plugins: 9 | - name: go 10 | type: go 11 | flags: plugins=grpc 12 | output: ../gen/go 13 | - name: js 14 | flags: import_style=commonjs 15 | output: ../gen/js 16 | - name: grpc-web 17 | flags: import_style=commonjs,mode=grpcwebtext 18 | output: ../gen/js -------------------------------------------------------------------------------- /internal/algofunc/filter.go: -------------------------------------------------------------------------------- 1 | package algofunc 2 | 3 | func FilterUp(numbers []float64) []float64 { 4 | var ret []float64 5 | for i := 1; i < len(numbers); i++ { 6 | if numbers[i] > numbers[i-1] { 7 | ret = append(ret, numbers[i]) 8 | } 9 | } 10 | 11 | return ret 12 | } 13 | 14 | func FilterDown(numbers []float64) []float64 { 15 | var ret []float64 16 | for i := 1; i < len(numbers); i++ { 17 | if numbers[i] < numbers[i-1] { 18 | ret = append(ret, numbers[i]) 19 | } 20 | } 21 | 22 | return ret 23 | } 24 | -------------------------------------------------------------------------------- /web/static/vuetify-logo.svg: -------------------------------------------------------------------------------- 1 | Artboard 46 2 | -------------------------------------------------------------------------------- /web/pages/inspire.vue: -------------------------------------------------------------------------------- 1 | 16 | 17 | 22 | -------------------------------------------------------------------------------- /internal/domain/strategy.go: -------------------------------------------------------------------------------- 1 | package domain 2 | 3 | import "time" 4 | 5 | type Strategy struct { 6 | Id string 7 | StackId string 8 | AccountId string 9 | RunInterval time.Duration 10 | Enabled bool 11 | } 12 | 13 | type StrategyLogType string 14 | 15 | const ( 16 | StrategyLogTypeAction StrategyLogType = "action" 17 | StrategyLogTypeStart StrategyLogType = "start" 18 | StrategyLogTypeError StrategyLogType = "error" 19 | ) 20 | 21 | type StrategyLog struct { 22 | LogType StrategyLogType 23 | Message string 24 | Time time.Time 25 | } 26 | -------------------------------------------------------------------------------- /.github/workflows/codecov.yml: -------------------------------------------------------------------------------- 1 | name: Test and coverage 2 | on: [push, pull_request] 3 | jobs: 4 | build: 5 | runs-on: ubuntu-latest 6 | steps: 7 | - name: checkout 8 | uses: actions/checkout@v2 9 | with: 10 | fetch-depth: 2 11 | - name: Set up Go 12 | uses: actions/setup-go@v2 13 | with: 14 | go-version: '1.18' 15 | - name: Run coverage 16 | run: go test -race -coverprofile=coverage.txt -covermode=atomic ./... 17 | - name: Upload coverage to Codecov 18 | run: bash <(curl -s https://codecov.io/bash) -------------------------------------------------------------------------------- /.github/workflows/golangci-lint.yml: -------------------------------------------------------------------------------- 1 | name: golangci-lint 2 | on: [push, pull_request] 3 | jobs: 4 | golangci: 5 | strategy: 6 | matrix: 7 | go-version: [1.18.x] 8 | name: lint 9 | runs-on: ubuntu-latest 10 | steps: 11 | - name: checkout 12 | uses: actions/checkout@v2 13 | - name: Set up Go 14 | uses: actions/setup-go@v2 15 | with: 16 | go-version: ${{ matrix.go-version }} 17 | run: go mod vendor 18 | - name: golangci-lint 19 | uses: golangci/golangci-lint-action@v3 20 | with: 21 | version: v1.46.2 -------------------------------------------------------------------------------- /internal/apiclient/users.go: -------------------------------------------------------------------------------- 1 | package apiclient 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "github.com/liderman/traderstack/internal/domain" 7 | "github.com/liderman/traderstack/internal/grpc/gen/tinkoff/investapi" 8 | "github.com/liderman/traderstack/internal/grpc/mapper" 9 | ) 10 | 11 | func (a *RealClient) GetAccounts() ([]*domain.Account, error) { 12 | resp, err := a.cUsers.GetAccounts(context.Background(), &investapi.GetAccountsRequest{}) 13 | if err != nil { 14 | return nil, fmt.Errorf("error api GetAccounts: %w", err) 15 | } 16 | 17 | return mapper.MapFromAccounts(resp.Accounts), nil 18 | } 19 | -------------------------------------------------------------------------------- /web/grpc/instance.js: -------------------------------------------------------------------------------- 1 | import { StackAPIPromiseClient } from "./gen/js/liderman/traderstack/stack/v1/stack_api_grpc_web_pb"; 2 | import { InfoAPIPromiseClient } from "./gen/js/liderman/traderstack/info/v1/info_api_grpc_web_pb"; 3 | import { StrategyAPIPromiseClient } from "./gen/js/liderman/traderstack/strategy/v1/strategy_api_grpc_web_pb"; 4 | 5 | const clientStack = new StackAPIPromiseClient('/rpc', null, null); 6 | const clientInfo = new InfoAPIPromiseClient('/rpc', null, null); 7 | const clientStrategy = new StrategyAPIPromiseClient('/rpc', null, null); 8 | 9 | export { 10 | clientStack, 11 | clientInfo, 12 | clientStrategy, 13 | } 14 | -------------------------------------------------------------------------------- /internal/apiclient/limits.go: -------------------------------------------------------------------------------- 1 | package apiclient 2 | 3 | import ( 4 | "github.com/liderman/traderstack/internal/domain" 5 | "time" 6 | ) 7 | 8 | func LimitGetHistoricCandlesDuration(interval domain.CandleInterval) time.Duration { 9 | switch interval { 10 | case domain.CandleInterval1Min: 11 | return time.Hour * 24 12 | case domain.CandleInterval5Min: 13 | return time.Hour * 24 14 | case domain.CandleInterval15Min: 15 | return time.Hour * 24 16 | case domain.CandleInterval1Hour: 17 | return time.Hour * 24 * 7 18 | case domain.CandleInterval1Day: 19 | return time.Hour * 24 * 356 20 | } 21 | 22 | return time.Hour * 24 23 | } 24 | -------------------------------------------------------------------------------- /.golangci.yml: -------------------------------------------------------------------------------- 1 | run: 2 | timeout: 5m 3 | modules-download-mode: vendor 4 | skip-files: 5 | -*_test.go 6 | 7 | issues: 8 | exclude: 9 | - G402 # TLS MinVersion too low 10 | - G404 # Use of weak random number generator 11 | - G401 # Use of weak cryptographic primitive 12 | - G501 # Blacklisted import `crypto/md5`: weak cryptographic primitive 13 | 14 | linters: 15 | enable: 16 | - prealloc 17 | - dogsled 18 | - exportloopref 19 | - unconvert 20 | - unparam 21 | - whitespace 22 | - bodyclose 23 | - gosec 24 | - asciicheck 25 | - depguard 26 | - errorlint 27 | - goconst 28 | - gocritic -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM golang:1.18 as gobuilder 2 | 3 | WORKDIR /build 4 | 5 | COPY . . 6 | 7 | RUN cd cmd/traderstack && \ 8 | CGO_ENABLED=0 go build 9 | 10 | FROM node:16.15.0-alpine as jsbuilder 11 | 12 | WORKDIR /build 13 | 14 | COPY web web 15 | 16 | RUN cd web && \ 17 | yarn && \ 18 | yarn build && \ 19 | yarn generate 20 | 21 | FROM alpine:3.16.0 22 | 23 | WORKDIR /var/www/traderstack 24 | 25 | COPY --from=gobuilder /build/cmd/traderstack/traderstack /usr/local/bin/traderstack 26 | COPY --from=jsbuilder /build/web/dist /var/www/traderstack/static 27 | 28 | ENV TS_STATIC_FILES_DIR /var/www/traderstack/static 29 | 30 | CMD ["traderstack"] -------------------------------------------------------------------------------- /internal/grpcsrv/strategy_to_domain_mapper.go: -------------------------------------------------------------------------------- 1 | package grpcsrv 2 | 3 | import ( 4 | "github.com/liderman/traderstack/internal/domain" 5 | strategyv1 "github.com/liderman/traderstack/internal/grpcsrv/gen/go/liderman/traderstack/strategy/v1" 6 | ) 7 | 8 | type StrategyToDomainMapper struct { 9 | } 10 | 11 | func (t *StrategyToDomainMapper) MapStrategy(in *strategyv1.Strategy) *domain.Strategy { 12 | if in == nil { 13 | return nil 14 | } 15 | return &domain.Strategy{ 16 | Id: in.Id, 17 | StackId: in.StackId, 18 | AccountId: in.AccountId, 19 | RunInterval: in.RunIntervalDuration.AsDuration(), 20 | Enabled: in.Enabled, 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: test 2 | on: [push, pull_request] 3 | jobs: 4 | test: 5 | strategy: 6 | matrix: 7 | go-version: [1.18.x] 8 | name: test 9 | runs-on: ubuntu-latest 10 | steps: 11 | - name: checkout 12 | uses: actions/checkout@v2 13 | - name: Set up Go 14 | uses: actions/setup-go@v2 15 | with: 16 | go-version: ${{ matrix.go-version }} 17 | - name: run tests 18 | run: go test -json ./... > test.json 19 | - name: Annotate tests 20 | if: always() 21 | uses: guyarb/golang-test-annotations@v0.5.1 22 | with: 23 | test-results: test.json -------------------------------------------------------------------------------- /internal/apiclient/instrument.go: -------------------------------------------------------------------------------- 1 | package apiclient 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "github.com/liderman/traderstack/internal/domain" 7 | "github.com/liderman/traderstack/internal/grpc/gen/tinkoff/investapi" 8 | "github.com/liderman/traderstack/internal/grpc/mapper" 9 | ) 10 | 11 | func (a *RealClient) GetShares() ([]*domain.Share, error) { 12 | resp, err := a.cInstrument.Shares(context.Background(), &investapi.InstrumentsRequest{ 13 | InstrumentStatus: investapi.InstrumentStatus_INSTRUMENT_STATUS_BASE, 14 | }) 15 | if err != nil { 16 | return nil, fmt.Errorf("error api Shares: %w", err) 17 | } 18 | 19 | return mapper.MapFromShares(resp.Instruments), nil 20 | } 21 | -------------------------------------------------------------------------------- /internal/apiclient/operations.go: -------------------------------------------------------------------------------- 1 | package apiclient 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "github.com/liderman/traderstack/internal/domain" 7 | "github.com/liderman/traderstack/internal/grpc/gen/tinkoff/investapi" 8 | "github.com/liderman/traderstack/internal/grpc/mapper" 9 | ) 10 | 11 | func (a *RealClient) GetPortfolio(accountId string) ([]*domain.PortfolioPosition, error) { 12 | resp, err := a.cOperations.GetPortfolio(context.Background(), &investapi.PortfolioRequest{ 13 | AccountId: accountId, 14 | }) 15 | if err != nil { 16 | return nil, fmt.Errorf("error api GetPortfolio: %w", err) 17 | } 18 | 19 | return mapper.MapFromPortfolioPositions(resp.Positions), nil 20 | } 21 | -------------------------------------------------------------------------------- /internal/stackfuncs/boolean_test.go: -------------------------------------------------------------------------------- 1 | package stackfuncs 2 | 3 | import ( 4 | "github.com/liderman/traderstack/internal/engine" 5 | "github.com/stretchr/testify/assert" 6 | "testing" 7 | "time" 8 | ) 9 | 10 | func TestBoolean_Run(t *testing.T) { 11 | fnc := NewBoolean() 12 | var args []*engine.Argument 13 | 14 | opts := engine.NewOptions(args, map[string]interface{}{}) 15 | result, err := fnc.Run(opts, time.Now(), "", false) 16 | 17 | assert.Error(t, err) 18 | assert.Equal(t, false, result) 19 | 20 | args = fnc.Arguments() 21 | args[0].Value = true 22 | opts = engine.NewOptions(args, map[string]interface{}{}) 23 | result, err = fnc.Run(opts, time.Now(), "", false) 24 | 25 | assert.NoError(t, err) 26 | assert.Equal(t, true, result) 27 | } 28 | -------------------------------------------------------------------------------- /internal/stackfuncs/string_test.go: -------------------------------------------------------------------------------- 1 | package stackfuncs 2 | 3 | import ( 4 | "github.com/liderman/traderstack/internal/engine" 5 | "github.com/stretchr/testify/assert" 6 | "testing" 7 | "time" 8 | ) 9 | 10 | func TestString_Run(t *testing.T) { 11 | fnc := NewString() 12 | var args []*engine.Argument 13 | 14 | opts := engine.NewOptions(args, map[string]interface{}{}) 15 | result, err := fnc.Run(opts, time.Now(), "", false) 16 | 17 | assert.Error(t, err) 18 | assert.Equal(t, "", result) 19 | 20 | args = fnc.Arguments() 21 | args[0].Value = "test" 22 | opts = engine.NewOptions(args, map[string]interface{}{}) 23 | result, err = fnc.Run(opts, time.Now(), "", false) 24 | 25 | assert.NoError(t, err) 26 | assert.Equal(t, "test", result) 27 | } 28 | -------------------------------------------------------------------------------- /internal/stackfuncs/integer_test.go: -------------------------------------------------------------------------------- 1 | package stackfuncs 2 | 3 | import ( 4 | "github.com/liderman/traderstack/internal/engine" 5 | "github.com/stretchr/testify/assert" 6 | "testing" 7 | "time" 8 | ) 9 | 10 | func TestInteger_Run(t *testing.T) { 11 | fnc := NewInteger() 12 | var args []*engine.Argument 13 | 14 | opts := engine.NewOptions(args, map[string]interface{}{}) 15 | result, err := fnc.Run(opts, time.Now(), "", false) 16 | 17 | assert.Error(t, err) 18 | assert.Equal(t, int64(0), result) 19 | 20 | args = fnc.Arguments() 21 | args[0].Value = int64(100) 22 | opts = engine.NewOptions(args, map[string]interface{}{}) 23 | result, err = fnc.Run(opts, time.Now(), "", false) 24 | 25 | assert.NoError(t, err) 26 | assert.Equal(t, int64(100), result) 27 | } 28 | -------------------------------------------------------------------------------- /internal/stackfuncs/decimal_test.go: -------------------------------------------------------------------------------- 1 | package stackfuncs 2 | 3 | import ( 4 | "github.com/liderman/traderstack/internal/engine" 5 | "github.com/shopspring/decimal" 6 | "github.com/stretchr/testify/assert" 7 | "testing" 8 | "time" 9 | ) 10 | 11 | func TestDecimal_Run(t *testing.T) { 12 | fnc := NewDecimal() 13 | var args []*engine.Argument 14 | 15 | opts := engine.NewOptions(args, map[string]interface{}{}) 16 | result, err := fnc.Run(opts, time.Now(), "", false) 17 | 18 | assert.Error(t, err) 19 | assert.Equal(t, decimal.Zero, result) 20 | 21 | args = fnc.Arguments() 22 | val := decimal.NewFromInt(100) 23 | args[0].Value = val 24 | opts = engine.NewOptions(args, map[string]interface{}{}) 25 | result, err = fnc.Run(opts, time.Now(), "", false) 26 | 27 | assert.NoError(t, err) 28 | assert.Equal(t, val, result) 29 | } 30 | -------------------------------------------------------------------------------- /internal/domain/share.go: -------------------------------------------------------------------------------- 1 | package domain 2 | 3 | import "github.com/shopspring/decimal" 4 | 5 | type Share struct { 6 | Figi string 7 | Ticker string 8 | // Класс-код (секция торгов). 9 | ClassCode string 10 | Isin string 11 | Lot int32 12 | Currency string 13 | Name string 14 | // Торговая площадка. 15 | Exchange string 16 | // Текущий режим торгов инструмента. 17 | TradingStatus SecurityTradingStatus 18 | // Признак внебиржевой ценной бумаги. 19 | OtcFlag bool 20 | // Признак доступности для покупки. 21 | BuyAvailableFlag bool 22 | // Признак доступности для продажи. 23 | SellAvailableFlag bool 24 | // Шаг цены. 25 | MinPriceIncrement decimal.Decimal 26 | // Признак доступности торгов через API. 27 | ApiTradeAvailableFlag bool 28 | // Уникальный идентификатор инструмента. 29 | Uid string 30 | } 31 | -------------------------------------------------------------------------------- /web/layouts/error.vue: -------------------------------------------------------------------------------- 1 | 12 | 13 | 38 | 39 | 44 | -------------------------------------------------------------------------------- /internal/stackfuncs/string.go: -------------------------------------------------------------------------------- 1 | package stackfuncs 2 | 3 | import ( 4 | "github.com/liderman/traderstack/internal/engine" 5 | "time" 6 | ) 7 | 8 | type String struct { 9 | } 10 | 11 | func NewString() *String { 12 | return &String{} 13 | } 14 | 15 | func (s *String) Name() string { 16 | return "String" 17 | } 18 | 19 | func (s *String) BaseType() string { 20 | return engine.BaseTypeString 21 | } 22 | 23 | func (s *String) Run(options *engine.Options, now time.Time, accountId string, isTest bool) (interface{}, error) { 24 | return options.GetString("value") 25 | } 26 | 27 | func (s *String) Arguments() []*engine.Argument { 28 | return []*engine.Argument{ 29 | { 30 | Id: "value", 31 | Name: "Строка", 32 | Desc: "", 33 | BaseType: "string", 34 | ExtendedType: "", 35 | Required: true, 36 | Value: "", 37 | }, 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /internal/stackfuncs/integer.go: -------------------------------------------------------------------------------- 1 | package stackfuncs 2 | 3 | import ( 4 | "github.com/liderman/traderstack/internal/engine" 5 | "time" 6 | ) 7 | 8 | type Integer struct { 9 | } 10 | 11 | func NewInteger() *Integer { 12 | return &Integer{} 13 | } 14 | 15 | func (i *Integer) Name() string { 16 | return "Integer" 17 | } 18 | 19 | func (i *Integer) BaseType() string { 20 | return engine.BaseTypeInteger 21 | } 22 | 23 | func (i *Integer) Run(options *engine.Options, now time.Time, accountId string, isTest bool) (interface{}, error) { 24 | return options.GetInteger("value") 25 | } 26 | 27 | func (i *Integer) Arguments() []*engine.Argument { 28 | return []*engine.Argument{ 29 | { 30 | Id: "value", 31 | Name: "Число", 32 | Desc: "", 33 | BaseType: "integer", 34 | ExtendedType: "", 35 | Required: true, 36 | Value: 0, 37 | }, 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /internal/grpcsrv/proto/liderman/traderstack/info/v1/info.proto: -------------------------------------------------------------------------------- 1 | syntax = "proto3"; 2 | 3 | package liderman.traderstack.info.v1; 4 | 5 | option csharp_namespace = "Liderman.Traderstack.Info.V1"; 6 | option go_package = "infov1"; 7 | option java_multiple_files = true; 8 | option java_outer_classname = "InfoProto"; 9 | option java_package = "com.liderman.traderstack.info.v1"; 10 | option objc_class_prefix = "LTI"; 11 | option php_namespace = "Liderman\\Traderstack\\Info\\V1"; 12 | 13 | // Финансовый инструмент. 14 | message Instrument { 15 | string figi = 1; 16 | string ticker = 2; 17 | string isin = 3; 18 | int32 lot = 4; 19 | string currency = 5; 20 | string name = 6; 21 | string exchange = 7; 22 | } 23 | 24 | // Аккаунт. 25 | message Account { 26 | string id = 1; 27 | string name = 2; 28 | } 29 | 30 | // Деньги. 31 | message Money { 32 | string currency = 1; 33 | string value = 2; 34 | } 35 | -------------------------------------------------------------------------------- /internal/stackfuncs/boolean.go: -------------------------------------------------------------------------------- 1 | package stackfuncs 2 | 3 | import ( 4 | "github.com/liderman/traderstack/internal/engine" 5 | "time" 6 | ) 7 | 8 | type Boolean struct { 9 | } 10 | 11 | func NewBoolean() *Boolean { 12 | return &Boolean{} 13 | } 14 | 15 | func (b *Boolean) Name() string { 16 | return "Boolean" 17 | } 18 | 19 | func (b *Boolean) BaseType() string { 20 | return engine.BaseTypeBoolean 21 | } 22 | 23 | func (b *Boolean) Run(options *engine.Options, now time.Time, accountId string, isTest bool) (interface{}, error) { 24 | return options.GetBoolean("value") 25 | } 26 | 27 | func (b *Boolean) Arguments() []*engine.Argument { 28 | return []*engine.Argument{ 29 | { 30 | Id: "value", 31 | Name: "Boolean", 32 | Desc: "", 33 | BaseType: "boolean", 34 | ExtendedType: "", 35 | Required: true, 36 | Value: false, 37 | }, 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /internal/stackfuncs/abs.go: -------------------------------------------------------------------------------- 1 | package stackfuncs 2 | 3 | import ( 4 | "github.com/liderman/traderstack/internal/engine" 5 | "time" 6 | ) 7 | 8 | type Abs struct { 9 | } 10 | 11 | func NewAbs() *Abs { 12 | return &Abs{} 13 | } 14 | 15 | func (l *Abs) Name() string { 16 | return "abs" 17 | } 18 | 19 | func (l *Abs) BaseType() string { 20 | return engine.BaseTypeDecimal 21 | } 22 | 23 | func (l *Abs) Run(options *engine.Options, now time.Time, accountId string, isTest bool) (interface{}, error) { 24 | value, err := options.GetNumericDecimal("value") 25 | if err != nil { 26 | return nil, err 27 | } 28 | 29 | return value.Abs(), nil 30 | } 31 | 32 | func (l *Abs) Arguments() []*engine.Argument { 33 | return []*engine.Argument{ 34 | { 35 | Id: "value", 36 | Name: "Число", 37 | Desc: "", 38 | BaseType: "numeric", 39 | ExtendedType: "", 40 | Required: true, 41 | }, 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /internal/stackfuncs/decimal.go: -------------------------------------------------------------------------------- 1 | package stackfuncs 2 | 3 | import ( 4 | "github.com/liderman/traderstack/internal/engine" 5 | "github.com/shopspring/decimal" 6 | "time" 7 | ) 8 | 9 | type Decimal struct { 10 | } 11 | 12 | func NewDecimal() *Decimal { 13 | return &Decimal{} 14 | } 15 | 16 | func (b *Decimal) Name() string { 17 | return "Decimal" 18 | } 19 | 20 | func (b *Decimal) BaseType() string { 21 | return engine.BaseTypeDecimal 22 | } 23 | 24 | func (b *Decimal) Run(options *engine.Options, now time.Time, accountId string, isTest bool) (interface{}, error) { 25 | return options.GetDecimal("value") 26 | } 27 | 28 | func (b *Decimal) Arguments() []*engine.Argument { 29 | return []*engine.Argument{ 30 | { 31 | Id: "value", 32 | Name: "Число", 33 | Desc: "", 34 | BaseType: "decimal", 35 | ExtendedType: "", 36 | Required: true, 37 | Value: decimal.Zero, 38 | }, 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /internal/domain/candle.go: -------------------------------------------------------------------------------- 1 | package domain 2 | 3 | import ( 4 | "github.com/shopspring/decimal" 5 | "time" 6 | ) 7 | 8 | type CandleInterval int 9 | 10 | const ( 11 | CandleIntervalUnspecified CandleInterval = iota 12 | CandleInterval1Min 13 | CandleInterval5Min 14 | CandleInterval15Min 15 | CandleInterval1Hour 16 | CandleInterval1Day 17 | ) 18 | 19 | func (c *CandleInterval) ToDuration() time.Duration { 20 | switch *c { 21 | case CandleInterval1Min: 22 | return time.Minute 23 | case CandleInterval5Min: 24 | return time.Minute * 5 25 | case CandleInterval15Min: 26 | return time.Minute * 15 27 | case CandleInterval1Hour: 28 | return time.Hour 29 | } 30 | 31 | return time.Nanosecond 32 | } 33 | 34 | type HistoricCandle struct { 35 | Open decimal.Decimal 36 | High decimal.Decimal 37 | Low decimal.Decimal 38 | Close decimal.Decimal 39 | Volume int64 40 | Time time.Time 41 | IsComplete bool 42 | } 43 | -------------------------------------------------------------------------------- /internal/grpcsrv/proto/liderman/traderstack/strategy/v1/strategy.proto: -------------------------------------------------------------------------------- 1 | syntax = "proto3"; 2 | 3 | package liderman.traderstack.strategy.v1; 4 | 5 | option csharp_namespace = "Liderman.Traderstack.Strategy.V1"; 6 | option go_package = "strategyv1"; 7 | option java_multiple_files = true; 8 | option java_outer_classname = "StrategyProto"; 9 | option java_package = "com.liderman.traderstack.strategy.v1"; 10 | option objc_class_prefix = "LTS"; 11 | option php_namespace = "Liderman\\Traderstack\\Strategy\\V1"; 12 | 13 | import "google/protobuf/duration.proto"; 14 | import "google/protobuf/timestamp.proto"; 15 | 16 | // Стратегия. 17 | message Strategy { 18 | string id = 1; 19 | string stack_id = 2; 20 | string account_id = 3; 21 | google.protobuf.Duration run_interval_duration = 4; 22 | bool enabled = 5; 23 | } 24 | 25 | // Лог. 26 | message StrategyLog { 27 | string log_type = 1; 28 | string message = 2; 29 | google.protobuf.Timestamp time = 3; 30 | } 31 | -------------------------------------------------------------------------------- /internal/stackfuncs/figibyticker.go: -------------------------------------------------------------------------------- 1 | package stackfuncs 2 | 3 | import ( 4 | "github.com/liderman/traderstack/internal/engine" 5 | "time" 6 | ) 7 | 8 | type FigiByTicker struct { 9 | } 10 | 11 | func NewFigiByTicker() *FigiByTicker { 12 | return &FigiByTicker{} 13 | } 14 | 15 | func (f *FigiByTicker) Name() string { 16 | return "FigiByTicker" 17 | } 18 | 19 | func (f *FigiByTicker) BaseType() string { 20 | return "string" 21 | } 22 | 23 | func (f *FigiByTicker) Run(options *engine.Options, now time.Time, accountId string, isTest bool) (interface{}, error) { 24 | return options.GetString("ticker") 25 | } 26 | 27 | func (f *FigiByTicker) Arguments() []*engine.Argument { 28 | return []*engine.Argument{ 29 | { 30 | Id: "ticker", 31 | Name: "Ticker", 32 | Desc: "Например, TCS", 33 | BaseType: "string", 34 | ExtendedType: "figi-select", 35 | Required: true, 36 | Value: "", 37 | }, 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /internal/algofunc/rsa.go: -------------------------------------------------------------------------------- 1 | package algofunc 2 | 3 | import "github.com/shopspring/decimal" 4 | 5 | func CalcRsi(data []decimal.Decimal) decimal.Decimal { 6 | totalGain := decimal.New(0, 0) 7 | totalLoss := decimal.New(0, 0) 8 | 9 | for i := 1; i < len(data); i++ { 10 | previousClose := data[i] 11 | currentClose := data[i-1] 12 | 13 | difference := currentClose.Sub(previousClose) 14 | 15 | // difference >= 0 16 | if difference.GreaterThanOrEqual(decimal.Zero) { 17 | totalGain = totalGain.Add(difference) 18 | } else { 19 | totalLoss = totalLoss.Sub(difference) 20 | } 21 | } 22 | 23 | if totalLoss.IsZero() { 24 | return decimal.NewFromInt(100) 25 | } 26 | 27 | if totalGain.IsZero() { 28 | return decimal.NewFromInt(0) 29 | } 30 | 31 | rs := totalGain.Div(totalLoss.Abs()) 32 | 33 | // 100 - 100/(1+rs) 34 | return decimal.NewFromInt(100).Sub( 35 | decimal.NewFromInt(100).Div( 36 | decimal.NewFromInt(1).Add(rs), 37 | ), 38 | ) 39 | } 40 | -------------------------------------------------------------------------------- /internal/engine/stackfunc.go: -------------------------------------------------------------------------------- 1 | package engine 2 | 3 | import ( 4 | "errors" 5 | "time" 6 | ) 7 | 8 | // StackFuncRun Функция элемента стека для выполнения. 9 | type StackFuncRun interface { 10 | Name() string 11 | BaseType() string 12 | Run(options *Options, now time.Time, accountId string, isTest bool) (interface{}, error) 13 | Arguments() []*Argument 14 | } 15 | 16 | type StackFunc struct { 17 | Name string 18 | Arguments []*Argument 19 | BaseType string 20 | } 21 | 22 | func (s *StackFunc) SetArgument(id string, value interface{}) error { 23 | for _, v := range s.Arguments { 24 | if v.Id == id { 25 | v.Value = value 26 | return nil 27 | } 28 | } 29 | 30 | return errors.New("argument is not exist") 31 | } 32 | 33 | func (s *StackFunc) GetArgument(id string) *Argument { 34 | for _, v := range s.Arguments { 35 | if v.Id == id { 36 | return v 37 | } 38 | } 39 | 40 | return nil 41 | } 42 | 43 | type SetStackFunc struct { 44 | Name string 45 | Arguments []*SetArgument 46 | } 47 | -------------------------------------------------------------------------------- /internal/stackfuncs/eq_test.go: -------------------------------------------------------------------------------- 1 | package stackfuncs 2 | 3 | import ( 4 | "github.com/liderman/traderstack/internal/engine" 5 | "github.com/stretchr/testify/assert" 6 | "testing" 7 | "time" 8 | ) 9 | 10 | func TestEq_Run(t *testing.T) { 11 | fnc := NewEq() 12 | 13 | tests := []struct { 14 | name string 15 | a int64 16 | b int64 17 | result bool 18 | }{ 19 | {"1 == 1 = true", 1, 1, true}, 20 | {"0 == 0 = true", 0, 0, true}, 21 | {"-1 == 1 = false", -1, 1, false}, 22 | {"1 == -1 = false", 2, 1, false}, 23 | } 24 | for _, tt := range tests { 25 | t.Run(tt.name, func(t *testing.T) { 26 | args := fnc.Arguments() 27 | args[0].Value = tt.a 28 | args[1].Value = tt.b 29 | 30 | opts := engine.NewOptions(args, map[string]interface{}{}) 31 | resp, err := fnc.Run(opts, time.Now(), "", false) 32 | 33 | assert.NoError(t, err) 34 | assert.Equal(t, resp, tt.result) 35 | }) 36 | } 37 | 38 | opts := engine.NewOptions(fnc.Arguments(), map[string]interface{}{}) 39 | resp, err := fnc.Run(opts, time.Now(), "", false) 40 | 41 | assert.Error(t, err) 42 | assert.Nil(t, resp) 43 | } 44 | -------------------------------------------------------------------------------- /internal/stackfuncs/ne_test.go: -------------------------------------------------------------------------------- 1 | package stackfuncs 2 | 3 | import ( 4 | "github.com/liderman/traderstack/internal/engine" 5 | "github.com/stretchr/testify/assert" 6 | "testing" 7 | "time" 8 | ) 9 | 10 | func TestNe_Run(t *testing.T) { 11 | fnc := NewNe() 12 | 13 | tests := []struct { 14 | name string 15 | a int64 16 | b int64 17 | result bool 18 | }{ 19 | {"1 != 1 = true", 1, 1, false}, 20 | {"0 != 0 = true", 0, 0, false}, 21 | {"-1 != 1 = false", -1, 1, true}, 22 | {"1 != -1 = false", 2, 1, true}, 23 | } 24 | for _, tt := range tests { 25 | t.Run(tt.name, func(t *testing.T) { 26 | args := fnc.Arguments() 27 | args[0].Value = tt.a 28 | args[1].Value = tt.b 29 | 30 | opts := engine.NewOptions(args, map[string]interface{}{}) 31 | resp, err := fnc.Run(opts, time.Now(), "", false) 32 | 33 | assert.NoError(t, err) 34 | assert.Equal(t, resp, tt.result) 35 | }) 36 | } 37 | 38 | opts := engine.NewOptions(fnc.Arguments(), map[string]interface{}{}) 39 | resp, err := fnc.Run(opts, time.Now(), "", false) 40 | 41 | assert.Error(t, err) 42 | assert.Nil(t, resp) 43 | } 44 | -------------------------------------------------------------------------------- /cmd/traderstack/grpc_options.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "google.golang.org/grpc" 6 | "google.golang.org/grpc/metadata" 7 | ) 8 | 9 | func appendMetadataGrpcOption(in []grpc.DialOption, key, value string) []grpc.DialOption { 10 | in = append(in, grpc.WithChainUnaryInterceptor(func( 11 | ctx context.Context, 12 | method string, 13 | req interface{}, 14 | reply interface{}, 15 | cc *grpc.ClientConn, 16 | invoker grpc.UnaryInvoker, 17 | opts ...grpc.CallOption, 18 | ) error { 19 | newCtx := metadata.AppendToOutgoingContext( 20 | ctx, 21 | key, 22 | value, 23 | ) 24 | 25 | return invoker(newCtx, method, req, reply, cc, opts...) 26 | })) 27 | 28 | in = append(in, grpc.WithChainStreamInterceptor(func( 29 | ctx context.Context, 30 | desc *grpc.StreamDesc, 31 | cc *grpc.ClientConn, 32 | method string, 33 | streamer grpc.Streamer, 34 | opts ...grpc.CallOption, 35 | ) (grpc.ClientStream, error) { 36 | newCtx := metadata.AppendToOutgoingContext( 37 | ctx, 38 | key, 39 | value, 40 | ) 41 | 42 | return streamer(newCtx, desc, cc, method, opts...) 43 | })) 44 | return in 45 | } 46 | -------------------------------------------------------------------------------- /internal/stackfuncs/ge_test.go: -------------------------------------------------------------------------------- 1 | package stackfuncs 2 | 3 | import ( 4 | "github.com/liderman/traderstack/internal/engine" 5 | "github.com/stretchr/testify/assert" 6 | "testing" 7 | "time" 8 | ) 9 | 10 | func TestGe_Run(t *testing.T) { 11 | ge := NewGe() 12 | 13 | tests := []struct { 14 | name string 15 | a int64 16 | b int64 17 | result bool 18 | }{ 19 | {"1 >= 2 = true", 2, 1, true}, 20 | {"2 >= 2 = true", 2, 2, true}, 21 | {"-2 >= -1 = true", -1, -2, true}, 22 | {"2 >= 1 = false", 1, 2, false}, 23 | {"-1 >= -2 = false", -2, -1, false}, 24 | } 25 | for _, tt := range tests { 26 | t.Run(tt.name, func(t *testing.T) { 27 | args := ge.Arguments() 28 | args[0].Value = tt.a 29 | args[1].Value = tt.b 30 | 31 | opts := engine.NewOptions(args, map[string]interface{}{}) 32 | resp, err := ge.Run(opts, time.Now(), "", false) 33 | 34 | assert.NoError(t, err) 35 | assert.Equal(t, resp, tt.result) 36 | }) 37 | } 38 | 39 | opts := engine.NewOptions(ge.Arguments(), map[string]interface{}{}) 40 | resp, err := ge.Run(opts, time.Now(), "", false) 41 | 42 | assert.Error(t, err) 43 | assert.Nil(t, resp) 44 | } 45 | -------------------------------------------------------------------------------- /internal/stackfuncs/le_test.go: -------------------------------------------------------------------------------- 1 | package stackfuncs 2 | 3 | import ( 4 | "github.com/liderman/traderstack/internal/engine" 5 | "github.com/stretchr/testify/assert" 6 | "testing" 7 | "time" 8 | ) 9 | 10 | func TestLe_Run(t *testing.T) { 11 | le := NewLe() 12 | 13 | tests := []struct { 14 | name string 15 | a int64 16 | b int64 17 | result bool 18 | }{ 19 | {"1 <= 2 = true", 1, 2, true}, 20 | {"2 <= 2 = true", 2, 2, true}, 21 | {"-2 <= -1 = true", -2, -1, true}, 22 | {"2 <= 1 = false", 2, 1, false}, 23 | {"-1 <= -2 = false", -1, -2, false}, 24 | } 25 | for _, tt := range tests { 26 | t.Run(tt.name, func(t *testing.T) { 27 | args := le.Arguments() 28 | args[0].Value = tt.a 29 | args[1].Value = tt.b 30 | 31 | opts := engine.NewOptions(args, map[string]interface{}{}) 32 | resp, err := le.Run(opts, time.Now(), "", false) 33 | 34 | assert.NoError(t, err) 35 | assert.Equal(t, resp, tt.result) 36 | }) 37 | } 38 | 39 | opts := engine.NewOptions(le.Arguments(), map[string]interface{}{}) 40 | resp, err := le.Run(opts, time.Now(), "", false) 41 | 42 | assert.Error(t, err) 43 | assert.Nil(t, resp) 44 | } 45 | -------------------------------------------------------------------------------- /internal/stackfuncs/eq.go: -------------------------------------------------------------------------------- 1 | package stackfuncs 2 | 3 | import ( 4 | "github.com/liderman/traderstack/internal/engine" 5 | "time" 6 | ) 7 | 8 | type Eq struct { 9 | } 10 | 11 | func NewEq() *Eq { 12 | return &Eq{} 13 | } 14 | 15 | func (l *Eq) Name() string { 16 | return "==" 17 | } 18 | 19 | func (l *Eq) BaseType() string { 20 | return engine.BaseTypeBoolean 21 | } 22 | 23 | func (l *Eq) Run(options *engine.Options, now time.Time, accountId string, isTest bool) (interface{}, error) { 24 | a, err := options.GetNumericDecimal("a") 25 | if err != nil { 26 | return nil, err 27 | } 28 | b, err := options.GetNumericDecimal("b") 29 | if err != nil { 30 | return nil, err 31 | } 32 | 33 | return a.Equal(b), nil 34 | } 35 | 36 | func (l *Eq) Arguments() []*engine.Argument { 37 | return []*engine.Argument{ 38 | { 39 | Id: "a", 40 | Name: "Слева", 41 | Desc: "", 42 | BaseType: "numeric", 43 | ExtendedType: "", 44 | Required: true, 45 | }, 46 | { 47 | Id: "b", 48 | Name: "Справа", 49 | Desc: "", 50 | BaseType: "numeric", 51 | ExtendedType: "", 52 | Required: true, 53 | }, 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /internal/stackfuncs/ne.go: -------------------------------------------------------------------------------- 1 | package stackfuncs 2 | 3 | import ( 4 | "github.com/liderman/traderstack/internal/engine" 5 | "time" 6 | ) 7 | 8 | type Ne struct { 9 | } 10 | 11 | func NewNe() *Ne { 12 | return &Ne{} 13 | } 14 | 15 | func (l *Ne) Name() string { 16 | return "!=" 17 | } 18 | 19 | func (l *Ne) BaseType() string { 20 | return engine.BaseTypeBoolean 21 | } 22 | 23 | func (l *Ne) Run(options *engine.Options, now time.Time, accountId string, isTest bool) (interface{}, error) { 24 | a, err := options.GetNumericDecimal("a") 25 | if err != nil { 26 | return nil, err 27 | } 28 | b, err := options.GetNumericDecimal("b") 29 | if err != nil { 30 | return nil, err 31 | } 32 | 33 | return !a.Equal(b), nil 34 | } 35 | 36 | func (l *Ne) Arguments() []*engine.Argument { 37 | return []*engine.Argument{ 38 | { 39 | Id: "a", 40 | Name: "Слева", 41 | Desc: "", 42 | BaseType: "numeric", 43 | ExtendedType: "", 44 | Required: true, 45 | }, 46 | { 47 | Id: "b", 48 | Name: "Справа", 49 | Desc: "", 50 | BaseType: "numeric", 51 | ExtendedType: "", 52 | Required: true, 53 | }, 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /internal/stackfuncs/add.go: -------------------------------------------------------------------------------- 1 | package stackfuncs 2 | 3 | import ( 4 | "github.com/liderman/traderstack/internal/engine" 5 | "time" 6 | ) 7 | 8 | type Add struct { 9 | } 10 | 11 | func NewAdd() *Add { 12 | return &Add{} 13 | } 14 | 15 | func (l *Add) Name() string { 16 | return "+" 17 | } 18 | 19 | func (l *Add) BaseType() string { 20 | return engine.BaseTypeDecimal 21 | } 22 | 23 | func (l *Add) Run(options *engine.Options, now time.Time, accountId string, isTest bool) (interface{}, error) { 24 | a, err := options.GetNumericDecimal("a") 25 | if err != nil { 26 | return nil, err 27 | } 28 | b, err := options.GetNumericDecimal("b") 29 | if err != nil { 30 | return nil, err 31 | } 32 | 33 | return a.Add(b), nil 34 | } 35 | 36 | func (l *Add) Arguments() []*engine.Argument { 37 | return []*engine.Argument{ 38 | { 39 | Id: "a", 40 | Name: "Слева", 41 | Desc: "", 42 | BaseType: "numeric", 43 | ExtendedType: "", 44 | Required: true, 45 | }, 46 | { 47 | Id: "b", 48 | Name: "Справа", 49 | Desc: "", 50 | BaseType: "numeric", 51 | ExtendedType: "", 52 | Required: true, 53 | }, 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /internal/stackfuncs/div.go: -------------------------------------------------------------------------------- 1 | package stackfuncs 2 | 3 | import ( 4 | "github.com/liderman/traderstack/internal/engine" 5 | "time" 6 | ) 7 | 8 | type Div struct { 9 | } 10 | 11 | func NewDiv() *Div { 12 | return &Div{} 13 | } 14 | 15 | func (l *Div) Name() string { 16 | return "/" 17 | } 18 | 19 | func (l *Div) BaseType() string { 20 | return engine.BaseTypeDecimal 21 | } 22 | 23 | func (l *Div) Run(options *engine.Options, now time.Time, accountId string, isTest bool) (interface{}, error) { 24 | a, err := options.GetNumericDecimal("a") 25 | if err != nil { 26 | return nil, err 27 | } 28 | b, err := options.GetNumericDecimal("b") 29 | if err != nil { 30 | return nil, err 31 | } 32 | 33 | return a.Div(b), nil 34 | } 35 | 36 | func (l *Div) Arguments() []*engine.Argument { 37 | return []*engine.Argument{ 38 | { 39 | Id: "a", 40 | Name: "Слева", 41 | Desc: "", 42 | BaseType: "numeric", 43 | ExtendedType: "", 44 | Required: true, 45 | }, 46 | { 47 | Id: "b", 48 | Name: "Справа", 49 | Desc: "", 50 | BaseType: "numeric", 51 | ExtendedType: "", 52 | Required: true, 53 | }, 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /internal/stackfuncs/mod.go: -------------------------------------------------------------------------------- 1 | package stackfuncs 2 | 3 | import ( 4 | "github.com/liderman/traderstack/internal/engine" 5 | "time" 6 | ) 7 | 8 | type Mod struct { 9 | } 10 | 11 | func NewMod() *Mod { 12 | return &Mod{} 13 | } 14 | 15 | func (l *Mod) Name() string { 16 | return "mod" 17 | } 18 | 19 | func (l *Mod) BaseType() string { 20 | return engine.BaseTypeDecimal 21 | } 22 | 23 | func (l *Mod) Run(options *engine.Options, now time.Time, accountId string, isTest bool) (interface{}, error) { 24 | a, err := options.GetNumericDecimal("a") 25 | if err != nil { 26 | return nil, err 27 | } 28 | b, err := options.GetNumericDecimal("b") 29 | if err != nil { 30 | return nil, err 31 | } 32 | 33 | return a.Mod(b), nil 34 | } 35 | 36 | func (l *Mod) Arguments() []*engine.Argument { 37 | return []*engine.Argument{ 38 | { 39 | Id: "a", 40 | Name: "Слева", 41 | Desc: "", 42 | BaseType: "numeric", 43 | ExtendedType: "", 44 | Required: true, 45 | }, 46 | { 47 | Id: "b", 48 | Name: "Справа", 49 | Desc: "", 50 | BaseType: "numeric", 51 | ExtendedType: "", 52 | Required: true, 53 | }, 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /internal/stackfuncs/mul.go: -------------------------------------------------------------------------------- 1 | package stackfuncs 2 | 3 | import ( 4 | "github.com/liderman/traderstack/internal/engine" 5 | "time" 6 | ) 7 | 8 | type Mul struct { 9 | } 10 | 11 | func NewMul() *Mul { 12 | return &Mul{} 13 | } 14 | 15 | func (l *Mul) Name() string { 16 | return "*" 17 | } 18 | 19 | func (l *Mul) BaseType() string { 20 | return engine.BaseTypeDecimal 21 | } 22 | 23 | func (l *Mul) Run(options *engine.Options, now time.Time, accountId string, isTest bool) (interface{}, error) { 24 | a, err := options.GetNumericDecimal("a") 25 | if err != nil { 26 | return nil, err 27 | } 28 | b, err := options.GetNumericDecimal("b") 29 | if err != nil { 30 | return nil, err 31 | } 32 | 33 | return a.Mul(b), nil 34 | } 35 | 36 | func (l *Mul) Arguments() []*engine.Argument { 37 | return []*engine.Argument{ 38 | { 39 | Id: "a", 40 | Name: "Слева", 41 | Desc: "", 42 | BaseType: "numeric", 43 | ExtendedType: "", 44 | Required: true, 45 | }, 46 | { 47 | Id: "b", 48 | Name: "Справа", 49 | Desc: "", 50 | BaseType: "numeric", 51 | ExtendedType: "", 52 | Required: true, 53 | }, 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /internal/stackfuncs/sub.go: -------------------------------------------------------------------------------- 1 | package stackfuncs 2 | 3 | import ( 4 | "github.com/liderman/traderstack/internal/engine" 5 | "time" 6 | ) 7 | 8 | type Sub struct { 9 | } 10 | 11 | func NewSub() *Sub { 12 | return &Sub{} 13 | } 14 | 15 | func (l *Sub) Name() string { 16 | return "-" 17 | } 18 | 19 | func (l *Sub) BaseType() string { 20 | return engine.BaseTypeDecimal 21 | } 22 | 23 | func (l *Sub) Run(options *engine.Options, now time.Time, accountId string, isTest bool) (interface{}, error) { 24 | a, err := options.GetNumericDecimal("a") 25 | if err != nil { 26 | return nil, err 27 | } 28 | b, err := options.GetNumericDecimal("b") 29 | if err != nil { 30 | return nil, err 31 | } 32 | 33 | return a.Sub(b), nil 34 | } 35 | 36 | func (l *Sub) Arguments() []*engine.Argument { 37 | return []*engine.Argument{ 38 | { 39 | Id: "a", 40 | Name: "Слева", 41 | Desc: "", 42 | BaseType: "numeric", 43 | ExtendedType: "", 44 | Required: true, 45 | }, 46 | { 47 | Id: "b", 48 | Name: "Справа", 49 | Desc: "", 50 | BaseType: "numeric", 51 | ExtendedType: "", 52 | Required: true, 53 | }, 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /internal/stackfuncs/ge.go: -------------------------------------------------------------------------------- 1 | package stackfuncs 2 | 3 | import ( 4 | "github.com/liderman/traderstack/internal/engine" 5 | "time" 6 | ) 7 | 8 | type Ge struct { 9 | } 10 | 11 | func NewGe() *Ge { 12 | return &Ge{} 13 | } 14 | 15 | func (g *Ge) Name() string { 16 | return ">=" 17 | } 18 | 19 | func (g *Ge) BaseType() string { 20 | return engine.BaseTypeBoolean 21 | } 22 | 23 | func (g *Ge) Run(options *engine.Options, now time.Time, accountId string, isTest bool) (interface{}, error) { 24 | a, err := options.GetNumericDecimal("a") 25 | if err != nil { 26 | return nil, err 27 | } 28 | b, err := options.GetNumericDecimal("b") 29 | if err != nil { 30 | return nil, err 31 | } 32 | 33 | return a.GreaterThanOrEqual(b), nil 34 | } 35 | 36 | func (g *Ge) Arguments() []*engine.Argument { 37 | return []*engine.Argument{ 38 | { 39 | Id: "a", 40 | Name: "Слева", 41 | Desc: "", 42 | BaseType: "numeric", 43 | ExtendedType: "", 44 | Required: true, 45 | }, 46 | { 47 | Id: "b", 48 | Name: "Справа", 49 | Desc: "", 50 | BaseType: "numeric", 51 | ExtendedType: "", 52 | Required: true, 53 | }, 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /internal/stackfuncs/le.go: -------------------------------------------------------------------------------- 1 | package stackfuncs 2 | 3 | import ( 4 | "github.com/liderman/traderstack/internal/engine" 5 | "time" 6 | ) 7 | 8 | type Le struct { 9 | } 10 | 11 | func NewLe() *Le { 12 | return &Le{} 13 | } 14 | 15 | func (l *Le) Name() string { 16 | return "<=" 17 | } 18 | 19 | func (l *Le) BaseType() string { 20 | return engine.BaseTypeBoolean 21 | } 22 | 23 | func (l *Le) Run(options *engine.Options, now time.Time, accountId string, isTest bool) (interface{}, error) { 24 | a, err := options.GetNumericDecimal("a") 25 | if err != nil { 26 | return nil, err 27 | } 28 | b, err := options.GetNumericDecimal("b") 29 | if err != nil { 30 | return nil, err 31 | } 32 | 33 | return a.LessThanOrEqual(b), nil 34 | } 35 | 36 | func (l *Le) Arguments() []*engine.Argument { 37 | return []*engine.Argument{ 38 | { 39 | Id: "a", 40 | Name: "Слева", 41 | Desc: "", 42 | BaseType: "numeric", 43 | ExtendedType: "", 44 | Required: true, 45 | }, 46 | { 47 | Id: "b", 48 | Name: "Справа", 49 | Desc: "", 50 | BaseType: "numeric", 51 | ExtendedType: "", 52 | Required: true, 53 | }, 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /internal/stackfuncs/abs_test.go: -------------------------------------------------------------------------------- 1 | package stackfuncs 2 | 3 | import ( 4 | "github.com/liderman/traderstack/internal/engine" 5 | "github.com/shopspring/decimal" 6 | "github.com/stretchr/testify/assert" 7 | "testing" 8 | "time" 9 | ) 10 | 11 | func TestAbs_Run(t *testing.T) { 12 | fnc := NewAbs() 13 | 14 | tests := []struct { 15 | name string 16 | value decimal.Decimal 17 | result decimal.Decimal 18 | }{ 19 | {"abs(9)=9", decimal.NewFromInt(9), decimal.NewFromInt(9)}, 20 | {"abs(-9)=9", decimal.NewFromInt(-9), decimal.NewFromInt(9)}, 21 | {"abs(0)=0", decimal.NewFromInt(0), decimal.NewFromInt(0)}, 22 | } 23 | for _, tt := range tests { 24 | t.Run(tt.name, func(t *testing.T) { 25 | args := fnc.Arguments() 26 | args[0].Value = tt.value 27 | 28 | opts := engine.NewOptions(args, map[string]interface{}{}) 29 | resp, err := fnc.Run(opts, time.Now(), "", false) 30 | 31 | assert.NoError(t, err) 32 | assert.Equal(t, tt.result.String(), resp.(decimal.Decimal).String()) 33 | }) 34 | } 35 | 36 | opts := engine.NewOptions(fnc.Arguments(), map[string]interface{}{}) 37 | resp, err := fnc.Run(opts, time.Now(), "", false) 38 | 39 | assert.Error(t, err) 40 | assert.Nil(t, resp) 41 | } 42 | -------------------------------------------------------------------------------- /web/components/fields/FieldVariable.vue: -------------------------------------------------------------------------------- 1 | 21 | 22 | 52 | 53 | 58 | -------------------------------------------------------------------------------- /internal/grpc/mapper/fromdomain_test.go: -------------------------------------------------------------------------------- 1 | package mapper 2 | 3 | import ( 4 | "github.com/liderman/traderstack/internal/grpc/gen/tinkoff/investapi" 5 | "github.com/shopspring/decimal" 6 | "github.com/stretchr/testify/assert" 7 | "testing" 8 | ) 9 | 10 | func TestMapToQuotation(t *testing.T) { 11 | tests := []struct { 12 | actual string 13 | expected *investapi.Quotation 14 | }{ 15 | { 16 | actual: "10.9", 17 | expected: &investapi.Quotation{ 18 | Units: 10, 19 | Nano: 900000000, 20 | }, 21 | }, 22 | { 23 | actual: "74.7175", 24 | expected: &investapi.Quotation{ 25 | Units: 74, 26 | Nano: 717500000, 27 | }, 28 | }, 29 | { 30 | actual: "73.045", 31 | expected: &investapi.Quotation{ 32 | Units: 73, 33 | Nano: 45000000, 34 | }, 35 | }, 36 | { 37 | actual: "-73.045", 38 | expected: &investapi.Quotation{ 39 | Units: -73, 40 | Nano: -45000000, 41 | }, 42 | }, 43 | } 44 | for _, tt := range tests { 45 | t.Run(tt.actual, func(t *testing.T) { 46 | val, _ := decimal.NewFromString(tt.actual) 47 | res := MapToQuotation(val) 48 | assert.Equal(t, tt.expected.Units, res.Units) 49 | assert.Equal(t, tt.expected.Nano, res.Nano) 50 | }) 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /internal/stackfuncs/div_test.go: -------------------------------------------------------------------------------- 1 | package stackfuncs 2 | 3 | import ( 4 | "github.com/liderman/traderstack/internal/engine" 5 | "github.com/shopspring/decimal" 6 | "github.com/stretchr/testify/assert" 7 | "testing" 8 | "time" 9 | ) 10 | 11 | func TestDiv_Run(t *testing.T) { 12 | fnc := NewDiv() 13 | 14 | tests := []struct { 15 | name string 16 | a decimal.Decimal 17 | b decimal.Decimal 18 | result decimal.Decimal 19 | }{ 20 | {"2/2=1", decimal.NewFromInt(2), decimal.NewFromInt(2), decimal.NewFromInt(1)}, 21 | {"-2/2=-1", decimal.NewFromInt(-2), decimal.NewFromInt(2), decimal.NewFromInt(-1)}, 22 | } 23 | for _, tt := range tests { 24 | t.Run(tt.name, func(t *testing.T) { 25 | args := fnc.Arguments() 26 | args[0].Value = tt.a 27 | args[1].Value = tt.b 28 | 29 | opts := engine.NewOptions(args, map[string]interface{}{}) 30 | resp, err := fnc.Run(opts, time.Now(), "", false) 31 | 32 | assert.NoError(t, err) 33 | assert.Equal(t, tt.result.String(), resp.(decimal.Decimal).String()) 34 | }) 35 | } 36 | 37 | opts := engine.NewOptions(fnc.Arguments(), map[string]interface{}{}) 38 | resp, err := fnc.Run(opts, time.Now(), "", false) 39 | 40 | assert.Error(t, err) 41 | assert.Nil(t, resp) 42 | } 43 | -------------------------------------------------------------------------------- /internal/apiclient/marketdata.go: -------------------------------------------------------------------------------- 1 | package apiclient 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "github.com/liderman/traderstack/internal/domain" 7 | "github.com/liderman/traderstack/internal/grpc/gen/tinkoff/investapi" 8 | "github.com/liderman/traderstack/internal/grpc/mapper" 9 | "google.golang.org/protobuf/types/known/timestamppb" 10 | "time" 11 | ) 12 | 13 | func (a *RealClient) GetCandles(figi string, from time.Time, to time.Time, interval domain.CandleInterval) ([]*domain.HistoricCandle, error) { 14 | resp, err := a.cMarket.GetCandles(context.Background(), &investapi.GetCandlesRequest{ 15 | Figi: figi, 16 | From: timestamppb.New(from), 17 | To: timestamppb.New(to), 18 | Interval: mapper.MapToCandleInterval(interval), 19 | }) 20 | if err != nil { 21 | return nil, fmt.Errorf("error api GetCandles: %w", err) 22 | } 23 | 24 | return mapper.MapFromHistoricCandles(resp.Candles), nil 25 | } 26 | 27 | func (a *RealClient) GetLastPrices() ([]*domain.LastPrice, error) { 28 | resp, err := a.cMarket.GetLastPrices(context.Background(), &investapi.GetLastPricesRequest{}) 29 | if err != nil { 30 | return nil, fmt.Errorf("error api GetLastPrices: %w", err) 31 | } 32 | 33 | return mapper.MapFromLastPrices(resp.LastPrices), nil 34 | } 35 | -------------------------------------------------------------------------------- /internal/engine/variable.go: -------------------------------------------------------------------------------- 1 | package engine 2 | 3 | type Argument struct { 4 | Id string 5 | Name string 6 | Desc string 7 | BaseType string 8 | ExtendedType string 9 | Required bool 10 | Value interface{} 11 | } 12 | 13 | type SetArgument struct { 14 | Id string 15 | Value interface{} 16 | } 17 | 18 | const ( 19 | BaseTypeString = "string" 20 | BaseTypeInteger = "integer" 21 | BaseTypeBoolean = "boolean" 22 | BaseTypeDecimal = "decimal" 23 | BaseTypeTime = "time" 24 | BaseTypeNumeric = "numeric" 25 | ) 26 | 27 | func (a *Argument) CheckInputBaseType(inputBaseType string) bool { 28 | switch a.BaseType { 29 | case BaseTypeNumeric: 30 | if inputBaseType == BaseTypeNumeric || inputBaseType == BaseTypeInteger || inputBaseType == BaseTypeDecimal { 31 | return true 32 | } 33 | case BaseTypeString: 34 | if inputBaseType == BaseTypeString { 35 | return true 36 | } 37 | case BaseTypeInteger: 38 | if inputBaseType == BaseTypeInteger { 39 | return true 40 | } 41 | case BaseTypeDecimal: 42 | if inputBaseType == BaseTypeDecimal { 43 | return true 44 | } 45 | case BaseTypeTime: 46 | if inputBaseType == BaseTypeTime { 47 | return true 48 | } 49 | } 50 | 51 | return false 52 | } 53 | -------------------------------------------------------------------------------- /internal/stackfuncs/round.go: -------------------------------------------------------------------------------- 1 | package stackfuncs 2 | 3 | import ( 4 | "github.com/liderman/traderstack/internal/engine" 5 | "time" 6 | ) 7 | 8 | type Round struct { 9 | } 10 | 11 | func NewRound() *Round { 12 | return &Round{} 13 | } 14 | 15 | func (l *Round) Name() string { 16 | return "round" 17 | } 18 | 19 | func (l *Round) BaseType() string { 20 | return engine.BaseTypeDecimal 21 | } 22 | 23 | func (l *Round) Run(options *engine.Options, now time.Time, accountId string, isTest bool) (interface{}, error) { 24 | value, err := options.GetNumericDecimal("value") 25 | if err != nil { 26 | return nil, err 27 | } 28 | places, err := options.GetInteger("places") 29 | if err != nil { 30 | return nil, err 31 | } 32 | 33 | return value.Round(int32(places)), nil 34 | } 35 | 36 | func (l *Round) Arguments() []*engine.Argument { 37 | return []*engine.Argument{ 38 | { 39 | Id: "value", 40 | Name: "Значение", 41 | Desc: "", 42 | BaseType: "numeric", 43 | ExtendedType: "", 44 | Required: true, 45 | }, 46 | { 47 | Id: "places", 48 | Name: "Округл.", 49 | Desc: "", 50 | BaseType: "integer", 51 | ExtendedType: "", 52 | Required: true, 53 | }, 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /internal/grpc/mapper/todomain_test.go: -------------------------------------------------------------------------------- 1 | package mapper 2 | 3 | import ( 4 | "github.com/liderman/traderstack/internal/grpc/gen/tinkoff/investapi" 5 | "github.com/stretchr/testify/assert" 6 | "testing" 7 | ) 8 | 9 | func Test_MapFromQuotation(t *testing.T) { 10 | tests := []struct { 11 | actual *investapi.Quotation 12 | expected string 13 | }{ 14 | { 15 | actual: &investapi.Quotation{ 16 | Units: 10, 17 | Nano: 900000000, 18 | }, 19 | expected: "10.9", 20 | }, 21 | { 22 | actual: &investapi.Quotation{ 23 | Units: 74, 24 | Nano: 717500000, 25 | }, 26 | expected: "74.7175", 27 | }, 28 | { 29 | actual: &investapi.Quotation{ 30 | Units: 73, 31 | Nano: 45000000, 32 | }, 33 | expected: "73.045", 34 | }, 35 | { 36 | actual: &investapi.Quotation{ 37 | Units: -73, 38 | Nano: -45000000, 39 | }, 40 | expected: "-73.045", 41 | }, 42 | { 43 | actual: &investapi.Quotation{ 44 | Units: 0, 45 | Nano: 0, 46 | }, 47 | expected: "0", 48 | }, 49 | { 50 | actual: nil, 51 | expected: "0", 52 | }, 53 | } 54 | for _, tt := range tests { 55 | t.Run(tt.expected, func(t *testing.T) { 56 | assert.Equal(t, tt.expected, MapFromQuotation(tt.actual).String()) 57 | }) 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /internal/stackfuncs/round_test.go: -------------------------------------------------------------------------------- 1 | package stackfuncs 2 | 3 | import ( 4 | "github.com/liderman/traderstack/internal/engine" 5 | "github.com/shopspring/decimal" 6 | "github.com/stretchr/testify/assert" 7 | "testing" 8 | "time" 9 | ) 10 | 11 | func TestRound_Run(t *testing.T) { 12 | fnc := NewRound() 13 | 14 | tests := []struct { 15 | name string 16 | value decimal.Decimal 17 | places int64 18 | result decimal.Decimal 19 | }{ 20 | {"round(5.45, 1)=5.5", decimal.NewFromFloat(5.45), 1, decimal.NewFromFloat(5.5)}, 21 | {"round(545, -1)=550", decimal.NewFromInt(545), -1, decimal.NewFromInt(550)}, 22 | {"round(555, 2)=555", decimal.NewFromInt(555), 2, decimal.NewFromInt(555)}, 23 | } 24 | for _, tt := range tests { 25 | t.Run(tt.name, func(t *testing.T) { 26 | args := fnc.Arguments() 27 | args[0].Value = tt.value 28 | args[1].Value = tt.places 29 | 30 | opts := engine.NewOptions(args, map[string]interface{}{}) 31 | resp, err := fnc.Run(opts, time.Now(), "", false) 32 | 33 | assert.NoError(t, err) 34 | assert.Equal(t, tt.result.String(), resp.(decimal.Decimal).String()) 35 | }) 36 | } 37 | 38 | opts := engine.NewOptions(fnc.Arguments(), map[string]interface{}{}) 39 | resp, err := fnc.Run(opts, time.Now(), "", false) 40 | 41 | assert.Error(t, err) 42 | assert.Nil(t, resp) 43 | } 44 | -------------------------------------------------------------------------------- /internal/stackfuncs/add_test.go: -------------------------------------------------------------------------------- 1 | package stackfuncs 2 | 3 | import ( 4 | "github.com/liderman/traderstack/internal/engine" 5 | "github.com/shopspring/decimal" 6 | "github.com/stretchr/testify/assert" 7 | "testing" 8 | "time" 9 | ) 10 | 11 | func TestAdd_Run(t *testing.T) { 12 | fnc := NewAdd() 13 | 14 | tests := []struct { 15 | name string 16 | a decimal.Decimal 17 | b decimal.Decimal 18 | result decimal.Decimal 19 | }{ 20 | {"2+1=3", decimal.NewFromInt(2), decimal.NewFromInt(1), decimal.NewFromInt(3)}, 21 | {"-1+1=0", decimal.NewFromInt(-1), decimal.NewFromInt(1), decimal.NewFromInt(0)}, 22 | {"-1-2=1", decimal.NewFromInt(-1), decimal.NewFromInt(2), decimal.NewFromInt(1)}, 23 | } 24 | for _, tt := range tests { 25 | t.Run(tt.name, func(t *testing.T) { 26 | args := fnc.Arguments() 27 | args[0].Value = tt.a 28 | args[1].Value = tt.b 29 | 30 | opts := engine.NewOptions(args, map[string]interface{}{}) 31 | resp, err := fnc.Run(opts, time.Now(), "", false) 32 | 33 | assert.NoError(t, err) 34 | assert.Equal(t, tt.result.String(), resp.(decimal.Decimal).String()) 35 | }) 36 | } 37 | 38 | opts := engine.NewOptions(fnc.Arguments(), map[string]interface{}{}) 39 | resp, err := fnc.Run(opts, time.Now(), "", false) 40 | 41 | assert.Error(t, err) 42 | assert.Nil(t, resp) 43 | } 44 | -------------------------------------------------------------------------------- /internal/stackfuncs/sub_test.go: -------------------------------------------------------------------------------- 1 | package stackfuncs 2 | 3 | import ( 4 | "github.com/liderman/traderstack/internal/engine" 5 | "github.com/shopspring/decimal" 6 | "github.com/stretchr/testify/assert" 7 | "testing" 8 | "time" 9 | ) 10 | 11 | func TestSub_Run(t *testing.T) { 12 | fnc := NewSub() 13 | 14 | tests := []struct { 15 | name string 16 | a decimal.Decimal 17 | b decimal.Decimal 18 | result decimal.Decimal 19 | }{ 20 | {"2-1=1", decimal.NewFromInt(2), decimal.NewFromInt(1), decimal.NewFromInt(1)}, 21 | {"1-1=0", decimal.NewFromInt(1), decimal.NewFromInt(1), decimal.NewFromInt(0)}, 22 | {"-1-2=-3", decimal.NewFromInt(-1), decimal.NewFromInt(2), decimal.NewFromInt(-3)}, 23 | } 24 | for _, tt := range tests { 25 | t.Run(tt.name, func(t *testing.T) { 26 | args := fnc.Arguments() 27 | args[0].Value = tt.a 28 | args[1].Value = tt.b 29 | 30 | opts := engine.NewOptions(args, map[string]interface{}{}) 31 | resp, err := fnc.Run(opts, time.Now(), "", false) 32 | 33 | assert.NoError(t, err) 34 | assert.Equal(t, tt.result.String(), resp.(decimal.Decimal).String()) 35 | }) 36 | } 37 | 38 | opts := engine.NewOptions(fnc.Arguments(), map[string]interface{}{}) 39 | resp, err := fnc.Run(opts, time.Now(), "", false) 40 | 41 | assert.Error(t, err) 42 | assert.Nil(t, resp) 43 | } 44 | -------------------------------------------------------------------------------- /web/components/fields/FieldInteger.vue: -------------------------------------------------------------------------------- 1 | 20 | 21 | 56 | 57 | 62 | -------------------------------------------------------------------------------- /web/components/fields/FieldString.vue: -------------------------------------------------------------------------------- 1 | 21 | 22 | 57 | 58 | 63 | -------------------------------------------------------------------------------- /internal/stackfuncs/mod_test.go: -------------------------------------------------------------------------------- 1 | package stackfuncs 2 | 3 | import ( 4 | "github.com/liderman/traderstack/internal/engine" 5 | "github.com/shopspring/decimal" 6 | "github.com/stretchr/testify/assert" 7 | "testing" 8 | "time" 9 | ) 10 | 11 | func TestMod_Run(t *testing.T) { 12 | fnc := NewMod() 13 | 14 | tests := []struct { 15 | name string 16 | a decimal.Decimal 17 | b decimal.Decimal 18 | result decimal.Decimal 19 | }{ 20 | {"mod(6,3)=0", decimal.NewFromInt(6), decimal.NewFromInt(3), decimal.NewFromInt(0)}, 21 | {"mod(66,30)=6", decimal.NewFromInt(66), decimal.NewFromInt(30), decimal.NewFromInt(6)}, 22 | {"mod(-66,30)=-6", decimal.NewFromInt(-66), decimal.NewFromInt(30), decimal.NewFromInt(-6)}, 23 | } 24 | for _, tt := range tests { 25 | t.Run(tt.name, func(t *testing.T) { 26 | args := fnc.Arguments() 27 | args[0].Value = tt.a 28 | args[1].Value = tt.b 29 | 30 | opts := engine.NewOptions(args, map[string]interface{}{}) 31 | resp, err := fnc.Run(opts, time.Now(), "", false) 32 | 33 | assert.NoError(t, err) 34 | assert.Equal(t, tt.result.String(), resp.(decimal.Decimal).String()) 35 | }) 36 | } 37 | 38 | opts := engine.NewOptions(fnc.Arguments(), map[string]interface{}{}) 39 | resp, err := fnc.Run(opts, time.Now(), "", false) 40 | 41 | assert.Error(t, err) 42 | assert.Nil(t, resp) 43 | } 44 | -------------------------------------------------------------------------------- /web/components/fields/FieldBoolean.vue: -------------------------------------------------------------------------------- 1 | 20 | 21 | 58 | 59 | 64 | -------------------------------------------------------------------------------- /web/components/SelectFunc.vue: -------------------------------------------------------------------------------- 1 | 14 | 15 | 58 | -------------------------------------------------------------------------------- /internal/stackfuncs/mul_test.go: -------------------------------------------------------------------------------- 1 | package stackfuncs 2 | 3 | import ( 4 | "github.com/liderman/traderstack/internal/engine" 5 | "github.com/shopspring/decimal" 6 | "github.com/stretchr/testify/assert" 7 | "testing" 8 | "time" 9 | ) 10 | 11 | func TestMul_Run(t *testing.T) { 12 | fnc := NewMul() 13 | 14 | tests := []struct { 15 | name string 16 | a decimal.Decimal 17 | b decimal.Decimal 18 | result decimal.Decimal 19 | }{ 20 | {"2*1=2", decimal.NewFromInt(2), decimal.NewFromInt(1), decimal.NewFromInt(2)}, 21 | {"2*0=0", decimal.NewFromInt(2), decimal.NewFromInt(0), decimal.NewFromInt(0)}, 22 | {"2*2=4", decimal.NewFromInt(2), decimal.NewFromInt(2), decimal.NewFromInt(4)}, 23 | {"2*-2=-4", decimal.NewFromInt(2), decimal.NewFromInt(-2), decimal.NewFromInt(-4)}, 24 | } 25 | for _, tt := range tests { 26 | t.Run(tt.name, func(t *testing.T) { 27 | args := fnc.Arguments() 28 | args[0].Value = tt.a 29 | args[1].Value = tt.b 30 | 31 | opts := engine.NewOptions(args, map[string]interface{}{}) 32 | resp, err := fnc.Run(opts, time.Now(), "", false) 33 | 34 | assert.NoError(t, err) 35 | assert.Equal(t, tt.result.String(), resp.(decimal.Decimal).String()) 36 | }) 37 | } 38 | 39 | opts := engine.NewOptions(fnc.Arguments(), map[string]interface{}{}) 40 | resp, err := fnc.Run(opts, time.Now(), "", false) 41 | 42 | assert.Error(t, err) 43 | assert.Nil(t, resp) 44 | } 45 | -------------------------------------------------------------------------------- /web/components/NuxtLogo.vue: -------------------------------------------------------------------------------- 1 | 14 | 15 | 20 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/liderman/traderstack 2 | 3 | go 1.18 4 | 5 | require ( 6 | github.com/golang/mock v1.6.0 7 | github.com/golang/protobuf v1.5.2 8 | github.com/google/uuid v1.3.0 9 | github.com/grpc-ecosystem/go-grpc-middleware v1.3.0 10 | github.com/improbable-eng/grpc-web v0.15.0 11 | github.com/jessevdk/go-flags v1.5.0 12 | github.com/patrickmn/go-cache v2.1.0+incompatible 13 | github.com/shopspring/decimal v1.3.1 14 | github.com/stretchr/testify v1.7.1 15 | go.uber.org/zap v1.21.0 16 | google.golang.org/grpc v1.46.0 17 | google.golang.org/protobuf v1.27.1 18 | ) 19 | 20 | require ( 21 | github.com/cenkalti/backoff/v4 v4.1.1 // indirect 22 | github.com/davecgh/go-spew v1.1.1 // indirect 23 | github.com/desertbit/timer v0.0.0-20180107155436-c41aec40b27f // indirect 24 | github.com/klauspost/compress v1.11.7 // indirect 25 | github.com/pmezard/go-difflib v1.0.0 // indirect 26 | github.com/rs/cors v1.7.0 // indirect 27 | go.uber.org/atomic v1.7.0 // indirect 28 | go.uber.org/multierr v1.6.0 // indirect 29 | golang.org/x/net v0.0.0-20220225172249-27dd8689420f // indirect 30 | golang.org/x/sys v0.0.0-20220227234510-4e6760a101f9 // indirect 31 | golang.org/x/text v0.3.7 // indirect 32 | google.golang.org/genproto v0.0.0-20210126160654-44e461bb6506 // indirect 33 | gopkg.in/yaml.v2 v2.4.0 // indirect 34 | gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b // indirect 35 | nhooyr.io/websocket v1.8.6 // indirect 36 | ) 37 | -------------------------------------------------------------------------------- /internal/apiclient/order.go: -------------------------------------------------------------------------------- 1 | package apiclient 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "github.com/google/uuid" 7 | "github.com/liderman/traderstack/internal/domain" 8 | "github.com/liderman/traderstack/internal/grpc/gen/tinkoff/investapi" 9 | "github.com/liderman/traderstack/internal/grpc/mapper" 10 | "github.com/shopspring/decimal" 11 | ) 12 | 13 | func (a *RealClient) PostOrder(figi string, lots int64, price decimal.Decimal, direction domain.OrderDirection, accountId string, orderType domain.OrderType) (*domain.PostOrderResponse, error) { 14 | resp, err := a.cOrder.PostOrder(context.Background(), &investapi.PostOrderRequest{ 15 | Figi: figi, 16 | Quantity: lots, 17 | Price: mapper.MapToQuotation(price), 18 | Direction: mapper.MapToOrderDirection(direction), 19 | AccountId: accountId, 20 | OrderType: mapper.MapToOrderType(orderType), 21 | OrderId: uuid.New().String(), 22 | }) 23 | if err != nil { 24 | return nil, fmt.Errorf("error api PostOrder: %w", err) 25 | } 26 | 27 | return mapper.MapFromPostOrderResponse(resp), nil 28 | } 29 | 30 | func (a *RealClient) GetOrders(accountId string) ([]*domain.OrderState, error) { 31 | resp, err := a.cOrder.GetOrders(context.Background(), &investapi.GetOrdersRequest{ 32 | AccountId: accountId, 33 | }) 34 | if err != nil { 35 | return nil, fmt.Errorf("error api GetOrders: %w", err) 36 | } 37 | 38 | return mapper.MapFromOrderStates(resp.Orders), nil 39 | } 40 | -------------------------------------------------------------------------------- /internal/stackfuncs/portfoliolots.go: -------------------------------------------------------------------------------- 1 | package stackfuncs 2 | 3 | import ( 4 | "fmt" 5 | "github.com/liderman/traderstack/internal/apiclient" 6 | "github.com/liderman/traderstack/internal/engine" 7 | "time" 8 | ) 9 | 10 | type PortfolioLots struct { 11 | api apiclient.ApiClient 12 | } 13 | 14 | func NewPortfolioLots(api apiclient.ApiClient) *PortfolioLots { 15 | return &PortfolioLots{ 16 | api: api, 17 | } 18 | } 19 | 20 | func (a *PortfolioLots) Name() string { 21 | return "PortfolioLots" 22 | } 23 | 24 | func (a *PortfolioLots) BaseType() string { 25 | return engine.BaseTypeInteger 26 | } 27 | 28 | func (a *PortfolioLots) Run(options *engine.Options, now time.Time, accountId string, isTest bool) (interface{}, error) { 29 | figi, err := options.GetString("figi") 30 | if err != nil { 31 | return nil, err 32 | } 33 | 34 | fnc := a.api.GetPortfolio 35 | if isTest { 36 | fnc = a.api.GetSandboxPortfolio 37 | } 38 | portfolio, err := fnc(accountId) 39 | if err != nil { 40 | return nil, fmt.Errorf("ошибка получения портфеля: %w", err) 41 | } 42 | 43 | for _, v := range portfolio { 44 | if v.Figi == figi { 45 | return v.QuantityLots.IntPart(), nil 46 | } 47 | } 48 | 49 | return int64(0), nil 50 | } 51 | 52 | func (a *PortfolioLots) Arguments() []*engine.Argument { 53 | return []*engine.Argument{ 54 | { 55 | Id: "figi", 56 | Name: "Figi", 57 | Desc: "Например, TCS", 58 | BaseType: "string", 59 | ExtendedType: "figi-select", 60 | Required: true, 61 | Value: "", 62 | }, 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /internal/engine/stackfuncrepository.go: -------------------------------------------------------------------------------- 1 | package engine 2 | 3 | import ( 4 | "sync" 5 | ) 6 | 7 | type StackFuncRepository struct { 8 | repo map[string]StackFuncRun 9 | 10 | mu sync.RWMutex 11 | } 12 | 13 | func NewStackFuncRepository() *StackFuncRepository { 14 | return &StackFuncRepository{ 15 | repo: map[string]StackFuncRun{}, 16 | } 17 | } 18 | 19 | func (s *StackFuncRepository) Register(sf StackFuncRun) { 20 | s.mu.Lock() 21 | s.repo[sf.Name()] = sf 22 | s.mu.Unlock() 23 | } 24 | 25 | func (s *StackFuncRepository) Get(name string) StackFuncRun { 26 | s.mu.RLock() 27 | defer s.mu.RUnlock() 28 | 29 | return s.repo[name] 30 | } 31 | 32 | func (s *StackFuncRepository) GetAll() []StackFuncRun { 33 | s.mu.RLock() 34 | defer s.mu.RUnlock() 35 | 36 | ret := make([]StackFuncRun, 0, len(s.repo)) 37 | for _, v := range s.repo { 38 | ret = append(ret, v) 39 | } 40 | 41 | return ret 42 | } 43 | 44 | func (s *StackFuncRepository) GetDeclaration(name string) *StackFunc { 45 | return s.funcToDeclaration(s.Get(name)) 46 | } 47 | 48 | func (s *StackFuncRepository) GetAllDeclaration() []*StackFunc { 49 | funcs := s.GetAll() 50 | ret := make([]*StackFunc, 0, len(funcs)) 51 | 52 | for _, v := range funcs { 53 | ret = append(ret, s.funcToDeclaration(v)) 54 | } 55 | 56 | return ret 57 | } 58 | 59 | func (s *StackFuncRepository) funcToDeclaration(fnc StackFuncRun) *StackFunc { 60 | if fnc == nil { 61 | return nil 62 | } 63 | 64 | return &StackFunc{ 65 | Name: fnc.Name(), 66 | Arguments: fnc.Arguments(), 67 | BaseType: fnc.BaseType(), 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /internal/algofunc/rsa_test.go: -------------------------------------------------------------------------------- 1 | package algofunc 2 | 3 | import ( 4 | "github.com/shopspring/decimal" 5 | "github.com/stretchr/testify/assert" 6 | "testing" 7 | ) 8 | 9 | func TestCalcRsi(t *testing.T) { 10 | toDecimal := func(data []int64) []decimal.Decimal { 11 | var ret []decimal.Decimal 12 | for _, v := range data { 13 | ret = append(ret, decimal.NewFromInt(v)) 14 | } 15 | return ret 16 | } 17 | 18 | tests := []struct { 19 | data []int64 20 | expected string 21 | }{ 22 | { 23 | data: []int64{7, 6, 5, 4, 3, 2, 1, 0, 1, 2, 3, 4, 5, 6, 7}, 24 | expected: "50", 25 | }, 26 | { 27 | data: []int64{0, 1, 2, 3, 4, 5, 6, 7, 6, 5, 4, 3, 2, 1, 0}, 28 | expected: "50", 29 | }, 30 | { 31 | data: []int64{1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14}, 32 | expected: "0", 33 | }, 34 | { 35 | data: []int64{14, 13, 12, 11, 10, 9, 8, 7, 6, 5, 4, 3, 2, 1}, 36 | expected: "100", 37 | }, 38 | { 39 | data: []int64{1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 10, 9, 8}, 40 | expected: "23.0769230769230769", 41 | }, 42 | { 43 | data: []int64{11, 10, 9, 8, 7, 6, 5, 4, 3, 2, 1, 2, 3, 4}, 44 | expected: "76.9230769230769229", 45 | }, 46 | { 47 | data: []int64{0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0}, 48 | expected: "100", 49 | }, 50 | { 51 | data: []int64{1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1}, 52 | expected: "100", 53 | }, 54 | } 55 | 56 | for _, tt := range tests { 57 | t.Run(tt.expected, func(t *testing.T) { 58 | assert.Equal(t, tt.expected, CalcRsi(toDecimal(tt.data)).String()) 59 | }) 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /internal/grpcsrv/info_to_protobuf_mapper.go: -------------------------------------------------------------------------------- 1 | package grpcsrv 2 | 3 | import ( 4 | "github.com/liderman/traderstack/internal/domain" 5 | infov1 "github.com/liderman/traderstack/internal/grpcsrv/gen/go/liderman/traderstack/info/v1" 6 | ) 7 | 8 | type InfoToProtobufMapper struct { 9 | } 10 | 11 | func (t *InfoToProtobufMapper) MapInstruments(in []*domain.Share) []*infov1.Instrument { 12 | ret := make([]*infov1.Instrument, 0, len(in)) 13 | for _, v := range in { 14 | ret = append(ret, t.MapInstrument(v)) 15 | } 16 | 17 | return ret 18 | } 19 | 20 | func (t *InfoToProtobufMapper) MapInstrument(in *domain.Share) *infov1.Instrument { 21 | if in == nil { 22 | return nil 23 | } 24 | return &infov1.Instrument{ 25 | Figi: in.Figi, 26 | Ticker: in.Ticker, 27 | Isin: in.Isin, 28 | Lot: in.Lot, 29 | Currency: in.Currency, 30 | Name: in.Name, 31 | Exchange: in.Exchange, 32 | } 33 | } 34 | 35 | func (t *InfoToProtobufMapper) MapMoneys(in []*domain.MoneyValue) []*infov1.Money { 36 | ret := make([]*infov1.Money, 0, len(in)) 37 | for _, v := range in { 38 | ret = append(ret, t.MapMoney(v)) 39 | } 40 | return ret 41 | } 42 | 43 | func (t *InfoToProtobufMapper) MapMoney(in *domain.MoneyValue) *infov1.Money { 44 | return &infov1.Money{ 45 | Currency: in.Currency, 46 | Value: in.Value.String(), 47 | } 48 | } 49 | 50 | func (t *InfoToProtobufMapper) MapAccounts(in []*domain.Account) []*infov1.Account { 51 | ret := make([]*infov1.Account, 0, len(in)) 52 | for _, v := range in { 53 | ret = append(ret, &infov1.Account{ 54 | Id: v.Id, 55 | Name: v.Name, 56 | }) 57 | } 58 | 59 | return ret 60 | } 61 | -------------------------------------------------------------------------------- /web/pages/index.vue: -------------------------------------------------------------------------------- 1 | 28 | 29 | 61 | -------------------------------------------------------------------------------- /internal/grpcsrv/strategy_to_protobuf_mapper.go: -------------------------------------------------------------------------------- 1 | package grpcsrv 2 | 3 | import ( 4 | "github.com/liderman/traderstack/internal/domain" 5 | strategyv1 "github.com/liderman/traderstack/internal/grpcsrv/gen/go/liderman/traderstack/strategy/v1" 6 | "google.golang.org/protobuf/types/known/durationpb" 7 | "google.golang.org/protobuf/types/known/timestamppb" 8 | ) 9 | 10 | type StrategyToProtobufMapper struct { 11 | } 12 | 13 | func (t *StrategyToProtobufMapper) MapStrategy(in *domain.Strategy) *strategyv1.Strategy { 14 | if in == nil { 15 | return nil 16 | } 17 | return &strategyv1.Strategy{ 18 | Id: in.Id, 19 | StackId: in.StackId, 20 | AccountId: in.AccountId, 21 | RunIntervalDuration: durationpb.New(in.RunInterval), 22 | Enabled: in.Enabled, 23 | } 24 | } 25 | 26 | func (t *StrategyToProtobufMapper) MapLog(in *domain.StrategyLog) *strategyv1.StrategyLog { 27 | if in == nil { 28 | return nil 29 | } 30 | return &strategyv1.StrategyLog{ 31 | LogType: string(in.LogType), 32 | Message: in.Message, 33 | Time: timestamppb.New(in.Time), 34 | } 35 | } 36 | 37 | func (t *StrategyToProtobufMapper) MapStrategies(in []*domain.Strategy) []*strategyv1.Strategy { 38 | ret := make([]*strategyv1.Strategy, 0, len(in)) 39 | for _, v := range in { 40 | ret = append(ret, t.MapStrategy(v)) 41 | } 42 | 43 | return ret 44 | } 45 | 46 | func (t *StrategyToProtobufMapper) MapLogs(in []*domain.StrategyLog) []*strategyv1.StrategyLog { 47 | ret := make([]*strategyv1.StrategyLog, 0, len(in)) 48 | for _, v := range in { 49 | ret = append(ret, t.MapLog(v)) 50 | } 51 | 52 | return ret 53 | } 54 | -------------------------------------------------------------------------------- /web/.gitignore: -------------------------------------------------------------------------------- 1 | # Created by .ignore support plugin (hsz.mobi) 2 | ### Node template 3 | # Logs 4 | /logs 5 | *.log 6 | npm-debug.log* 7 | yarn-debug.log* 8 | yarn-error.log* 9 | 10 | # Runtime data 11 | pids 12 | *.pid 13 | *.seed 14 | *.pid.lock 15 | 16 | # Directory for instrumented libs generated by jscoverage/JSCover 17 | lib-cov 18 | 19 | # Coverage directory used by tools like istanbul 20 | coverage 21 | 22 | # nyc test coverage 23 | .nyc_output 24 | 25 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 26 | .grunt 27 | 28 | # Bower dependency directory (https://bower.io/) 29 | bower_components 30 | 31 | # node-waf configuration 32 | .lock-wscript 33 | 34 | # Compiled binary addons (https://nodejs.org/api/addons.html) 35 | build/Release 36 | 37 | # Dependency directories 38 | node_modules/ 39 | jspm_packages/ 40 | 41 | # TypeScript v1 declaration files 42 | typings/ 43 | 44 | # Optional npm cache directory 45 | .npm 46 | 47 | # Optional eslint cache 48 | .eslintcache 49 | 50 | # Optional REPL history 51 | .node_repl_history 52 | 53 | # Output of 'npm pack' 54 | *.tgz 55 | 56 | # Yarn Integrity file 57 | .yarn-integrity 58 | 59 | # dotenv environment variables file 60 | .env 61 | 62 | # parcel-bundler cache (https://parceljs.org/) 63 | .cache 64 | 65 | # next.js build output 66 | .next 67 | 68 | # nuxt.js build output 69 | .nuxt 70 | 71 | # Nuxt generate 72 | dist 73 | 74 | # vuepress build output 75 | .vuepress/dist 76 | 77 | # Serverless directories 78 | .serverless 79 | 80 | # IDE / Editor 81 | .idea 82 | 83 | # Service worker 84 | sw.* 85 | 86 | # macOS 87 | .DS_Store 88 | 89 | # Vim swap files 90 | *.swp 91 | -------------------------------------------------------------------------------- /internal/domain/users.go: -------------------------------------------------------------------------------- 1 | package domain 2 | 3 | import ( 4 | "time" 5 | ) 6 | 7 | type Account struct { 8 | // Идентификатор счёта. 9 | Id string 10 | // Тип счёта. 11 | Type AccountType 12 | // Название счёта. 13 | Name string 14 | // Статус счёта. 15 | Status AccountStatus 16 | // Дата открытия счёта в часовом поясе UTC. 17 | OpenedDate time.Time 18 | // Дата закрытия счёта в часовом поясе UTC. 19 | ClosedDate time.Time 20 | // Уровень доступа к текущему счёту (определяется токеном). 21 | AccessLevel AccessLevel 22 | } 23 | 24 | // AccountType Тип счёта. 25 | type AccountType int32 26 | 27 | const ( 28 | AccountTypeUnspecified AccountType = 0 // Тип аккаунта не определён. 29 | AccountTypeTinkoff AccountType = 1 // Брокерский счёт Тинькофф. 30 | AccountTypeTinkoffIis AccountType = 2 // ИИС счёт. 31 | AccountTypeInvestBox AccountType = 3 // Инвесткопилка. 32 | ) 33 | 34 | // AccountStatus Статус счёта. 35 | type AccountStatus int32 36 | 37 | const ( 38 | AccountStatusUnspecified AccountStatus = 0 // Статус счёта не определён. 39 | AccountStatusNew AccountStatus = 1 // Новый, в процессе открытия. 40 | AccountStatusOpen AccountStatus = 2 // Открытый и активный счёт. 41 | AccountStatusClosed AccountStatus = 3 // Закрытый счёт. 42 | ) 43 | 44 | // AccessLevel Уровень доступа к счёту. 45 | type AccessLevel int32 46 | 47 | const ( 48 | AccessLevelUnspecified AccessLevel = 0 // Уровень доступа не определён. 49 | AccessLevelFullAccess AccessLevel = 1 // Полный доступ к счёту. 50 | AccessLevelReadOnly AccessLevel = 2 // Доступ с уровнем прав "только чтение". 51 | AccessLevelNoAccess AccessLevel = 3 // Доступ отсутствует. 52 | ) 53 | -------------------------------------------------------------------------------- /web/components/fields/FieldDecimal.vue: -------------------------------------------------------------------------------- 1 | 21 | 22 | 70 | 71 | 76 | -------------------------------------------------------------------------------- /cmd/traderstack/config.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import "time" 4 | 5 | // Config Application configuration 6 | type Config struct { 7 | LogLevel string `long:"log-level" description:"Log level: panic, fatal, warn or warning, info, debug" env:"TS_LOG_LEVEL" default:"info"` 8 | LogJSON bool `long:"log-json" description:"Enable force log format JSON" env:"TS_LOG_JSON"` 9 | ListenHTTP string `long:"listen-http" description:"Listening host:port for HTTP-server" env:"TS_LISTEN_HTTP" default:":8080"` 10 | BrokerAPIToken string `long:"broker-api-token" description:"Production token for access broker API" env:"TS_BROKER_API_TOKEN"` 11 | BrokerAPITimeout time.Duration `long:"broker-api-timeout" description:"Broker API timeout" env:"TS_BROKER_API_TIMEOUT" default:"5s"` 12 | BrokerSandboxAPIToken string `long:"broker-sandbox-api-token" description:"Sandbox token for access broker API" env:"TS_BROKER_SANDBOX_API_TOKEN"` 13 | ProductionMode bool `long:"production-mode" description:"Use production API token" env:"TS_PRODUCTION_MODE"` 14 | DataDir string `long:"data-dir" description:"Directory path to save application data" env:"TS_DATA_DIR" default:"data"` 15 | UseDevProxy bool `long:"use-dev-proxy" description:"Enable proxy for static files" env:"TS_USE_DEV_PROXY"` 16 | DevProxyHost string `long:"dev-proxy-host" description:"host:port for static files dev server" env:"TS_DEV_PROXY_HOST" default:"127.0.0.1:3000"` 17 | StaticFilesDir string `long:"static-files-dir" description:"Path for static files" env:"TS_STATIC_FILES_DIR" default:"../../web/dist"` 18 | } 19 | -------------------------------------------------------------------------------- /web/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "TraderStack", 3 | "version": "1.0.0", 4 | "private": true, 5 | "scripts": { 6 | "dev": "nuxt", 7 | "build": "nuxt build", 8 | "start": "nuxt start", 9 | "generate": "nuxt generate", 10 | "lint:js": "eslint --ext \".js,.vue\" --ignore-path .gitignore .", 11 | "lint:style": "stylelint \"**/*.{css,scss,sass,html,vue}\" --ignore-path .gitignore", 12 | "lint:prettier": "prettier --check .", 13 | "lint": "yarn lint:js && yarn lint:style && yarn lint:prettier", 14 | "lintfix": "prettier --write --list-different . && yarn lint:js --fix && yarn lint:style --fix" 15 | }, 16 | "dependencies": { 17 | "@nuxtjs/axios": "^5.13.6", 18 | "core-js": "^3.19.3", 19 | "date-fns": "^2.28.0", 20 | "decimal.js": "^10.3.1", 21 | "google-protobuf": "^3.20.1", 22 | "grpc-web": "1.2.1", 23 | "nuxt": "^2.15.8", 24 | "vue": "^2.6.14", 25 | "vue-server-renderer": "^2.6.14", 26 | "vue-template-compiler": "^2.6.14", 27 | "vuetify": "^2.6.1", 28 | "webpack": "^4.46.0" 29 | }, 30 | "devDependencies": { 31 | "@babel/eslint-parser": "^7.16.3", 32 | "@nuxtjs/eslint-config": "^8.0.0", 33 | "@nuxtjs/eslint-module": "^3.0.2", 34 | "@nuxtjs/stylelint-module": "^4.1.0", 35 | "@nuxtjs/vuetify": "^1.12.3", 36 | "eslint": "^8.4.1", 37 | "eslint-config-prettier": "^8.3.0", 38 | "eslint-plugin-nuxt": "^3.1.0", 39 | "eslint-plugin-vue": "^8.2.0", 40 | "postcss-html": "^1.3.0", 41 | "prettier": "^2.5.1", 42 | "stylelint": "^14.1.0", 43 | "stylelint-config-prettier": "^9.0.3", 44 | "stylelint-config-recommended-vue": "^1.1.0", 45 | "stylelint-config-standard": "^24.0.0" 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /web/.prettierignore: -------------------------------------------------------------------------------- 1 | ### 2 | # Place your Prettier ignore content here 3 | 4 | ### 5 | # .gitignore content is duplicated here due to https://github.com/prettier/prettier/issues/8506 6 | 7 | # Created by .ignore support plugin (hsz.mobi) 8 | ### Node template 9 | # Logs 10 | /logs 11 | *.log 12 | npm-debug.log* 13 | yarn-debug.log* 14 | yarn-error.log* 15 | 16 | # Runtime data 17 | pids 18 | *.pid 19 | *.seed 20 | *.pid.lock 21 | 22 | # Directory for instrumented libs generated by jscoverage/JSCover 23 | lib-cov 24 | 25 | # Coverage directory used by tools like istanbul 26 | coverage 27 | 28 | # nyc test coverage 29 | .nyc_output 30 | 31 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 32 | .grunt 33 | 34 | # Bower dependency directory (https://bower.io/) 35 | bower_components 36 | 37 | # node-waf configuration 38 | .lock-wscript 39 | 40 | # Compiled binary addons (https://nodejs.org/api/addons.html) 41 | build/Release 42 | 43 | # Dependency directories 44 | node_modules/ 45 | jspm_packages/ 46 | 47 | # TypeScript v1 declaration files 48 | typings/ 49 | 50 | # Optional npm cache directory 51 | .npm 52 | 53 | # Optional eslint cache 54 | .eslintcache 55 | 56 | # Optional REPL history 57 | .node_repl_history 58 | 59 | # Output of 'npm pack' 60 | *.tgz 61 | 62 | # Yarn Integrity file 63 | .yarn-integrity 64 | 65 | # dotenv environment variables file 66 | .env 67 | 68 | # parcel-bundler cache (https://parceljs.org/) 69 | .cache 70 | 71 | # next.js build output 72 | .next 73 | 74 | # nuxt.js build output 75 | .nuxt 76 | 77 | # Nuxt generate 78 | dist 79 | 80 | # vuepress build output 81 | .vuepress/dist 82 | 83 | # Serverless directories 84 | .serverless 85 | 86 | # IDE / Editor 87 | .idea 88 | 89 | # Service worker 90 | sw.* 91 | 92 | # macOS 93 | .DS_Store 94 | 95 | # Vim swap files 96 | *.swp 97 | -------------------------------------------------------------------------------- /internal/stackfuncs/inordersbuymarketlots.go: -------------------------------------------------------------------------------- 1 | package stackfuncs 2 | 3 | import ( 4 | "fmt" 5 | "github.com/liderman/traderstack/internal/apiclient" 6 | "github.com/liderman/traderstack/internal/domain" 7 | "github.com/liderman/traderstack/internal/engine" 8 | "time" 9 | ) 10 | 11 | type InOrdersBuyMarketLots struct { 12 | api apiclient.ApiClient 13 | } 14 | 15 | func NewInOrdersBuyMarketLots(api apiclient.ApiClient) *InOrdersBuyMarketLots { 16 | return &InOrdersBuyMarketLots{ 17 | api: api, 18 | } 19 | } 20 | 21 | func (a *InOrdersBuyMarketLots) Name() string { 22 | return "InOrdersBuyMarketLots" 23 | } 24 | 25 | func (a *InOrdersBuyMarketLots) BaseType() string { 26 | return engine.BaseTypeInteger 27 | } 28 | 29 | func (a *InOrdersBuyMarketLots) Run(options *engine.Options, now time.Time, accountId string, isTest bool) (interface{}, error) { 30 | figi, err := options.GetString("figi") 31 | if err != nil { 32 | return nil, err 33 | } 34 | 35 | fnc := a.api.GetOrders 36 | if isTest { 37 | fnc = a.api.GetSandboxOrders 38 | } 39 | orders, err := fnc(accountId) 40 | if err != nil { 41 | return nil, fmt.Errorf("ошибка получения списка заказов: %w", err) 42 | } 43 | 44 | lots := int64(0) 45 | for _, v := range orders { 46 | if v.OrderType == domain.OrderTypeMarket && 47 | v.Direction == domain.OrderDirectionBuy && 48 | v.ExecutionReportStatus.InProcess() && 49 | v.Figi == figi { 50 | lots += v.LotsRequested 51 | } 52 | } 53 | 54 | return lots, nil 55 | } 56 | 57 | func (a *InOrdersBuyMarketLots) Arguments() []*engine.Argument { 58 | return []*engine.Argument{ 59 | { 60 | Id: "figi", 61 | Name: "Figi", 62 | Desc: "Например, TCS", 63 | BaseType: "string", 64 | ExtendedType: "figi-select", 65 | Required: true, 66 | Value: "", 67 | }, 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /internal/domain/operations.go: -------------------------------------------------------------------------------- 1 | package domain 2 | 3 | import ( 4 | "github.com/shopspring/decimal" 5 | ) 6 | 7 | // PositionsResponse Список позиций по счёту. 8 | type PositionsResponse struct { 9 | // Массив валютных позиций портфеля. 10 | Money []*MoneyValue 11 | // Массив заблокированных валютных позиций портфеля. 12 | Blocked []*MoneyValue 13 | // Список ценно-бумажных позиций портфеля. 14 | Securities []*PositionsSecurities 15 | // Признак идущей в данный момент выгрузки лимитов. 16 | LimitsLoadingInProgress bool 17 | } 18 | 19 | // PositionsSecurities Баланс позиции ценной бумаги. 20 | type PositionsSecurities struct { 21 | // Figi-идентификатор бумаги. 22 | Figi string 23 | // Заблокировано. 24 | Blocked int64 25 | // Текущий незаблокированный баланс. 26 | Balance int64 27 | } 28 | 29 | // PortfolioPosition Позиции портфеля. 30 | type PortfolioPosition struct { 31 | Figi string 32 | InstrumentType string 33 | Quantity decimal.Decimal // Количество инструмента в портфеле в штуках. 34 | AveragePositionPrice *MoneyValue // Средневзвешенная цена позиции. **Возможна задержка до секунды для пересчёта**. 35 | ExpectedYield decimal.Decimal // Текущая рассчитанная относительная доходность позиции, в %. 36 | CurrentNkd *MoneyValue // Текущий НКД. 37 | AveragePositionPricePt decimal.Decimal // Средняя цена лота в позиции в пунктах (для фьючерсов). **Возможна задержка до секунды для пересчёта**. 38 | CurrentPrice *MoneyValue // Текущая цена за 1 инструмент. Для получения стоимости лота требуется умножить на лотность инструмента. 39 | AveragePositionPriceFifo *MoneyValue // Средняя цена лота в позиции по методу FIFO. **Возможна задержка до секунды для пересчёта**. 40 | QuantityLots decimal.Decimal // Количество лотов в портфеле. 41 | } 42 | -------------------------------------------------------------------------------- /internal/stackfuncs/inorderssellmarketlots.go: -------------------------------------------------------------------------------- 1 | package stackfuncs 2 | 3 | import ( 4 | "fmt" 5 | "github.com/liderman/traderstack/internal/apiclient" 6 | "github.com/liderman/traderstack/internal/domain" 7 | "github.com/liderman/traderstack/internal/engine" 8 | "time" 9 | ) 10 | 11 | type InOrdersSellMarketLots struct { 12 | api apiclient.ApiClient 13 | } 14 | 15 | func NewInOrdersSellMarketLots(api apiclient.ApiClient) *InOrdersSellMarketLots { 16 | return &InOrdersSellMarketLots{ 17 | api: api, 18 | } 19 | } 20 | 21 | func (a *InOrdersSellMarketLots) Name() string { 22 | return "InOrdersSellMarketLots" 23 | } 24 | 25 | func (a *InOrdersSellMarketLots) BaseType() string { 26 | return engine.BaseTypeInteger 27 | } 28 | 29 | func (a *InOrdersSellMarketLots) Run(options *engine.Options, now time.Time, accountId string, isTest bool) (interface{}, error) { 30 | figi, err := options.GetString("figi") 31 | if err != nil { 32 | return nil, err 33 | } 34 | 35 | fnc := a.api.GetOrders 36 | if isTest { 37 | fnc = a.api.GetSandboxOrders 38 | } 39 | orders, err := fnc(accountId) 40 | if err != nil { 41 | return nil, fmt.Errorf("ошибка получения списка заказов: %w", err) 42 | } 43 | 44 | lots := int64(0) 45 | for _, v := range orders { 46 | if v.OrderType == domain.OrderTypeMarket && 47 | v.Direction == domain.OrderDirectionSell && 48 | v.ExecutionReportStatus.InProcess() && 49 | v.Figi == figi { 50 | lots += v.LotsRequested 51 | } 52 | } 53 | 54 | return lots, nil 55 | } 56 | 57 | func (a *InOrdersSellMarketLots) Arguments() []*engine.Argument { 58 | return []*engine.Argument{ 59 | { 60 | Id: "figi", 61 | Name: "Figi", 62 | Desc: "Например, TCS", 63 | BaseType: "string", 64 | ExtendedType: "figi-select", 65 | Required: true, 66 | Value: "", 67 | }, 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /internal/engine/teststack.go: -------------------------------------------------------------------------------- 1 | package engine 2 | 3 | import ( 4 | "fmt" 5 | "time" 6 | ) 7 | 8 | // TestStack Движок тестирования стека. 9 | type TestStack struct { 10 | sm *StackManager 11 | sfr *StackFuncRepository 12 | } 13 | 14 | func NewTestStack(sm *StackManager, sfr *StackFuncRepository) *TestStack { 15 | return &TestStack{ 16 | sm: sm, 17 | sfr: sfr, 18 | } 19 | } 20 | 21 | type TestItemResult struct { 22 | Variable string 23 | Result interface{} 24 | BaseType string 25 | Error string 26 | } 27 | 28 | func (t *TestStack) Test(stackId string, now time.Time, accountId string) ([]*TestItemResult, error) { 29 | stack := t.sm.Get(stackId) 30 | if stack == nil { 31 | return nil, fmt.Errorf("stack `%s` is not exist", stackId) 32 | } 33 | 34 | varIdx := map[string]interface{}{} 35 | ret := make([]*TestItemResult, 0, len(stack.Items)) 36 | for _, v := range stack.Items { 37 | res := t.testItem(v, varIdx, now, accountId) 38 | if res.Error == "" { 39 | varIdx[res.Variable] = res.Result 40 | } 41 | 42 | ret = append(ret, res) 43 | } 44 | 45 | return ret, nil 46 | } 47 | 48 | func (t *TestStack) testItem(item *StackItem, varIdx map[string]interface{}, now time.Time, accountId string) *TestItemResult { 49 | ret := &TestItemResult{ 50 | Variable: item.Variable, 51 | } 52 | 53 | if item.StackFunc == nil { 54 | ret.Error = "Функция не задана" 55 | return ret 56 | } 57 | 58 | ret.BaseType = item.StackFunc.BaseType 59 | 60 | fnc := t.sfr.Get(item.StackFunc.Name) 61 | if fnc == nil { 62 | ret.Error = "Функция не существует" 63 | return ret 64 | } 65 | 66 | options := NewOptions(item.StackFunc.Arguments, varIdx) 67 | value, err := fnc.Run(options, now, accountId, true) 68 | if err != nil { 69 | ret.Error = fmt.Sprintf("Ошибка выполнения: %s", err) 70 | return ret 71 | } 72 | 73 | ret.Result = value 74 | return ret 75 | } 76 | -------------------------------------------------------------------------------- /internal/grpcsrv/proto/liderman/traderstack/strategy/v1/strategy_api.proto: -------------------------------------------------------------------------------- 1 | syntax = "proto3"; 2 | 3 | package liderman.traderstack.strategy.v1; 4 | 5 | option csharp_namespace = "Liderman.Traderstack.Strategy.V1"; 6 | option go_package = "strategyv1"; 7 | option java_multiple_files = true; 8 | option java_outer_classname = "StrategyApiProto"; 9 | option java_package = "com.liderman.traderstack.strategy.v1"; 10 | option objc_class_prefix = "LTS"; 11 | option php_namespace = "Liderman\\Traderstack\\Strategy\\V1"; 12 | 13 | import "liderman/traderstack/strategy/v1/strategy.proto"; 14 | 15 | // API управления стратегиями. 16 | service StrategyAPI { 17 | // Создаёт новую стратегию. 18 | rpc Create(CreateRequest) returns (CreateResponse); 19 | // Возвращает стратегию. 20 | rpc Get(GetRequest) returns (GetResponse); 21 | // Возвращает список всех стратегий. 22 | rpc GetAll(GetAllRequest) returns (GetAllResponse); 23 | // Обновляет стратегию. 24 | rpc Update(UpdateRequest) returns (UpdateResponse); 25 | // Удаляет стратегию. 26 | rpc Delete(DeleteRequest) returns (DeleteResponse); 27 | // Возвращает логи запуска стратегии. 28 | rpc GetLogs(GetLogsRequest) returns (GetLogsResponse); 29 | } 30 | 31 | message CreateRequest { } 32 | 33 | message CreateResponse { 34 | Strategy strategy = 1; 35 | } 36 | 37 | message GetRequest { 38 | string id = 1; 39 | } 40 | 41 | message GetResponse { 42 | Strategy strategy = 1; 43 | } 44 | 45 | message GetAllRequest { } 46 | 47 | message GetAllResponse { 48 | repeated Strategy strategies = 1; 49 | } 50 | 51 | message UpdateRequest { 52 | Strategy strategy = 1; 53 | } 54 | 55 | message UpdateResponse { } 56 | 57 | message DeleteRequest { 58 | string id = 1; 59 | } 60 | 61 | message DeleteResponse { } 62 | 63 | message GetLogsRequest { 64 | string id = 1; 65 | } 66 | 67 | message GetLogsResponse { 68 | repeated StrategyLog logs = 1; 69 | } 70 | 71 | 72 | -------------------------------------------------------------------------------- /internal/stackfuncs/rsi.go: -------------------------------------------------------------------------------- 1 | package stackfuncs 2 | 3 | import ( 4 | "fmt" 5 | "github.com/liderman/traderstack/internal/algofunc" 6 | "github.com/liderman/traderstack/internal/datamanager" 7 | "github.com/liderman/traderstack/internal/domain" 8 | "github.com/liderman/traderstack/internal/engine" 9 | "time" 10 | ) 11 | 12 | type Rsi struct { 13 | marketData *datamanager.MarketData 14 | } 15 | 16 | func NewRsi(marketData *datamanager.MarketData) *Rsi { 17 | return &Rsi{ 18 | marketData: marketData, 19 | } 20 | } 21 | 22 | func (r *Rsi) Name() string { 23 | return "RSI" 24 | } 25 | 26 | func (r *Rsi) BaseType() string { 27 | return engine.BaseTypeDecimal 28 | } 29 | 30 | func (r *Rsi) Run(options *engine.Options, now time.Time, accountId string, isTest bool) (interface{}, error) { 31 | figi, err := options.GetString("figi") 32 | if err != nil { 33 | return nil, err 34 | } 35 | 36 | period, err := options.GetInteger("period") 37 | if err != nil { 38 | return nil, err 39 | } 40 | 41 | data, err := r.marketData.GetLastCandles(figi, domain.CandleInterval1Day, int(period), now) 42 | if err != nil { 43 | return nil, fmt.Errorf("can't load last candles by figi %s and period %d: %w", figi, period, err) 44 | } 45 | 46 | rsi := algofunc.CalcRsi(domain.HistoricCandleToSliceClose(data)) 47 | return rsi, nil 48 | } 49 | 50 | func (r *Rsi) Arguments() []*engine.Argument { 51 | return []*engine.Argument{ 52 | { 53 | Id: "figi", 54 | Name: "FIGI", 55 | Desc: "Идентификатор инструмента", 56 | BaseType: "string", 57 | ExtendedType: "figi-select", 58 | Required: true, 59 | Value: "", 60 | }, 61 | { 62 | Id: "period", 63 | Name: "Период", 64 | Desc: "Рекомендуется 14 дней", 65 | BaseType: "integer", 66 | ExtendedType: "", 67 | Required: true, 68 | Value: int64(14), 69 | }, 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /web/components/StackFunc.vue: -------------------------------------------------------------------------------- 1 | 32 | 33 | 61 | 62 | 80 | -------------------------------------------------------------------------------- /web/components/selects/SelectStack.vue: -------------------------------------------------------------------------------- 1 | 22 | 23 | 79 | -------------------------------------------------------------------------------- /web/layouts/default.vue: -------------------------------------------------------------------------------- 1 | 43 | 44 | 84 | -------------------------------------------------------------------------------- /internal/grpcsrv/proto/liderman/traderstack/stack/v1/stack.proto: -------------------------------------------------------------------------------- 1 | syntax = "proto3"; 2 | 3 | package liderman.traderstack.stack.v1; 4 | 5 | option csharp_namespace = "Liderman.Traderstack.Stack.V1"; 6 | option go_package = "stackv1"; 7 | option java_multiple_files = true; 8 | option java_outer_classname = "StackProto"; 9 | option java_package = "com.liderman.traderstack.stack.v1"; 10 | option objc_class_prefix = "LTS"; 11 | option php_namespace = "Liderman\\Traderstack\\Stack\\V1"; 12 | 13 | import "google/protobuf/timestamp.proto"; 14 | 15 | // Стек операций. 16 | message Stack { 17 | string id = 1; 18 | string name = 2; 19 | repeated Item items = 3; 20 | } 21 | 22 | // Позиция в стеке. 23 | message Item { 24 | string variable = 1; 25 | StackFunc stack_func = 2; 26 | } 27 | 28 | // Функция стека. 29 | message StackFunc { 30 | string name = 1; 31 | repeated Argument arguments = 2; 32 | string base_type = 3; 33 | } 34 | 35 | // Аргумент функции. 36 | message Argument { 37 | string id = 1; 38 | string name = 2; 39 | string desc = 3; 40 | string base_type = 4; 41 | string extended_type = 5; 42 | bool required = 6; 43 | 44 | oneof value { 45 | Value input = 7; 46 | Variable variable = 8; 47 | } 48 | } 49 | 50 | // Значение переменной. 51 | message Value { 52 | oneof val { 53 | string string = 1; 54 | int64 integer = 2; 55 | string decimal = 3; 56 | bool boolean = 4; 57 | google.protobuf.Timestamp time = 5; 58 | } 59 | } 60 | 61 | // Значение имени переменной. 62 | message Variable { 63 | string name = 1; 64 | } 65 | 66 | // Установка позиции в стеке. 67 | message SetItem { 68 | string variable = 1; 69 | SetStackFunc stack_func = 2; 70 | } 71 | 72 | // Установка функции стека. 73 | message SetStackFunc { 74 | string name = 1; 75 | repeated SetArgument arguments = 2; 76 | } 77 | 78 | // Установка аргумента функции. 79 | message SetArgument { 80 | string id = 1; 81 | oneof value { 82 | Value input = 2; 83 | Variable variable = 3; 84 | } 85 | } 86 | 87 | // Результат тестирования позиции стека. 88 | message TestItemResult { 89 | string variable = 1; 90 | Value result = 2; 91 | string base_type = 3; 92 | string error = 4; 93 | } -------------------------------------------------------------------------------- /internal/grpcsrv/proto/liderman/traderstack/info/v1/info_api.proto: -------------------------------------------------------------------------------- 1 | syntax = "proto3"; 2 | 3 | package liderman.traderstack.info.v1; 4 | 5 | option csharp_namespace = "Liderman.Traderstack.Info.V1"; 6 | option go_package = "infov1"; 7 | option java_multiple_files = true; 8 | option java_outer_classname = "InfoApiProto"; 9 | option java_package = "com.liderman.traderstack.info.v1"; 10 | option objc_class_prefix = "LTI"; 11 | option php_namespace = "Liderman\\Traderstack\\Info\\V1"; 12 | 13 | import "liderman/traderstack/info/v1/info.proto"; 14 | 15 | // API получения справочной информации. 16 | service InfoAPI { 17 | // Ищет финансовые инструменты. 18 | rpc SearchInstrument(SearchInstrumentRequest) returns (SearchInstrumentResponse); 19 | // Возвращает список аккаунтов. 20 | rpc Accounts(AccountsRequest) returns (AccountsResponse); 21 | // Возвращает список аккаунтов для песочницы. 22 | rpc SandboxAccounts(SandboxAccountsRequest) returns (SandboxAccountsResponse); 23 | // Создаёт новый аккаунт для песочницы. 24 | rpc OpenSandboxAccount(OpenSandboxAccountRequest) returns (OpenSandboxAccountResponse); 25 | // Получение позиций для песочницы. 26 | rpc GetSandboxPositions(GetSandboxPositionsRequest) returns (GetSandboxPositionsResponse); 27 | // Пополнение баланса для песочницы. 28 | rpc SandboxPayIn(SandboxPayInRequest) returns (SandboxPayInResponse); 29 | } 30 | 31 | message SearchInstrumentRequest { 32 | string ticker = 1; 33 | } 34 | 35 | message SearchInstrumentResponse { 36 | repeated Instrument instruments = 1; 37 | } 38 | 39 | message AccountsRequest { 40 | } 41 | 42 | message AccountsResponse { 43 | repeated Account accounts = 1; 44 | } 45 | 46 | message SandboxAccountsRequest { 47 | } 48 | 49 | message SandboxAccountsResponse { 50 | repeated Account accounts = 1; 51 | } 52 | 53 | message OpenSandboxAccountRequest { 54 | } 55 | 56 | message OpenSandboxAccountResponse { 57 | string account_id = 1; 58 | } 59 | 60 | message GetSandboxPositionsRequest { 61 | string account_id = 1; 62 | } 63 | 64 | message GetSandboxPositionsResponse { 65 | repeated Money money = 1; 66 | } 67 | 68 | message SandboxPayInRequest { 69 | string account_id = 1; 70 | string amount = 2; 71 | } 72 | 73 | message SandboxPayInResponse { 74 | } -------------------------------------------------------------------------------- /web/components/selects/SelectAccount.vue: -------------------------------------------------------------------------------- 1 | 23 | 24 | 86 | -------------------------------------------------------------------------------- /internal/grpcsrv/server_strategy.go: -------------------------------------------------------------------------------- 1 | package grpcsrv 2 | 3 | import ( 4 | "context" 5 | "github.com/liderman/traderstack/internal/engine" 6 | strategyv1 "github.com/liderman/traderstack/internal/grpcsrv/gen/go/liderman/traderstack/strategy/v1" 7 | "google.golang.org/grpc/codes" 8 | "google.golang.org/grpc/status" 9 | ) 10 | 11 | type StrategyServer struct { 12 | se *engine.StrategyEngine 13 | mapProtobuf StrategyToProtobufMapper 14 | mapDomain StrategyToDomainMapper 15 | } 16 | 17 | func NewStrategyServer(se *engine.StrategyEngine) *StrategyServer { 18 | return &StrategyServer{ 19 | se: se, 20 | } 21 | } 22 | 23 | func (s *StrategyServer) Create(ctx context.Context, in *strategyv1.CreateRequest) (*strategyv1.CreateResponse, error) { 24 | strategy := s.se.Create() 25 | 26 | return &strategyv1.CreateResponse{ 27 | Strategy: s.mapProtobuf.MapStrategy(strategy), 28 | }, nil 29 | } 30 | 31 | func (s *StrategyServer) Get(ctx context.Context, in *strategyv1.GetRequest) (*strategyv1.GetResponse, error) { 32 | strategy := s.se.Get(in.Id) 33 | 34 | return &strategyv1.GetResponse{ 35 | Strategy: s.mapProtobuf.MapStrategy(strategy), 36 | }, nil 37 | } 38 | 39 | func (s *StrategyServer) GetAll(ctx context.Context, in *strategyv1.GetAllRequest) (*strategyv1.GetAllResponse, error) { 40 | strategies := s.se.GetAll() 41 | 42 | return &strategyv1.GetAllResponse{ 43 | Strategies: s.mapProtobuf.MapStrategies(strategies), 44 | }, nil 45 | } 46 | 47 | func (s *StrategyServer) Update(ctx context.Context, in *strategyv1.UpdateRequest) (*strategyv1.UpdateResponse, error) { 48 | err := s.se.Update( 49 | s.mapDomain.MapStrategy(in.Strategy), 50 | ) 51 | if err != nil { 52 | return nil, status.Errorf(codes.Internal, "Failed update strategy: %s", err) 53 | } 54 | 55 | return &strategyv1.UpdateResponse{}, nil 56 | } 57 | 58 | func (s *StrategyServer) Delete(ctx context.Context, in *strategyv1.DeleteRequest) (*strategyv1.DeleteResponse, error) { 59 | s.se.Delete(in.Id) 60 | 61 | return &strategyv1.DeleteResponse{}, nil 62 | } 63 | 64 | func (s *StrategyServer) GetLogs(ctx context.Context, in *strategyv1.GetLogsRequest) (*strategyv1.GetLogsResponse, error) { 65 | logs := s.se.GetLogs(in.Id) 66 | 67 | return &strategyv1.GetLogsResponse{ 68 | Logs: s.mapProtobuf.MapLogs(logs), 69 | }, nil 70 | } 71 | -------------------------------------------------------------------------------- /internal/grpc/mapper/fromdomain.go: -------------------------------------------------------------------------------- 1 | package mapper 2 | 3 | import ( 4 | "github.com/liderman/traderstack/internal/domain" 5 | "github.com/liderman/traderstack/internal/grpc/gen/tinkoff/investapi" 6 | "github.com/shopspring/decimal" 7 | ) 8 | 9 | func MapToCandleInterval(in domain.CandleInterval) investapi.CandleInterval { 10 | switch in { 11 | case domain.CandleInterval1Min: 12 | return investapi.CandleInterval_CANDLE_INTERVAL_1_MIN 13 | case domain.CandleInterval5Min: 14 | return investapi.CandleInterval_CANDLE_INTERVAL_5_MIN 15 | case domain.CandleInterval15Min: 16 | return investapi.CandleInterval_CANDLE_INTERVAL_15_MIN 17 | case domain.CandleInterval1Hour: 18 | return investapi.CandleInterval_CANDLE_INTERVAL_HOUR 19 | case domain.CandleInterval1Day: 20 | return investapi.CandleInterval_CANDLE_INTERVAL_DAY 21 | } 22 | 23 | return investapi.CandleInterval_CANDLE_INTERVAL_UNSPECIFIED 24 | } 25 | 26 | func MapToQuotation(in decimal.Decimal) *investapi.Quotation { 27 | v := in.Sub(decimal.NewFromInt(in.IntPart())).CoefficientInt64() 28 | return &investapi.Quotation{ 29 | Units: in.IntPart(), 30 | Nano: int32(calcFactor(v, -9-in.Exponent())), 31 | } 32 | } 33 | 34 | func calcFactor(v int64, exp int32) int64 { 35 | if exp == 0 { 36 | return v 37 | } 38 | return calcFactor(v*10, exp+1) 39 | } 40 | 41 | func MapToOrderDirection(in domain.OrderDirection) investapi.OrderDirection { 42 | switch in { 43 | case domain.OrderDirectionBuy: 44 | return investapi.OrderDirection_ORDER_DIRECTION_BUY 45 | case domain.OrderDirectionSell: 46 | return investapi.OrderDirection_ORDER_DIRECTION_SELL 47 | } 48 | return investapi.OrderDirection_ORDER_DIRECTION_UNSPECIFIED 49 | } 50 | 51 | func MapToOrderType(in domain.OrderType) investapi.OrderType { 52 | switch in { 53 | case domain.OrderTypeLimit: 54 | return investapi.OrderType_ORDER_TYPE_LIMIT 55 | case domain.OrderTypeMarket: 56 | return investapi.OrderType_ORDER_TYPE_MARKET 57 | } 58 | return investapi.OrderType_ORDER_TYPE_UNSPECIFIED 59 | } 60 | 61 | func MapToMoneyValue(in *domain.MoneyValue) *investapi.MoneyValue { 62 | v := in.Value.Sub(decimal.NewFromInt(in.Value.IntPart())).CoefficientInt64() 63 | return &investapi.MoneyValue{ 64 | Currency: in.Currency, 65 | Units: in.Value.IntPart(), 66 | Nano: int32(calcFactor(v, -9-in.Value.Exponent())), 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /internal/datamanager/instruments.go: -------------------------------------------------------------------------------- 1 | package datamanager 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "github.com/liderman/traderstack/internal/apiclient" 7 | "github.com/liderman/traderstack/internal/domain" 8 | "github.com/patrickmn/go-cache" 9 | "strings" 10 | "time" 11 | ) 12 | 13 | type Instruments struct { 14 | client apiclient.ApiClient 15 | cache *cache.Cache 16 | } 17 | 18 | func NewInstruments(client apiclient.ApiClient, cache *cache.Cache) *Instruments { 19 | return &Instruments{ 20 | client: client, 21 | cache: cache, 22 | } 23 | } 24 | 25 | func (i *Instruments) GetShares() ([]*domain.Share, error) { 26 | if v, ok := i.cache.Get("shares"); ok { 27 | return v.([]*domain.Share), nil 28 | } 29 | 30 | resp, err := i.client.GetShares() 31 | if err != nil { 32 | return nil, fmt.Errorf("error api GetShares: %w", err) 33 | } 34 | 35 | i.cache.Set("shares", resp, time.Minute*15) 36 | 37 | return resp, nil 38 | } 39 | 40 | func (i *Instruments) GetShareByFigi(figi string) (*domain.Share, error) { 41 | shares, err := i.GetShares() 42 | if err != nil { 43 | return nil, err 44 | } 45 | 46 | for _, v := range shares { 47 | if v.Figi == figi { 48 | return v, nil 49 | } 50 | } 51 | 52 | return nil, errors.New("share is not exist") 53 | } 54 | 55 | func (i *Instruments) SearchShares(text string, limit int) ([]*domain.Share, error) { 56 | loText := strings.ToLower(text) 57 | allShares, err := i.GetShares() 58 | if err != nil { 59 | return nil, err 60 | } 61 | 62 | total := 0 63 | rating1 := []*domain.Share{} 64 | rating2 := []*domain.Share{} 65 | rating3 := []*domain.Share{} 66 | for _, v := range allShares { 67 | if total >= limit { 68 | break 69 | } 70 | ticker := strings.ToLower(v.Ticker) 71 | figi := strings.ToLower(v.Figi) 72 | name := strings.ToLower(v.Name) 73 | 74 | if ticker == loText || figi == loText || name == loText { 75 | rating1 = append(rating1, v) 76 | total++ 77 | continue 78 | } 79 | 80 | if strings.HasPrefix(name, loText) || strings.HasPrefix(ticker, loText) { 81 | rating2 = append(rating2, v) 82 | total++ 83 | continue 84 | } 85 | 86 | if strings.Contains(name, loText) { 87 | rating3 = append(rating3, v) 88 | total++ 89 | continue 90 | } 91 | } 92 | 93 | ret := rating1 94 | ret = append(ret, rating2...) 95 | ret = append(ret, rating3...) 96 | return ret, nil 97 | } 98 | -------------------------------------------------------------------------------- /internal/apiclient/client.go: -------------------------------------------------------------------------------- 1 | package apiclient 2 | 3 | //go:generate mockgen -source=./client.go -destination=./mock/client_mock.go -package=mock 4 | 5 | import ( 6 | "github.com/liderman/traderstack/internal/domain" 7 | "github.com/liderman/traderstack/internal/grpc/gen/tinkoff/investapi" 8 | "github.com/shopspring/decimal" 9 | "time" 10 | ) 11 | 12 | type ApiClient interface { 13 | GetCandles(figi string, from time.Time, to time.Time, interval domain.CandleInterval) ([]*domain.HistoricCandle, error) 14 | GetShares() ([]*domain.Share, error) 15 | PostOrder(figi string, lots int64, price decimal.Decimal, direction domain.OrderDirection, accountId string, orderType domain.OrderType) (*domain.PostOrderResponse, error) 16 | GetLastPrices() ([]*domain.LastPrice, error) 17 | GetAccounts() ([]*domain.Account, error) 18 | GetSandboxAccounts() ([]*domain.Account, error) 19 | OpenSandboxAccount() (string, error) 20 | GetSandboxPositions(accountId string) (*domain.PositionsResponse, error) 21 | SandboxPayIn(accountId string, amount *domain.MoneyValue) error 22 | PostSandboxOrder(figi string, lots int64, price decimal.Decimal, direction domain.OrderDirection, accountId string, orderType domain.OrderType) (*domain.PostOrderResponse, error) 23 | GetSandboxPortfolio(accountId string) ([]*domain.PortfolioPosition, error) 24 | GetPortfolio(accountId string) ([]*domain.PortfolioPosition, error) 25 | GetOrders(accountId string) ([]*domain.OrderState, error) 26 | GetSandboxOrders(accountId string) ([]*domain.OrderState, error) 27 | } 28 | 29 | type RealClient struct { 30 | cMarket investapi.MarketDataServiceClient 31 | cInstrument investapi.InstrumentsServiceClient 32 | cOrder investapi.OrdersServiceClient 33 | cUsers investapi.UsersServiceClient 34 | cSandbox investapi.SandboxServiceClient 35 | cOperations investapi.OperationsServiceClient 36 | } 37 | 38 | func NewRealClient( 39 | cMarket investapi.MarketDataServiceClient, 40 | cInstrument investapi.InstrumentsServiceClient, 41 | cOrder investapi.OrdersServiceClient, 42 | cUsers investapi.UsersServiceClient, 43 | cSandbox investapi.SandboxServiceClient, 44 | cOperations investapi.OperationsServiceClient, 45 | ) *RealClient { 46 | return &RealClient{ 47 | cMarket: cMarket, 48 | cInstrument: cInstrument, 49 | cOrder: cOrder, 50 | cUsers: cUsers, 51 | cSandbox: cSandbox, 52 | cOperations: cOperations, 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /web/components/DlgTestStack.vue: -------------------------------------------------------------------------------- 1 | 52 | 53 | 89 | -------------------------------------------------------------------------------- /web/components/DlgCreateStack.vue: -------------------------------------------------------------------------------- 1 | 51 | 52 | 96 | -------------------------------------------------------------------------------- /internal/datamanager/marketdata_test.go: -------------------------------------------------------------------------------- 1 | package datamanager 2 | 3 | import ( 4 | "github.com/golang/mock/gomock" 5 | "github.com/liderman/traderstack/internal/apiclient" 6 | "github.com/liderman/traderstack/internal/apiclient/mock" 7 | "github.com/liderman/traderstack/internal/domain" 8 | "github.com/patrickmn/go-cache" 9 | "github.com/stretchr/testify/assert" 10 | "testing" 11 | "time" 12 | ) 13 | 14 | func TestMarketData_GetCandles(t *testing.T) { 15 | ctl := gomock.NewController(t) 16 | defer ctl.Finish() 17 | 18 | client := mock.NewMockApiClient(ctl) 19 | 20 | figi := "test" 21 | to := time.Now() 22 | from := to.Add(-apiclient.LimitGetHistoricCandlesDuration(domain.CandleInterval1Day)) 23 | interval := domain.CandleInterval1Day 24 | 25 | gomock.InOrder( 26 | client.EXPECT().GetCandles(figi, from, to, interval).Return([]*domain.HistoricCandle{ 27 | {Time: time.Now()}, 28 | {Time: time.Now().Add(-time.Hour * 24)}, 29 | {Time: time.Now().Add(-time.Hour * 24 * 10)}, 30 | {Time: time.Now().Add(-time.Hour * 24 * 2)}, 31 | }, nil), 32 | ) 33 | 34 | md := NewMarketData(client, cache.New(time.Minute, time.Minute)) 35 | resp, err := md.GetCandles(figi, from, to, interval) 36 | assert.NoError(t, err) 37 | for i := 1; i < 4; i++ { 38 | assert.True(t, resp[i-1].Time.After(resp[i].Time)) 39 | } 40 | } 41 | 42 | func TestMarketData_GetLastCandles(t *testing.T) { 43 | ctl := gomock.NewController(t) 44 | defer ctl.Finish() 45 | 46 | client := mock.NewMockApiClient(ctl) 47 | 48 | figi := "test" 49 | interval := domain.CandleInterval1Day 50 | 51 | gomock.InOrder( 52 | client.EXPECT().GetCandles(figi, gomock.Any(), gomock.Any(), interval).Return([]*domain.HistoricCandle{ 53 | {Time: time.Now()}, 54 | {Time: time.Now().Add(-time.Hour * 24)}, 55 | {Time: time.Now().Add(-time.Hour * 24 * 3)}, 56 | {Time: time.Now().Add(-time.Hour * 24 * 2)}, 57 | }, nil), 58 | client.EXPECT().GetCandles(figi, gomock.Any(), gomock.Any(), interval).Return([]*domain.HistoricCandle{ 59 | {Time: time.Now().Add(-time.Hour * 24 * 5)}, 60 | {Time: time.Now().Add(-time.Hour * 24 * 4)}, 61 | {Time: time.Now().Add(-time.Hour * 24 * 8)}, 62 | {Time: time.Now().Add(-time.Hour * 24 * 7)}, 63 | {Time: time.Now().Add(-time.Hour * 24 * 6)}, 64 | }, nil), 65 | ) 66 | 67 | md := NewMarketData(client, cache.New(time.Minute, time.Minute)) 68 | resp, err := md.GetLastCandles(figi, interval, 6, time.Now()) 69 | assert.NoError(t, err) 70 | assert.Len(t, resp, 6) 71 | for i := 1; i < 6; i++ { 72 | assert.True(t, resp[i-1].Time.After(resp[i].Time)) 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /internal/grpcsrv/to_domain_mapper.go: -------------------------------------------------------------------------------- 1 | package grpcsrv 2 | 3 | import ( 4 | "github.com/liderman/traderstack/internal/engine" 5 | "github.com/liderman/traderstack/internal/engine/baseoption" 6 | stackv1 "github.com/liderman/traderstack/internal/grpcsrv/gen/go/liderman/traderstack/stack/v1" 7 | "github.com/shopspring/decimal" 8 | ) 9 | 10 | type ToDomainMapper struct { 11 | } 12 | 13 | func (t *ToDomainMapper) MapSetItems(in []*stackv1.SetItem) []*engine.SetStackItem { 14 | ret := make([]*engine.SetStackItem, 0, len(in)) 15 | for _, v := range in { 16 | ret = append(ret, t.MapSetItem(v)) 17 | } 18 | 19 | return ret 20 | } 21 | 22 | func (t *ToDomainMapper) MapSetItem(in *stackv1.SetItem) *engine.SetStackItem { 23 | return &engine.SetStackItem{ 24 | Variable: in.Variable, 25 | StackFunc: t.MapSetStackFunc(in.StackFunc), 26 | } 27 | } 28 | 29 | func (t *ToDomainMapper) MapSetStackFunc(in *stackv1.SetStackFunc) *engine.SetStackFunc { 30 | if in == nil { 31 | return nil 32 | } 33 | 34 | fnc := &engine.SetStackFunc{ 35 | Name: in.Name, 36 | } 37 | 38 | for _, v := range in.Arguments { 39 | fnc.Arguments = append(fnc.Arguments, t.MapSetArgument(v)) 40 | } 41 | 42 | return fnc 43 | } 44 | 45 | func (t *ToDomainMapper) MapSetArgument(in *stackv1.SetArgument) *engine.SetArgument { 46 | ret := &engine.SetArgument{ 47 | Id: in.Id, 48 | } 49 | 50 | switch v := in.Value.(type) { 51 | case *stackv1.SetArgument_Variable: 52 | ret.Value = t.MapVariable(v.Variable) 53 | case *stackv1.SetArgument_Input: 54 | ret.Value = t.MapValue(v.Input) 55 | } 56 | 57 | return ret 58 | } 59 | 60 | func (t *ToDomainMapper) MapValue(in *stackv1.Value) interface{} { 61 | if in == nil { 62 | return nil 63 | } 64 | 65 | switch v := in.Val.(type) { 66 | case *stackv1.Value_Integer: 67 | return v.Integer 68 | case *stackv1.Value_String_: 69 | return v.String_ 70 | case *stackv1.Value_Boolean: 71 | return v.Boolean 72 | case *stackv1.Value_Decimal: 73 | return t.MapDecimal(v.Decimal) 74 | case *stackv1.Value_Time: 75 | return v.Time.AsTime() 76 | } 77 | return nil 78 | } 79 | 80 | func (t *ToDomainMapper) MapDecimal(in string) decimal.Decimal { 81 | if in == "" { 82 | return decimal.Zero 83 | } 84 | 85 | ret, err := decimal.NewFromString(in) 86 | if err != nil { 87 | return decimal.Zero 88 | } 89 | 90 | return ret 91 | } 92 | 93 | func (t *ToDomainMapper) MapVariable(in *stackv1.Variable) *baseoption.Variable { 94 | if in == nil { 95 | return nil 96 | } 97 | 98 | return &baseoption.Variable{ 99 | Name: in.Name, 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /internal/grpc/proto/tinkoff/investapi/common.proto: -------------------------------------------------------------------------------- 1 | syntax = "proto3"; 2 | 3 | package tinkoff.public.invest.api.contract.v1; 4 | 5 | option go_package = "./;investapi"; 6 | option java_package = "ru.tinkoff.piapi.contract.v1"; 7 | option java_multiple_files = true; 8 | option csharp_namespace = "Tinkoff.InvestApi.V1"; 9 | option objc_class_prefix = "TIAPI"; 10 | option php_namespace = "Tinkoff\\Invest\\V1"; 11 | 12 | import "google/protobuf/timestamp.proto"; 13 | 14 | //Денежная сумма в определенной валюте 15 | message MoneyValue { 16 | 17 | // строковый ISO-код валюты 18 | string currency = 1; 19 | 20 | // целая часть суммы, может быть отрицательным числом 21 | int64 units = 2; 22 | 23 | // дробная часть суммы, может быть отрицательным числом 24 | int32 nano = 3; 25 | } 26 | 27 | //Котировка - денежная сумма без указания валюты 28 | message Quotation { 29 | 30 | // целая часть суммы, может быть отрицательным числом 31 | int64 units = 1; 32 | 33 | // дробная часть суммы, может быть отрицательным числом 34 | int32 nano = 2; 35 | } 36 | 37 | //Режим торгов инструмента 38 | enum SecurityTradingStatus { 39 | SECURITY_TRADING_STATUS_UNSPECIFIED = 0; //Торговый статус не определён 40 | SECURITY_TRADING_STATUS_NOT_AVAILABLE_FOR_TRADING = 1; //Недоступен для торгов 41 | SECURITY_TRADING_STATUS_OPENING_PERIOD = 2; //Период открытия торгов 42 | SECURITY_TRADING_STATUS_CLOSING_PERIOD = 3; //Период закрытия торгов 43 | SECURITY_TRADING_STATUS_BREAK_IN_TRADING = 4; //Перерыв в торговле 44 | SECURITY_TRADING_STATUS_NORMAL_TRADING = 5; //Нормальная торговля 45 | SECURITY_TRADING_STATUS_CLOSING_AUCTION = 6; //Аукцион закрытия 46 | SECURITY_TRADING_STATUS_DARK_POOL_AUCTION = 7; //Аукцион крупных пакетов 47 | SECURITY_TRADING_STATUS_DISCRETE_AUCTION = 8; //Дискретный аукцион 48 | SECURITY_TRADING_STATUS_OPENING_AUCTION_PERIOD = 9; //Аукцион открытия 49 | SECURITY_TRADING_STATUS_TRADING_AT_CLOSING_AUCTION_PRICE = 10; //Период торгов по цене аукциона закрытия 50 | SECURITY_TRADING_STATUS_SESSION_ASSIGNED = 11; //Сессия назначена 51 | SECURITY_TRADING_STATUS_SESSION_CLOSE = 12; //Сессия закрыта 52 | SECURITY_TRADING_STATUS_SESSION_OPEN = 13; //Сессия открыта 53 | SECURITY_TRADING_STATUS_DEALER_NORMAL_TRADING = 14; //Доступна торговля в режиме внутренней ликвидности брокера 54 | SECURITY_TRADING_STATUS_DEALER_BREAK_IN_TRADING = 15; //Перерыв торговли в режиме внутренней ликвидности брокера 55 | SECURITY_TRADING_STATUS_DEALER_NOT_AVAILABLE_FOR_TRADING = 16; //Недоступна торговля в режиме внутренней ликвидности брокера 56 | } 57 | 58 | //Проверка активности стрима. 59 | message Ping { 60 | 61 | //Время проверки. 62 | google.protobuf.Timestamp time = 1; 63 | } -------------------------------------------------------------------------------- /internal/grpcsrv/proto/liderman/traderstack/stack/v1/stack_api.proto: -------------------------------------------------------------------------------- 1 | syntax = "proto3"; 2 | 3 | package liderman.traderstack.stack.v1; 4 | 5 | option csharp_namespace = "Liderman.Traderstack.Stack.V1"; 6 | option go_package = "stackv1"; 7 | option java_multiple_files = true; 8 | option java_outer_classname = "StackApiProto"; 9 | option java_package = "com.liderman.traderstack.stack.v1"; 10 | option objc_class_prefix = "LTS"; 11 | option php_namespace = "Liderman\\Traderstack\\Stack\\V1"; 12 | 13 | import "liderman/traderstack/stack/v1/stack.proto"; 14 | import "google/protobuf/timestamp.proto"; 15 | 16 | // API управления стеком. 17 | service StackAPI { 18 | // Создаёт новый стек. 19 | rpc Create(CreateRequest) returns (CreateResponse); 20 | // Обновляет стек. 21 | rpc Update(UpdateRequest) returns (UpdateResponse); 22 | // Удаляет стек. 23 | rpc Delete(DeleteRequest) returns (DeleteResponse); 24 | // Возвращает стек. 25 | rpc Get(GetRequest) returns (GetResponse); 26 | // Возвращает все стеки. 27 | rpc GetAll(GetAllRequest) returns (GetAllResponse); 28 | // Тестирует стек. 29 | rpc Test(TestRequest) returns (TestResponse); 30 | // Возвращает список функций. 31 | rpc FuncList(FuncListRequest) returns (FuncListResponse); 32 | // Возвращает список переменных для аргумента функции. 33 | rpc FuncArgumentVarList(FuncArgumentVarListRequest) returns (FuncArgumentVarListResponse); 34 | } 35 | 36 | message CreateRequest { 37 | string name = 1; 38 | } 39 | 40 | message CreateResponse { 41 | Stack stack = 1; 42 | } 43 | 44 | message UpdateRequest { 45 | string id = 1; 46 | string name = 2; 47 | repeated SetItem items = 3; 48 | } 49 | 50 | message UpdateResponse { 51 | Stack stack = 1; 52 | } 53 | 54 | message DeleteRequest { 55 | string id = 1; 56 | } 57 | 58 | message DeleteResponse { } 59 | 60 | message GetRequest { 61 | string id = 1; 62 | } 63 | 64 | message GetResponse { 65 | Stack stack = 1; 66 | } 67 | 68 | message GetAllRequest { } 69 | 70 | message GetAllResponse { 71 | repeated Stack stacks = 1; 72 | } 73 | 74 | message TestRequest { 75 | string id = 1; 76 | google.protobuf.Timestamp time = 2; 77 | string account_id = 3; 78 | } 79 | 80 | message TestResponse { 81 | repeated TestItemResult result = 1; 82 | } 83 | 84 | message FuncListRequest { } 85 | 86 | message FuncListResponse { 87 | repeated StackFunc func = 1; 88 | } 89 | 90 | 91 | message FuncArgumentVarListRequest { 92 | string stack_id = 1; 93 | string item_variable = 2; 94 | string argument_id = 3; 95 | 96 | } 97 | 98 | message FuncArgumentVarListResponse { 99 | repeated string variables = 1; 100 | } 101 | 102 | -------------------------------------------------------------------------------- /internal/engine/options.go: -------------------------------------------------------------------------------- 1 | package engine 2 | 3 | import ( 4 | "errors" 5 | "github.com/liderman/traderstack/internal/engine/baseoption" 6 | "github.com/shopspring/decimal" 7 | ) 8 | 9 | var ErrOptionArgNotExist = errors.New("argument is not exist") 10 | var ErrOptionVarNotExist = errors.New("variable is not exist") 11 | var ErrOptionBadType = errors.New("value is bad type") 12 | 13 | type Options struct { 14 | argIdx map[string]interface{} 15 | varIdx map[string]interface{} 16 | } 17 | 18 | func NewOptions(arguments []*Argument, varIdx map[string]interface{}) *Options { 19 | argIdx := map[string]interface{}{} 20 | for _, v := range arguments { 21 | argIdx[v.Id] = v.Value 22 | } 23 | 24 | return &Options{ 25 | argIdx: argIdx, 26 | varIdx: varIdx, 27 | } 28 | } 29 | 30 | func (o *Options) get(key string) (interface{}, error) { 31 | v := o.argIdx[key] 32 | if v == nil { 33 | return v, ErrOptionArgNotExist 34 | } 35 | 36 | variable, varOk := v.(*baseoption.Variable) 37 | if varOk { 38 | ret, ok := o.varIdx[variable.Name] 39 | if !ok { 40 | return ret, ErrOptionVarNotExist 41 | } 42 | return ret, nil 43 | } 44 | 45 | return v, nil 46 | } 47 | 48 | func (o *Options) GetInteger(key string) (int64, error) { 49 | v, err := o.get(key) 50 | if err != nil { 51 | return 0, err 52 | } 53 | 54 | ret, ok := v.(int64) 55 | if !ok { 56 | return 0, ErrOptionBadType 57 | } 58 | return ret, nil 59 | } 60 | 61 | func (o *Options) GetString(key string) (string, error) { 62 | v, err := o.get(key) 63 | if err != nil { 64 | return "", err 65 | } 66 | 67 | ret, ok := v.(string) 68 | if !ok { 69 | return "", ErrOptionBadType 70 | } 71 | return ret, nil 72 | } 73 | 74 | func (o *Options) GetBoolean(key string) (bool, error) { 75 | v, err := o.get(key) 76 | if err != nil { 77 | return false, err 78 | } 79 | 80 | ret, ok := v.(bool) 81 | if !ok { 82 | return false, ErrOptionBadType 83 | } 84 | return ret, nil 85 | } 86 | 87 | func (o *Options) GetDecimal(key string) (decimal.Decimal, error) { 88 | v, err := o.get(key) 89 | if err != nil { 90 | return decimal.Zero, err 91 | } 92 | 93 | ret, ok := v.(decimal.Decimal) 94 | if !ok { 95 | return decimal.Zero, ErrOptionBadType 96 | } 97 | return ret, nil 98 | } 99 | 100 | func (o *Options) GetNumericDecimal(key string) (decimal.Decimal, error) { 101 | v, err := o.get(key) 102 | if err != nil { 103 | return decimal.Zero, err 104 | } 105 | 106 | switch val := v.(type) { 107 | case decimal.Decimal: 108 | return val, nil 109 | case int64: 110 | return decimal.NewFromInt(val), nil 111 | } 112 | 113 | return decimal.Zero, ErrOptionBadType 114 | } 115 | -------------------------------------------------------------------------------- /web/nuxt.config.js: -------------------------------------------------------------------------------- 1 | import colors from 'vuetify/es5/util/colors' 2 | 3 | export default { 4 | // Disable server-side rendering: https://go.nuxtjs.dev/ssr-mode 5 | ssr: false, 6 | 7 | // Target: https://go.nuxtjs.dev/config-target 8 | target: 'static', 9 | 10 | // Global page headers: https://go.nuxtjs.dev/config-head 11 | head: { 12 | titleTemplate: 'TraderStack / %s', 13 | title: 'TraderStack', 14 | htmlAttrs: { 15 | lang: 'en', 16 | }, 17 | meta: [ 18 | { charset: 'utf-8' }, 19 | { name: 'viewport', content: 'width=device-width, initial-scale=1' }, 20 | { hid: 'description', name: 'description', content: '' }, 21 | { name: 'format-detection', content: 'telephone=no' }, 22 | ], 23 | link: [ 24 | { rel: 'icon', type: 'image/x-icon', href: '/favicon.ico' }, 25 | { 26 | rel: 'stylesheet', 27 | href: 28 | 'https://fonts.googleapis.com/css?family=Roboto:300,400,500,700|Material+Icons' 29 | } 30 | ], 31 | }, 32 | 33 | // Global CSS: https://go.nuxtjs.dev/config-css 34 | css: [], 35 | 36 | // Plugins to run before rendering page: https://go.nuxtjs.dev/config-plugins 37 | plugins: [ 38 | '~plugins/filters.js', 39 | ], 40 | 41 | // Auto import components: https://go.nuxtjs.dev/config-components 42 | components: true, 43 | 44 | // Modules for dev and build (recommended): https://go.nuxtjs.dev/config-modules 45 | buildModules: [ 46 | // https://go.nuxtjs.dev/eslint 47 | '@nuxtjs/eslint-module', 48 | // https://go.nuxtjs.dev/stylelint 49 | '@nuxtjs/stylelint-module', 50 | // https://go.nuxtjs.dev/vuetify 51 | '@nuxtjs/vuetify', 52 | ], 53 | 54 | // Modules: https://go.nuxtjs.dev/config-modules 55 | modules: [ 56 | // https://go.nuxtjs.dev/axios 57 | '@nuxtjs/axios', 58 | ], 59 | 60 | // Axios module configuration: https://go.nuxtjs.dev/config-axios 61 | axios: { 62 | // Workaround to avoid enforcing hard-coded localhost:3000: https://github.com/nuxt-community/axios-module/issues/308 63 | baseURL: '/', 64 | }, 65 | 66 | // Vuetify module configuration: https://go.nuxtjs.dev/config-vuetify 67 | vuetify: { 68 | customVariables: ['~/assets/variables.scss'], 69 | theme: { 70 | dark: true, 71 | themes: { 72 | dark: { 73 | primary: colors.blue.darken2, 74 | accent: colors.grey.darken3, 75 | secondary: colors.amber.darken3, 76 | info: colors.teal.lighten1, 77 | warning: colors.amber.base, 78 | error: colors.deepOrange.accent4, 79 | success: colors.green.accent3, 80 | }, 81 | }, 82 | }, 83 | }, 84 | 85 | // Build Configuration: https://go.nuxtjs.dev/config-build 86 | build: {}, 87 | } 88 | -------------------------------------------------------------------------------- /web/pages/stacks.vue: -------------------------------------------------------------------------------- 1 | 47 | 48 | 99 | -------------------------------------------------------------------------------- /web/grpc/mappers.js: -------------------------------------------------------------------------------- 1 | import { UpdateRequest } from "~/grpc/gen/js/liderman/traderstack/stack/v1/stack_api_pb"; 2 | import { SetItem, SetStackFunc, SetArgument, Variable, Value } from "~/grpc/gen/js/liderman/traderstack/stack/v1/stack_pb"; 3 | 4 | const stackToUpdateRequest = (stack, stackId, stackName) => { 5 | const req = new UpdateRequest(); 6 | req.setId(stackId); 7 | req.setName(stackName); 8 | stack.forEach((elItem, indexItem) => { 9 | const item = new SetItem(); 10 | item.setVariable(elItem.variable); 11 | item.setStackFunc(mapStackFunc(elItem.stackFunc)); 12 | 13 | req.addItems(item, indexItem); 14 | }); 15 | 16 | return req; 17 | } 18 | 19 | const mapStackFunc = (stackFunc) => { 20 | if (!stackFunc) { 21 | return undefined; 22 | } 23 | 24 | const ret = new SetStackFunc(); 25 | ret.setName(stackFunc.name); 26 | stackFunc.argumentsList.forEach((argument, indexArg) => { 27 | ret.addArguments(mapArgument(argument), indexArg); 28 | }) 29 | 30 | return ret; 31 | } 32 | 33 | const mapArgument = (argument) => { 34 | const ret = new SetArgument(); 35 | ret.setId(argument.id); 36 | if (argument.variable !== undefined) { 37 | const variable = new Variable(); 38 | variable.setName(argument.variable.name ? argument.variable.name : ''); 39 | ret.setVariable(variable); 40 | return ret; 41 | } 42 | 43 | const value = new Value(); 44 | switch (argument.baseType) { 45 | case "string": 46 | value.setString(argument.input.string); 47 | break; 48 | case "integer": 49 | value.setInteger(argument.input.integer); 50 | break 51 | case "decimal": 52 | value.setDecimal(argument.input.decimal); 53 | break 54 | case "boolean": 55 | value.setBoolean(argument.input.pb_boolean); 56 | break 57 | } 58 | 59 | ret.setInput(value); 60 | 61 | return ret; 62 | } 63 | 64 | const setValueByArgument = (argument, varType, val) => { 65 | const ret = { 66 | input: undefined, 67 | variable: undefined, 68 | }; 69 | 70 | if (varType === 'variable') { 71 | const variable = new Variable(); 72 | variable.setName(val === undefined ? '' : val); 73 | ret.variable = variable.toObject(); 74 | return ret; 75 | } 76 | 77 | const value = new Value(); 78 | switch (argument.baseType) { 79 | case "string": 80 | value.setString(val); 81 | break; 82 | case "integer": 83 | value.setInteger(val); 84 | break; 85 | case "decimal": 86 | value.setDecimal(val); 87 | break; 88 | case "boolean": 89 | value.setBoolean(val); 90 | break; 91 | } 92 | 93 | ret.input = value.toObject(); 94 | return ret; 95 | } 96 | 97 | export { 98 | stackToUpdateRequest, 99 | setValueByArgument, 100 | } 101 | -------------------------------------------------------------------------------- /web/components/fields/ExFieldFigiSelect.vue: -------------------------------------------------------------------------------- 1 | 37 | 38 | 97 | 98 | 103 | -------------------------------------------------------------------------------- /internal/grpc/proto/tinkoff/investapi/sandbox.proto: -------------------------------------------------------------------------------- 1 | syntax = "proto3"; 2 | 3 | package tinkoff.public.invest.api.contract.v1; 4 | 5 | option go_package = "./;investapi"; 6 | option java_package = "ru.tinkoff.piapi.contract.v1"; 7 | option java_multiple_files = true; 8 | option csharp_namespace = "Tinkoff.InvestApi.V1"; 9 | option objc_class_prefix = "TIAPI"; 10 | option php_namespace = "Tinkoff\\Invest\\V1"; 11 | 12 | import "common.proto"; 13 | import "orders.proto"; 14 | import "operations.proto"; 15 | import "users.proto"; 16 | 17 | service SandboxService { //Сервис для работы с песочницей TINKOFF INVEST API 18 | 19 | //Метод регистрации счёта в песочнице. 20 | rpc OpenSandboxAccount(OpenSandboxAccountRequest) returns (OpenSandboxAccountResponse); 21 | 22 | //Метод получения счетов в песочнице. 23 | rpc GetSandboxAccounts(GetAccountsRequest) returns (GetAccountsResponse); 24 | 25 | //Метод закрытия счёта в песочнице. 26 | rpc CloseSandboxAccount(CloseSandboxAccountRequest) returns (CloseSandboxAccountResponse); 27 | 28 | //Метод выставления торгового поручения в песочнице. 29 | rpc PostSandboxOrder(PostOrderRequest) returns (PostOrderResponse); 30 | 31 | //Метод получения списка активных заявок по счёту в песочнице. 32 | rpc GetSandboxOrders(GetOrdersRequest) returns (GetOrdersResponse); 33 | 34 | //Метод отмены торгового поручения в песочнице. 35 | rpc CancelSandboxOrder(CancelOrderRequest) returns (CancelOrderResponse); 36 | 37 | //Метод получения статуса заявки в песочнице. 38 | rpc GetSandboxOrderState(GetOrderStateRequest) returns (OrderState); 39 | 40 | //Метод получения позиций по виртуальному счёту песочницы. 41 | rpc GetSandboxPositions(PositionsRequest) returns (PositionsResponse); 42 | 43 | //Метод получения операций в песочнице по номеру счёта. 44 | rpc GetSandboxOperations(OperationsRequest) returns (OperationsResponse); 45 | 46 | //Метод получения портфолио в песочнице. 47 | rpc GetSandboxPortfolio(PortfolioRequest) returns (PortfolioResponse); 48 | 49 | //Метод пополнения счёта в песочнице. 50 | rpc SandboxPayIn(SandboxPayInRequest) returns (SandboxPayInResponse); 51 | } 52 | 53 | //Запрос открытия счёта в песочнице. 54 | message OpenSandboxAccountRequest { 55 | //пустой запрос 56 | } 57 | 58 | //Номер открытого счёта в песочнице. 59 | message OpenSandboxAccountResponse { 60 | string account_id = 1; //Номер счёта 61 | } 62 | 63 | //Запрос закрытия счёта в песочнице. 64 | message CloseSandboxAccountRequest { 65 | string account_id = 1; //Номер счёта 66 | } 67 | 68 | //Результат закрытия счёта в песочнице. 69 | message CloseSandboxAccountResponse { 70 | //пустой ответ 71 | } 72 | 73 | //Запрос пополнения счёта в песочнице. 74 | message SandboxPayInRequest { 75 | string account_id = 1; //Номер счёта 76 | MoneyValue amount = 2; //Сумма пополнения счёта в рублях 77 | } 78 | 79 | //Результат пополнения счёта, текущий баланс. 80 | message SandboxPayInResponse { 81 | MoneyValue balance = 1; //Текущий баланс счёта 82 | } 83 | -------------------------------------------------------------------------------- /web/components/fields/AnyField.vue: -------------------------------------------------------------------------------- 1 | 62 | 63 | 75 | 76 | 90 | -------------------------------------------------------------------------------- /web/README.md: -------------------------------------------------------------------------------- 1 | # TraderStack 2 | 3 | ## Build Setup 4 | 5 | ```bash 6 | # install dependencies 7 | $ yarn install 8 | 9 | # serve with hot reload at localhost:3000 10 | $ yarn dev 11 | 12 | # build for production and launch server 13 | $ yarn build 14 | $ yarn start 15 | 16 | # generate static project 17 | $ yarn generate 18 | ``` 19 | 20 | For detailed explanation on how things work, check out the [documentation](https://nuxtjs.org). 21 | 22 | ## Special Directories 23 | 24 | You can create the following extra directories, some of which have special behaviors. Only `pages` is required; you can delete them if you don't want to use their functionality. 25 | 26 | ### `assets` 27 | 28 | The assets directory contains your uncompiled assets such as Stylus or Sass files, images, or fonts. 29 | 30 | More information about the usage of this directory in [the documentation](https://nuxtjs.org/docs/2.x/directory-structure/assets). 31 | 32 | ### `components` 33 | 34 | The components directory contains your Vue.js components. Components make up the different parts of your page and can be reused and imported into your pages, layouts and even other components. 35 | 36 | More information about the usage of this directory in [the documentation](https://nuxtjs.org/docs/2.x/directory-structure/components). 37 | 38 | ### `layouts` 39 | 40 | Layouts are a great help when you want to change the look and feel of your Nuxt app, whether you want to include a sidebar or have distinct layouts for mobile and desktop. 41 | 42 | More information about the usage of this directory in [the documentation](https://nuxtjs.org/docs/2.x/directory-structure/layouts). 43 | 44 | ### `pages` 45 | 46 | This directory contains your application views and routes. Nuxt will read all the `*.vue` files inside this directory and setup Vue Router automatically. 47 | 48 | More information about the usage of this directory in [the documentation](https://nuxtjs.org/docs/2.x/get-started/routing). 49 | 50 | ### `plugins` 51 | 52 | The plugins directory contains JavaScript plugins that you want to run before instantiating the root Vue.js Application. This is the place to add Vue plugins and to inject functions or constants. Every time you need to use `Vue.use()`, you should create a file in `plugins/` and add its path to plugins in `nuxt.config.js`. 53 | 54 | More information about the usage of this directory in [the documentation](https://nuxtjs.org/docs/2.x/directory-structure/plugins). 55 | 56 | ### `static` 57 | 58 | This directory contains your static files. Each file inside this directory is mapped to `/`. 59 | 60 | Example: `/static/robots.txt` is mapped as `/robots.txt`. 61 | 62 | More information about the usage of this directory in [the documentation](https://nuxtjs.org/docs/2.x/directory-structure/static). 63 | 64 | ### `store` 65 | 66 | This directory contains your Vuex store files. Creating a file in this directory automatically activates Vuex. 67 | 68 | More information about the usage of this directory in [the documentation](https://nuxtjs.org/docs/2.x/directory-structure/store). 69 | -------------------------------------------------------------------------------- /internal/grpcsrv/server_stack.go: -------------------------------------------------------------------------------- 1 | package grpcsrv 2 | 3 | import ( 4 | "context" 5 | "github.com/liderman/traderstack/internal/engine" 6 | stackv1 "github.com/liderman/traderstack/internal/grpcsrv/gen/go/liderman/traderstack/stack/v1" 7 | "google.golang.org/grpc/codes" 8 | "google.golang.org/grpc/status" 9 | ) 10 | 11 | type StackServer struct { 12 | sm *engine.StackManager 13 | sfr *engine.StackFuncRepository 14 | ts *engine.TestStack 15 | mapProtobuf ToProtobufMapper 16 | mapDomain ToDomainMapper 17 | } 18 | 19 | func NewStackServer(sm *engine.StackManager, sfr *engine.StackFuncRepository, ts *engine.TestStack) *StackServer { 20 | return &StackServer{ 21 | sm: sm, 22 | sfr: sfr, 23 | ts: ts, 24 | } 25 | } 26 | 27 | func (s *StackServer) Create(ctx context.Context, in *stackv1.CreateRequest) (*stackv1.CreateResponse, error) { 28 | stack := s.sm.Create(in.Name) 29 | 30 | return &stackv1.CreateResponse{ 31 | Stack: s.mapProtobuf.MapStack(stack), 32 | }, nil 33 | } 34 | 35 | func (s *StackServer) Update(ctx context.Context, in *stackv1.UpdateRequest) (*stackv1.UpdateResponse, error) { 36 | stack, err := s.sm.Update(in.Id, in.Name, s.mapDomain.MapSetItems(in.Items)) 37 | if err != nil { 38 | return nil, status.Errorf(codes.InvalidArgument, "Failed update stack: %s", err) 39 | } 40 | 41 | return &stackv1.UpdateResponse{ 42 | Stack: s.mapProtobuf.MapStack(stack), 43 | }, nil 44 | } 45 | 46 | func (s *StackServer) Delete(ctx context.Context, in *stackv1.DeleteRequest) (*stackv1.DeleteResponse, error) { 47 | s.sm.Delete(in.Id) 48 | 49 | return &stackv1.DeleteResponse{}, nil 50 | } 51 | 52 | func (s *StackServer) Get(ctx context.Context, in *stackv1.GetRequest) (*stackv1.GetResponse, error) { 53 | stack := s.sm.Get(in.Id) 54 | 55 | return &stackv1.GetResponse{ 56 | Stack: s.mapProtobuf.MapStack(stack), 57 | }, nil 58 | } 59 | 60 | func (s *StackServer) GetAll(ctx context.Context, in *stackv1.GetAllRequest) (*stackv1.GetAllResponse, error) { 61 | stacks := s.sm.GetAll() 62 | 63 | return &stackv1.GetAllResponse{ 64 | Stacks: s.mapProtobuf.MapStacks(stacks), 65 | }, nil 66 | } 67 | 68 | func (s *StackServer) Test(ctx context.Context, in *stackv1.TestRequest) (*stackv1.TestResponse, error) { 69 | resp, err := s.ts.Test(in.Id, in.Time.AsTime(), in.AccountId) 70 | if err != nil { 71 | return nil, status.Errorf(codes.InvalidArgument, "Failed test stack: %s", err) 72 | } 73 | 74 | return &stackv1.TestResponse{ 75 | Result: s.mapProtobuf.MapTestItemResults(resp), 76 | }, nil 77 | } 78 | 79 | func (s *StackServer) FuncList(ctx context.Context, in *stackv1.FuncListRequest) (*stackv1.FuncListResponse, error) { 80 | funcs := s.sfr.GetAllDeclaration() 81 | 82 | return &stackv1.FuncListResponse{ 83 | Func: s.mapProtobuf.MapFuncs(funcs), 84 | }, nil 85 | } 86 | 87 | func (s *StackServer) FuncArgumentVarList(ctx context.Context, in *stackv1.FuncArgumentVarListRequest) (*stackv1.FuncArgumentVarListResponse, error) { 88 | funcList := s.sm.FuncArgumentVarList(in.StackId, in.ItemVariable, in.ArgumentId) 89 | 90 | return &stackv1.FuncArgumentVarListResponse{ 91 | Variables: funcList, 92 | }, nil 93 | } 94 | -------------------------------------------------------------------------------- /internal/engine/stackmanager.go: -------------------------------------------------------------------------------- 1 | package engine 2 | 3 | import ( 4 | "errors" 5 | "sync" 6 | ) 7 | 8 | // StackManager Менеджер управления стеками. 9 | type StackManager struct { 10 | sfr *StackFuncRepository 11 | data map[string]*Stack 12 | mu sync.Mutex 13 | } 14 | 15 | func NewStackManager(sfr *StackFuncRepository) *StackManager { 16 | return &StackManager{ 17 | sfr: sfr, 18 | data: map[string]*Stack{}, 19 | } 20 | } 21 | 22 | func (s *StackManager) Create(name string) *Stack { 23 | stack := NewStack(name) 24 | 25 | s.mu.Lock() 26 | s.data[stack.Id] = stack 27 | s.mu.Unlock() 28 | 29 | return stack 30 | } 31 | 32 | func (s *StackManager) Update(id, name string, setItems []*SetStackItem) (*Stack, error) { 33 | s.mu.Lock() 34 | stack, ok := s.data[id] 35 | s.mu.Unlock() 36 | if !ok { 37 | return nil, errors.New("stack is not exists") 38 | } 39 | 40 | stack.Name = name 41 | items := make([]*StackItem, 0, len(setItems)) 42 | for _, v := range setItems { 43 | fnc, err := s.mapStackFunc(v.StackFunc) 44 | if err != nil { 45 | return nil, err 46 | } 47 | items = append(items, &StackItem{ 48 | Variable: v.Variable, 49 | StackFunc: fnc, 50 | }) 51 | } 52 | stack.Items = items 53 | 54 | s.mu.Lock() 55 | s.data[id] = stack 56 | s.mu.Unlock() 57 | 58 | return stack, nil 59 | } 60 | 61 | func (s *StackManager) Get(id string) *Stack { 62 | s.mu.Lock() 63 | defer s.mu.Unlock() 64 | return s.data[id] 65 | } 66 | 67 | func (s *StackManager) GetAll() []*Stack { 68 | s.mu.Lock() 69 | defer s.mu.Unlock() 70 | ret := make([]*Stack, 0, len(s.data)) 71 | for _, v := range s.data { 72 | ret = append(ret, v) 73 | } 74 | return ret 75 | } 76 | 77 | func (s *StackManager) Delete(id string) { 78 | s.mu.Lock() 79 | delete(s.data, id) 80 | s.mu.Unlock() 81 | } 82 | 83 | func (s *StackManager) FuncArgumentVarList(stackId, itemVariable, argumentId string) []string { 84 | s.mu.Lock() 85 | stack, ok := s.data[stackId] 86 | s.mu.Unlock() 87 | 88 | if !ok { 89 | return nil 90 | } 91 | 92 | idx := -1 93 | var arg *Argument 94 | for k, v := range stack.Items { 95 | if v.Variable != itemVariable || v.StackFunc == nil { 96 | continue 97 | } 98 | 99 | arg = v.StackFunc.GetArgument(argumentId) 100 | if arg != nil { 101 | idx = k 102 | break 103 | } 104 | } 105 | 106 | if arg == nil { 107 | return nil 108 | } 109 | 110 | var ret []string 111 | for k := idx - 1; k > 0; k-- { 112 | item := stack.Items[k] 113 | if item.StackFunc == nil { 114 | continue 115 | } 116 | 117 | if arg.CheckInputBaseType(item.StackFunc.BaseType) { 118 | ret = append(ret, item.Variable) 119 | } 120 | } 121 | 122 | return ret 123 | } 124 | 125 | func (s *StackManager) mapStackFunc(in *SetStackFunc) (*StackFunc, error) { 126 | if in == nil { 127 | return nil, nil 128 | } 129 | fnc := s.sfr.GetDeclaration(in.Name) 130 | if fnc == nil { 131 | return nil, errors.New("function is not exist") 132 | } 133 | 134 | for _, v := range in.Arguments { 135 | err := fnc.SetArgument(v.Id, v.Value) 136 | if err != nil { 137 | return nil, err 138 | } 139 | } 140 | 141 | return fnc, nil 142 | } 143 | -------------------------------------------------------------------------------- /internal/apiclient/sandbox.go: -------------------------------------------------------------------------------- 1 | package apiclient 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "github.com/google/uuid" 7 | "github.com/liderman/traderstack/internal/domain" 8 | "github.com/liderman/traderstack/internal/grpc/gen/tinkoff/investapi" 9 | "github.com/liderman/traderstack/internal/grpc/mapper" 10 | "github.com/shopspring/decimal" 11 | ) 12 | 13 | func (a *RealClient) GetSandboxAccounts() ([]*domain.Account, error) { 14 | resp, err := a.cSandbox.GetSandboxAccounts(context.Background(), &investapi.GetAccountsRequest{}) 15 | if err != nil { 16 | return nil, fmt.Errorf("error api GetSandboxAccounts: %w", err) 17 | } 18 | 19 | return mapper.MapFromAccounts(resp.Accounts), nil 20 | } 21 | 22 | func (a *RealClient) OpenSandboxAccount() (string, error) { 23 | resp, err := a.cSandbox.OpenSandboxAccount(context.Background(), &investapi.OpenSandboxAccountRequest{}) 24 | if err != nil { 25 | return "", fmt.Errorf("error api OpenSandboxAccount: %w", err) 26 | } 27 | 28 | return resp.AccountId, nil 29 | } 30 | 31 | func (a *RealClient) GetSandboxPositions(accountId string) (*domain.PositionsResponse, error) { 32 | resp, err := a.cSandbox.GetSandboxPositions(context.Background(), &investapi.PositionsRequest{ 33 | AccountId: accountId, 34 | }) 35 | if err != nil { 36 | return nil, fmt.Errorf("error api GetSandboxPositions: %w", err) 37 | } 38 | 39 | return mapper.MapFromPositionsResponse(resp), nil 40 | } 41 | 42 | func (a *RealClient) SandboxPayIn(accountId string, amount *domain.MoneyValue) error { 43 | _, err := a.cSandbox.SandboxPayIn(context.Background(), &investapi.SandboxPayInRequest{ 44 | AccountId: accountId, 45 | Amount: mapper.MapToMoneyValue(amount), 46 | }) 47 | if err != nil { 48 | return fmt.Errorf("error api SandboxPayIn: %w", err) 49 | } 50 | 51 | return nil 52 | } 53 | 54 | func (a *RealClient) PostSandboxOrder(figi string, lots int64, price decimal.Decimal, direction domain.OrderDirection, accountId string, orderType domain.OrderType) (*domain.PostOrderResponse, error) { 55 | resp, err := a.cSandbox.PostSandboxOrder(context.Background(), &investapi.PostOrderRequest{ 56 | Figi: figi, 57 | Quantity: lots, 58 | Price: mapper.MapToQuotation(price), 59 | Direction: mapper.MapToOrderDirection(direction), 60 | AccountId: accountId, 61 | OrderType: mapper.MapToOrderType(orderType), 62 | OrderId: uuid.New().String(), 63 | }) 64 | if err != nil { 65 | return nil, fmt.Errorf("error api PostSandboxOrder: %w", err) 66 | } 67 | 68 | return mapper.MapFromPostOrderResponse(resp), nil 69 | } 70 | 71 | func (a *RealClient) GetSandboxPortfolio(accountId string) ([]*domain.PortfolioPosition, error) { 72 | resp, err := a.cSandbox.GetSandboxPortfolio(context.Background(), &investapi.PortfolioRequest{ 73 | AccountId: accountId, 74 | }) 75 | if err != nil { 76 | return nil, fmt.Errorf("error api GetSandboxPortfolio: %w", err) 77 | } 78 | 79 | return mapper.MapFromPortfolioPositions(resp.Positions), nil 80 | } 81 | 82 | func (a *RealClient) GetSandboxOrders(accountId string) ([]*domain.OrderState, error) { 83 | resp, err := a.cSandbox.GetSandboxOrders(context.Background(), &investapi.GetOrdersRequest{ 84 | AccountId: accountId, 85 | }) 86 | if err != nil { 87 | return nil, fmt.Errorf("error api GetSandboxOrders: %w", err) 88 | } 89 | 90 | return mapper.MapFromOrderStates(resp.Orders), nil 91 | } 92 | -------------------------------------------------------------------------------- /internal/grpcsrv/server_info.go: -------------------------------------------------------------------------------- 1 | package grpcsrv 2 | 3 | import ( 4 | "context" 5 | "github.com/liderman/traderstack/internal/apiclient" 6 | "github.com/liderman/traderstack/internal/datamanager" 7 | "github.com/liderman/traderstack/internal/domain" 8 | infov1 "github.com/liderman/traderstack/internal/grpcsrv/gen/go/liderman/traderstack/info/v1" 9 | "github.com/shopspring/decimal" 10 | "google.golang.org/grpc/codes" 11 | "google.golang.org/grpc/status" 12 | ) 13 | 14 | type InfoServer struct { 15 | instr *datamanager.Instruments 16 | api apiclient.ApiClient 17 | mapProtobuf InfoToProtobufMapper 18 | } 19 | 20 | func NewInfoServer(instr *datamanager.Instruments, api apiclient.ApiClient) *InfoServer { 21 | return &InfoServer{ 22 | instr: instr, 23 | api: api, 24 | } 25 | } 26 | 27 | func (s *InfoServer) SearchInstrument(ctx context.Context, in *infov1.SearchInstrumentRequest) (*infov1.SearchInstrumentResponse, error) { 28 | shares, err := s.instr.SearchShares(in.Ticker, 10) 29 | if err != nil { 30 | return nil, status.Errorf(codes.Internal, "Failed get shares: %s", err) 31 | } 32 | 33 | return &infov1.SearchInstrumentResponse{ 34 | Instruments: s.mapProtobuf.MapInstruments(shares), 35 | }, nil 36 | } 37 | 38 | func (s *InfoServer) Accounts(ctx context.Context, in *infov1.AccountsRequest) (*infov1.AccountsResponse, error) { 39 | accounts, err := s.api.GetAccounts() 40 | if err != nil { 41 | return nil, status.Errorf(codes.Internal, "Failed get accounts: %s", err) 42 | } 43 | 44 | return &infov1.AccountsResponse{ 45 | Accounts: s.mapProtobuf.MapAccounts(accounts), 46 | }, nil 47 | } 48 | 49 | func (s *InfoServer) SandboxAccounts(ctx context.Context, in *infov1.SandboxAccountsRequest) (*infov1.SandboxAccountsResponse, error) { 50 | accounts, err := s.api.GetSandboxAccounts() 51 | if err != nil { 52 | return nil, status.Errorf(codes.Internal, "Failed get sandbox accounts: %s", err) 53 | } 54 | 55 | return &infov1.SandboxAccountsResponse{ 56 | Accounts: s.mapProtobuf.MapAccounts(accounts), 57 | }, nil 58 | } 59 | 60 | func (s *InfoServer) OpenSandboxAccount(ctx context.Context, in *infov1.OpenSandboxAccountRequest) (*infov1.OpenSandboxAccountResponse, error) { 61 | accountId, err := s.api.OpenSandboxAccount() 62 | if err != nil { 63 | return nil, status.Errorf(codes.Internal, "Failed open sandbox account: %s", err) 64 | } 65 | 66 | return &infov1.OpenSandboxAccountResponse{ 67 | AccountId: accountId, 68 | }, nil 69 | } 70 | 71 | func (s *InfoServer) GetSandboxPositions(ctx context.Context, in *infov1.GetSandboxPositionsRequest) (*infov1.GetSandboxPositionsResponse, error) { 72 | positions, err := s.api.GetSandboxPositions(in.AccountId) 73 | if err != nil { 74 | return nil, status.Errorf(codes.Internal, "Failed get sandbox positions: %s", err) 75 | } 76 | 77 | return &infov1.GetSandboxPositionsResponse{ 78 | Money: s.mapProtobuf.MapMoneys(positions.Money), 79 | }, nil 80 | } 81 | 82 | func (s *InfoServer) SandboxPayIn(ctx context.Context, in *infov1.SandboxPayInRequest) (*infov1.SandboxPayInResponse, error) { 83 | val, _ := decimal.NewFromString(in.Amount) 84 | err := s.api.SandboxPayIn(in.AccountId, &domain.MoneyValue{ 85 | Currency: "RUB", 86 | Value: val, 87 | }) 88 | if err != nil { 89 | return nil, status.Errorf(codes.Internal, "Failed pay in sandbox: %s", err) 90 | } 91 | 92 | return &infov1.SandboxPayInResponse{}, nil 93 | } 94 | -------------------------------------------------------------------------------- /internal/domain/common.go: -------------------------------------------------------------------------------- 1 | package domain 2 | 3 | type SecurityTradingStatus int32 4 | 5 | const ( 6 | // SecurityTradingStatusUnspecified Торговый статус не определён 7 | SecurityTradingStatusUnspecified SecurityTradingStatus = 0 8 | // SecurityTradingStatusNotAvailableForTrading Недоступен для торгов 9 | SecurityTradingStatusNotAvailableForTrading SecurityTradingStatus = 1 10 | // SecurityTradingStatusOpeningPeriod Период открытия торгов 11 | SecurityTradingStatusOpeningPeriod SecurityTradingStatus = 2 12 | // SecurityTradingStatusClosingPeriod Период закрытия торгов 13 | SecurityTradingStatusClosingPeriod SecurityTradingStatus = 3 14 | // SecurityTradingStatusBreakInTrading Перерыв в торговле 15 | SecurityTradingStatusBreakInTrading SecurityTradingStatus = 4 16 | // SecurityTradingStatusNormalTrading Нормальная торговля 17 | SecurityTradingStatusNormalTrading SecurityTradingStatus = 5 18 | // SecurityTradingStatusClosingAuction Аукцион закрытия 19 | SecurityTradingStatusClosingAuction SecurityTradingStatus = 6 20 | // SecurityTradingStatusDarkPoolAuction Аукцион крупных пакетов 21 | SecurityTradingStatusDarkPoolAuction SecurityTradingStatus = 7 22 | // SecurityTradingStatusDiscreteAuction Дискретный аукцион 23 | SecurityTradingStatusDiscreteAuction SecurityTradingStatus = 8 24 | // SecurityTradingStatusOpeningAuctionPeriod Аукцион открытия 25 | SecurityTradingStatusOpeningAuctionPeriod SecurityTradingStatus = 9 26 | // SecurityTradingStatusTradingAtClosingAuctionPrice Период торгов по цене аукциона закрытия 27 | SecurityTradingStatusTradingAtClosingAuctionPrice SecurityTradingStatus = 10 28 | // SecurityTradingStatusSessionAssigned Сессия назначена 29 | SecurityTradingStatusSessionAssigned SecurityTradingStatus = 11 30 | // SecurityTradingStatusSessionClose Сессия закрыта 31 | SecurityTradingStatusSessionClose SecurityTradingStatus = 12 32 | // SecurityTradingStatusSessionOpen Сессия открыта 33 | SecurityTradingStatusSessionOpen SecurityTradingStatus = 13 34 | // SecurityTradingStatusDealerNormalTrading Доступна торговля в режиме внутренней ликвидности брокера 35 | SecurityTradingStatusDealerNormalTrading SecurityTradingStatus = 14 36 | // SecurityTradingStatusDealerBreakInTrading Перерыв торговли в режиме внутренней ликвидности брокера 37 | SecurityTradingStatusDealerBreakInTrading SecurityTradingStatus = 15 38 | // SecurityTradingStatusDealerNotAvailableForTrading Недоступна торговля в режиме внутренней ликвидности брокера 39 | SecurityTradingStatusDealerNotAvailableForTrading SecurityTradingStatus = 16 40 | ) 41 | 42 | // AllowLimitOrder Возможность выставлять лимитные заявки 43 | func (s SecurityTradingStatus) AllowLimitOrder() bool { 44 | switch s { 45 | case SecurityTradingStatusOpeningPeriod, SecurityTradingStatusClosingPeriod, SecurityTradingStatusNormalTrading, 46 | SecurityTradingStatusClosingAuction, SecurityTradingStatusDiscreteAuction, 47 | SecurityTradingStatusOpeningAuctionPeriod, SecurityTradingStatusTradingAtClosingAuctionPrice, 48 | SecurityTradingStatusSessionAssigned, SecurityTradingStatusSessionClose, SecurityTradingStatusSessionOpen: 49 | return true 50 | } 51 | return false 52 | } 53 | 54 | // AllowMarketOrder Возможность выставлять рыночные заявки 55 | func (s SecurityTradingStatus) AllowMarketOrder() bool { 56 | switch s { 57 | case SecurityTradingStatusNormalTrading, SecurityTradingStatusDarkPoolAuction, 58 | SecurityTradingStatusDealerNormalTrading: 59 | return true 60 | } 61 | return false 62 | } 63 | -------------------------------------------------------------------------------- /web/pages/strategies.vue: -------------------------------------------------------------------------------- 1 | 49 | 50 | 113 | -------------------------------------------------------------------------------- /internal/datamanager/marketdata.go: -------------------------------------------------------------------------------- 1 | package datamanager 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "github.com/liderman/traderstack/internal/apiclient" 7 | "github.com/liderman/traderstack/internal/domain" 8 | "github.com/patrickmn/go-cache" 9 | "sort" 10 | "time" 11 | ) 12 | 13 | type MarketData struct { 14 | client apiclient.ApiClient 15 | cache *cache.Cache 16 | } 17 | 18 | func NewMarketData(client apiclient.ApiClient, cache *cache.Cache) *MarketData { 19 | return &MarketData{ 20 | client: client, 21 | cache: cache, 22 | } 23 | } 24 | 25 | func (m *MarketData) GetLastPrices() ([]*domain.LastPrice, error) { 26 | if v, ok := m.cache.Get("lastPrices"); ok { 27 | return v.([]*domain.LastPrice), nil 28 | } 29 | 30 | resp, err := m.client.GetLastPrices() 31 | if err != nil { 32 | return nil, fmt.Errorf("error api GetLastPrices: %w", err) 33 | } 34 | 35 | m.cache.Set("lastPrices", resp, time.Second*2) 36 | 37 | return resp, nil 38 | } 39 | 40 | func (m *MarketData) GetLastPrice(figi string) (*domain.LastPrice, error) { 41 | prices, err := m.GetLastPrices() 42 | if err != nil { 43 | return nil, err 44 | } 45 | 46 | for _, v := range prices { 47 | if v.Figi == figi { 48 | return v, nil 49 | } 50 | } 51 | 52 | return nil, errors.New("price not found") 53 | } 54 | 55 | func (m *MarketData) GetCandles(figi string, from time.Time, to time.Time, interval domain.CandleInterval) ([]*domain.HistoricCandle, error) { 56 | cKey, cTime := m.getCandlesCacheParams(figi, from, to, interval) 57 | if v, ok := m.cache.Get(cKey); ok { 58 | return v.([]*domain.HistoricCandle), nil 59 | } 60 | 61 | resp, err := m.client.GetCandles(figi, from, to, interval) 62 | if err != nil { 63 | return nil, fmt.Errorf("error api GetCandles: %w", err) 64 | } 65 | 66 | sort.Slice(resp, func(i, j int) bool { 67 | return resp[i].Time.After(resp[j].Time) 68 | }) 69 | 70 | m.cache.Set(cKey, resp, cTime) 71 | 72 | return resp, nil 73 | } 74 | 75 | func (m *MarketData) GetLastCandles(figi string, interval domain.CandleInterval, period int, now time.Time) ([]*domain.HistoricCandle, error) { 76 | limit := apiclient.LimitGetHistoricCandlesDuration(interval) 77 | to := now 78 | from := to.Add(-limit) 79 | 80 | var candles []*domain.HistoricCandle 81 | var data []*domain.HistoricCandle 82 | var err error 83 | for len(candles) < period { 84 | data, err = m.GetCandles(figi, from, to, interval) 85 | if err != nil { 86 | break 87 | } 88 | 89 | candles = append(candles, data...) 90 | to = to.Add(-limit) 91 | from = from.Add(-limit) 92 | } 93 | 94 | if err != nil { 95 | return nil, err 96 | } 97 | 98 | return candles[:period], nil 99 | } 100 | 101 | func (m *MarketData) getCandlesCacheParams(figi string, from time.Time, to time.Time, interval domain.CandleInterval) (string, time.Duration) { 102 | truncDuration := time.Second 103 | switch interval { 104 | case domain.CandleInterval1Min: 105 | truncDuration = time.Second * 5 106 | case domain.CandleInterval5Min: 107 | truncDuration = time.Second * 15 108 | case domain.CandleInterval15Min: 109 | truncDuration = time.Minute 110 | case domain.CandleInterval1Hour: 111 | truncDuration = time.Minute * 5 112 | case domain.CandleInterval1Day: 113 | truncDuration = time.Minute * 15 114 | } 115 | 116 | key := fmt.Sprintf( 117 | "%d.%s.%s.%s", 118 | interval, 119 | figi, 120 | from.Truncate(truncDuration), 121 | to.Truncate(truncDuration), 122 | ) 123 | return key, truncDuration 124 | } 125 | -------------------------------------------------------------------------------- /web/components/StackLine.vue: -------------------------------------------------------------------------------- 1 | 45 | 46 | 94 | 95 | 143 | -------------------------------------------------------------------------------- /internal/domain/order.go: -------------------------------------------------------------------------------- 1 | package domain 2 | 3 | import ( 4 | "github.com/shopspring/decimal" 5 | "time" 6 | ) 7 | 8 | type OrderDirection int32 9 | 10 | const ( 11 | OrderDirectionUnspecified OrderDirection = 0 // Значение не указано 12 | OrderDirectionBuy OrderDirection = 1 // Покупка 13 | OrderDirectionSell OrderDirection = 2 // Продажа 14 | ) 15 | 16 | type OrderType int32 17 | 18 | const ( 19 | OrderTypeUnspecified OrderType = 0 // Значение не указано 20 | OrderTypeLimit OrderType = 1 // Лимитная 21 | OrderTypeMarket OrderType = 2 // Рыночная 22 | ) 23 | 24 | // OrderExecutionReportStatus Текущий статус заявки (поручения) 25 | type OrderExecutionReportStatus int32 26 | 27 | const ( 28 | OrderExecutionReportStatusUnspecified OrderExecutionReportStatus = 0 29 | OrderExecutionReportStatusFill OrderExecutionReportStatus = 1 // Исполнена 30 | OrderExecutionReportStatusRejected OrderExecutionReportStatus = 2 // Отклонена 31 | OrderExecutionReportStatusCancelled OrderExecutionReportStatus = 3 // Отменена пользователем 32 | OrderExecutionReportStatusNew OrderExecutionReportStatus = 4 // Новая 33 | OrderExecutionReportStatusPartiallyfill OrderExecutionReportStatus = 5 // Частично исполнена 34 | ) 35 | 36 | func (o OrderExecutionReportStatus) InProcess() bool { 37 | return o == OrderExecutionReportStatusNew || o == OrderExecutionReportStatusPartiallyfill 38 | } 39 | 40 | type MoneyValue struct { 41 | Currency string 42 | Value decimal.Decimal 43 | } 44 | 45 | type PostOrderResponse struct { 46 | OrderId string 47 | ExecutionReportStatus OrderExecutionReportStatus 48 | LotsRequested int64 49 | LotsExecuted int64 50 | InitialOrderPrice *MoneyValue 51 | ExecutedOrderPrice *MoneyValue 52 | TotalOrderAmount *MoneyValue 53 | InitialCommission *MoneyValue 54 | ExecutedCommission *MoneyValue 55 | AciValue *MoneyValue 56 | Figi string 57 | Direction OrderDirection 58 | InitialSecurityPrice *MoneyValue 59 | OrderType OrderType 60 | Message string 61 | InitialOrderPricePt decimal.Decimal 62 | } 63 | 64 | // OrderState Информация о торговом поручении. 65 | type OrderState struct { 66 | OrderId string 67 | ExecutionReportStatus OrderExecutionReportStatus // Текущий статус заявки. 68 | LotsRequested int64 69 | LotsExecuted int64 70 | InitialOrderPrice *MoneyValue // Начальная цена заявки. Произведение количества запрошенных лотов на цену. 71 | ExecutedOrderPrice *MoneyValue // Исполненная цена заявки. Произведение средней цены покупки на количество лотов. 72 | TotalOrderAmount *MoneyValue // Итоговая стоимость заявки, включающая все комиссии. 73 | AveragePositionPrice *MoneyValue // Средняя цена позиции по сделке. 74 | InitialCommission *MoneyValue // Начальная комиссия. Комиссия, рассчитанная на момент подачи заявки. 75 | ExecutedCommission *MoneyValue // Фактическая комиссия по итогам исполнения заявки. 76 | Figi string 77 | Direction OrderDirection 78 | InitialSecurityPrice *MoneyValue // Начальная цена за 1 инструмент. Для получения стоимости лота требуется умножить на лотность инструмента. 79 | Stages []*OrderStage // Стадии выполнения заявки. 80 | ServiceCommission *MoneyValue // Сервисная комиссия. 81 | Currency string // Валюта заявки. 82 | OrderType OrderType // Тип заявки. 83 | OrderDate time.Time // Дата и время выставления заявки в часовом поясе UTC. 84 | } 85 | 86 | // OrderStage Сделки в рамках торгового поручения. 87 | type OrderStage struct { 88 | Price *MoneyValue // Цена за 1 инструмент. Для получения стоимости лота требуется умножить на лотность инструмента.. 89 | Quantity int64 // Количество лотов. 90 | TradeId string // Идентификатор торговой операции. 91 | } 92 | -------------------------------------------------------------------------------- /internal/stackfuncs/actionbuymarket.go: -------------------------------------------------------------------------------- 1 | package stackfuncs 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "github.com/liderman/traderstack/internal/apiclient" 7 | "github.com/liderman/traderstack/internal/datamanager" 8 | "github.com/liderman/traderstack/internal/domain" 9 | "github.com/liderman/traderstack/internal/engine" 10 | "time" 11 | ) 12 | 13 | type ActionBuyMarket struct { 14 | api apiclient.ApiClient 15 | md *datamanager.MarketData 16 | inst *datamanager.Instruments 17 | } 18 | 19 | func NewActionBuyMarket(api apiclient.ApiClient, md *datamanager.MarketData, inst *datamanager.Instruments) *ActionBuyMarket { 20 | return &ActionBuyMarket{ 21 | api: api, 22 | md: md, 23 | inst: inst, 24 | } 25 | } 26 | 27 | func (a *ActionBuyMarket) Name() string { 28 | return "ActionBuyMarket" 29 | } 30 | 31 | func (a *ActionBuyMarket) BaseType() string { 32 | return engine.BaseTypeBoolean 33 | } 34 | 35 | func (a *ActionBuyMarket) Run(options *engine.Options, now time.Time, accountId string, isTest bool) (interface{}, error) { 36 | condition, err := options.GetBoolean("condition") 37 | if err != nil { 38 | return nil, err 39 | } 40 | 41 | if !condition { 42 | return false, nil 43 | } 44 | figi, err := options.GetString("figi") 45 | if err != nil { 46 | return nil, err 47 | } 48 | lots, err := options.GetInteger("lots") 49 | if err != nil { 50 | return nil, err 51 | } 52 | 53 | instrument, err := a.inst.GetShareByFigi(figi) 54 | if err != nil { 55 | return nil, err 56 | } 57 | 58 | err = a.checkWorkingExchange(instrument, now, isTest) 59 | if err != nil { 60 | return nil, err 61 | } 62 | 63 | lastPrice, err := a.md.GetLastPrice(figi) 64 | if err != nil { 65 | return nil, fmt.Errorf("ошибка получения последней цены: %w", err) 66 | } 67 | 68 | fnc := a.api.PostOrder 69 | if isTest { 70 | fnc = a.api.PostSandboxOrder 71 | } 72 | resp, err := fnc(figi, lots, lastPrice.Price, domain.OrderDirectionBuy, accountId, domain.OrderTypeMarket) 73 | if err != nil { 74 | return nil, fmt.Errorf("ошибка создания заказа: %w", err) 75 | } 76 | 77 | fmt.Printf("!!! Buy %s with %d lots and price %s\n", resp.Figi, resp.LotsRequested, lastPrice.Price.String()) 78 | return true, nil 79 | } 80 | 81 | func (a *ActionBuyMarket) checkWorkingExchange(instrument *domain.Share, now time.Time, isTest bool) error { 82 | if isTest { 83 | to := now.Truncate(time.Minute).Add(time.Minute) 84 | from := now.Add(-time.Minute * 2) 85 | candles, err := a.md.GetCandles(instrument.Figi, from, to, domain.CandleInterval1Min) 86 | if err != nil { 87 | return err 88 | } 89 | 90 | if len(candles) > 0 { 91 | instrument.TradingStatus = domain.SecurityTradingStatusNormalTrading 92 | } 93 | } 94 | 95 | if !instrument.ApiTradeAvailableFlag { 96 | return errors.New("инструмент не доступен для торговли через API") 97 | } 98 | 99 | if !instrument.BuyAvailableFlag { 100 | return errors.New("инструмент не доступен для покупки") 101 | } 102 | 103 | if !instrument.TradingStatus.AllowMarketOrder() { 104 | return errors.New("биржа закрыта для рыночных заявок") 105 | } 106 | 107 | return nil 108 | } 109 | 110 | func (a *ActionBuyMarket) Arguments() []*engine.Argument { 111 | return []*engine.Argument{ 112 | { 113 | Id: "condition", 114 | Name: "Условие", 115 | Desc: "", 116 | BaseType: "boolean", 117 | ExtendedType: "", 118 | Required: true, 119 | Value: false, 120 | }, 121 | { 122 | Id: "figi", 123 | Name: "Figi", 124 | Desc: "Например, TCS", 125 | BaseType: "string", 126 | ExtendedType: "figi-select", 127 | Required: true, 128 | Value: "", 129 | }, 130 | { 131 | Id: "lots", 132 | Name: "Лотов", 133 | Desc: "Например, 1", 134 | BaseType: "integer", 135 | ExtendedType: "", 136 | Required: true, 137 | Value: 1, 138 | }, 139 | } 140 | } 141 | -------------------------------------------------------------------------------- /internal/stackfuncs/actionsellmarket.go: -------------------------------------------------------------------------------- 1 | package stackfuncs 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "github.com/liderman/traderstack/internal/apiclient" 7 | "github.com/liderman/traderstack/internal/datamanager" 8 | "github.com/liderman/traderstack/internal/domain" 9 | "github.com/liderman/traderstack/internal/engine" 10 | "time" 11 | ) 12 | 13 | type ActionSellMarket struct { 14 | api apiclient.ApiClient 15 | md *datamanager.MarketData 16 | inst *datamanager.Instruments 17 | } 18 | 19 | func NewActionSellMarket( 20 | api apiclient.ApiClient, 21 | md *datamanager.MarketData, 22 | inst *datamanager.Instruments, 23 | ) *ActionSellMarket { 24 | return &ActionSellMarket{ 25 | api: api, 26 | md: md, 27 | inst: inst, 28 | } 29 | } 30 | 31 | func (a *ActionSellMarket) Name() string { 32 | return "ActionSellMarket" 33 | } 34 | 35 | func (a *ActionSellMarket) BaseType() string { 36 | return engine.BaseTypeBoolean 37 | } 38 | 39 | func (a *ActionSellMarket) Run(options *engine.Options, now time.Time, accountId string, isTest bool) (interface{}, error) { 40 | condition, err := options.GetBoolean("condition") 41 | if err != nil { 42 | return nil, err 43 | } 44 | 45 | if !condition { 46 | return false, nil 47 | } 48 | figi, err := options.GetString("figi") 49 | if err != nil { 50 | return nil, err 51 | } 52 | lots, err := options.GetInteger("lots") 53 | if err != nil { 54 | return nil, err 55 | } 56 | 57 | instrument, err := a.inst.GetShareByFigi(figi) 58 | if err != nil { 59 | return nil, err 60 | } 61 | 62 | err = a.checkWorkingExchange(instrument, now, isTest) 63 | if err != nil { 64 | return nil, err 65 | } 66 | 67 | lastPrice, err := a.md.GetLastPrice(figi) 68 | if err != nil { 69 | return nil, fmt.Errorf("ошибка получения последней цены: %w", err) 70 | } 71 | 72 | fnc := a.api.PostOrder 73 | if isTest { 74 | fnc = a.api.PostSandboxOrder 75 | } 76 | resp, err := fnc(figi, lots, lastPrice.Price, domain.OrderDirectionSell, accountId, domain.OrderTypeMarket) 77 | if err != nil { 78 | return nil, fmt.Errorf("ошибка создания заказа: %w", err) 79 | } 80 | 81 | fmt.Printf("!!! Sell %s with %d lots and price %s\n", resp.Figi, resp.LotsRequested, lastPrice.Price.String()) 82 | return true, nil 83 | } 84 | 85 | func (a *ActionSellMarket) checkWorkingExchange(instrument *domain.Share, now time.Time, isTest bool) error { 86 | if isTest { 87 | to := now.Truncate(time.Minute).Add(time.Minute) 88 | from := now.Add(-time.Minute * 2) 89 | candles, err := a.md.GetCandles(instrument.Figi, from, to, domain.CandleInterval1Min) 90 | if err != nil { 91 | return err 92 | } 93 | 94 | if len(candles) > 0 { 95 | instrument.TradingStatus = domain.SecurityTradingStatusNormalTrading 96 | } 97 | } 98 | 99 | if !instrument.ApiTradeAvailableFlag { 100 | return errors.New("инструмент не доступен для торговли через API") 101 | } 102 | 103 | if !instrument.SellAvailableFlag { 104 | return errors.New("инструмент не доступен для продажи") 105 | } 106 | 107 | if !instrument.TradingStatus.AllowMarketOrder() { 108 | return errors.New("биржа закрыта для рыночных заявок") 109 | } 110 | 111 | return nil 112 | } 113 | 114 | func (a *ActionSellMarket) Arguments() []*engine.Argument { 115 | return []*engine.Argument{ 116 | { 117 | Id: "condition", 118 | Name: "Условие", 119 | Desc: "", 120 | BaseType: "boolean", 121 | ExtendedType: "", 122 | Required: true, 123 | Value: false, 124 | }, 125 | { 126 | Id: "figi", 127 | Name: "Figi", 128 | Desc: "Например, TCS", 129 | BaseType: "string", 130 | ExtendedType: "figi-select", 131 | Required: true, 132 | Value: "", 133 | }, 134 | { 135 | Id: "lots", 136 | Name: "Лотов", 137 | Desc: "Например, 1", 138 | BaseType: "integer", 139 | ExtendedType: "", 140 | Required: true, 141 | Value: 1, 142 | }, 143 | } 144 | } 145 | -------------------------------------------------------------------------------- /internal/grpcsrv/to_protobuf_mapper.go: -------------------------------------------------------------------------------- 1 | package grpcsrv 2 | 3 | import ( 4 | "github.com/liderman/traderstack/internal/engine" 5 | "github.com/liderman/traderstack/internal/engine/baseoption" 6 | stackv1 "github.com/liderman/traderstack/internal/grpcsrv/gen/go/liderman/traderstack/stack/v1" 7 | "github.com/shopspring/decimal" 8 | "google.golang.org/protobuf/types/known/timestamppb" 9 | "time" 10 | ) 11 | 12 | type ToProtobufMapper struct { 13 | } 14 | 15 | func (t *ToProtobufMapper) MapStacks(in []*engine.Stack) []*stackv1.Stack { 16 | ret := make([]*stackv1.Stack, 0, len(in)) 17 | for _, v := range in { 18 | ret = append(ret, t.MapStack(v)) 19 | } 20 | 21 | return ret 22 | } 23 | 24 | func (t *ToProtobufMapper) MapStack(in *engine.Stack) *stackv1.Stack { 25 | if in == nil { 26 | return nil 27 | } 28 | ret := &stackv1.Stack{ 29 | Id: in.Id, 30 | Name: in.Name, 31 | Items: []*stackv1.Item{}, 32 | } 33 | 34 | for _, v := range in.Items { 35 | ret.Items = append(ret.Items, t.MapItem(v)) 36 | } 37 | 38 | return ret 39 | } 40 | 41 | func (t *ToProtobufMapper) MapItem(in *engine.StackItem) *stackv1.Item { 42 | return &stackv1.Item{ 43 | Variable: in.Variable, 44 | StackFunc: t.MapStackFunc(in.StackFunc), 45 | } 46 | } 47 | 48 | func (t *ToProtobufMapper) MapStackFunc(in *engine.StackFunc) *stackv1.StackFunc { 49 | if in == nil { 50 | return nil 51 | } 52 | return &stackv1.StackFunc{ 53 | Name: in.Name, 54 | Arguments: t.MapArguments(in.Arguments), 55 | BaseType: in.BaseType, 56 | } 57 | } 58 | 59 | func (t *ToProtobufMapper) MapArguments(in []*engine.Argument) []*stackv1.Argument { 60 | ret := make([]*stackv1.Argument, 0, len(in)) 61 | for _, v := range in { 62 | ret = append(ret, t.MapArgument(v)) 63 | } 64 | return ret 65 | } 66 | 67 | func (t *ToProtobufMapper) MapArgument(in *engine.Argument) *stackv1.Argument { 68 | if in == nil { 69 | return nil 70 | } 71 | 72 | ret := &stackv1.Argument{ 73 | Id: in.Id, 74 | Name: in.Name, 75 | Desc: in.Desc, 76 | BaseType: in.BaseType, 77 | ExtendedType: in.ExtendedType, 78 | Required: in.Required, 79 | } 80 | 81 | switch v := in.Value.(type) { 82 | case *baseoption.Variable: 83 | ret.Value = &stackv1.Argument_Variable{ 84 | Variable: t.MapVariable(v), 85 | } 86 | default: 87 | ret.Value = &stackv1.Argument_Input{ 88 | Input: t.MapValue(v), 89 | } 90 | } 91 | 92 | return ret 93 | } 94 | 95 | func (t *ToProtobufMapper) MapValue(in interface{}) *stackv1.Value { 96 | if in == nil { 97 | return nil 98 | } 99 | 100 | ret := &stackv1.Value{} 101 | 102 | switch v := in.(type) { 103 | case int64: 104 | ret.Val = &stackv1.Value_Integer{ 105 | Integer: v, 106 | } 107 | case string: 108 | ret.Val = &stackv1.Value_String_{ 109 | String_: v, 110 | } 111 | case bool: 112 | ret.Val = &stackv1.Value_Boolean{ 113 | Boolean: v, 114 | } 115 | case decimal.Decimal: 116 | ret.Val = &stackv1.Value_Decimal{ 117 | Decimal: t.MapDecimal(v), 118 | } 119 | case time.Time: 120 | ret.Val = &stackv1.Value_Time{ 121 | Time: timestamppb.New(v), 122 | } 123 | default: 124 | return nil 125 | } 126 | 127 | return ret 128 | } 129 | 130 | func (t *ToProtobufMapper) MapDecimal(in decimal.Decimal) string { 131 | return in.String() 132 | } 133 | 134 | func (t *ToProtobufMapper) MapVariable(in *baseoption.Variable) *stackv1.Variable { 135 | return &stackv1.Variable{ 136 | Name: in.Name, 137 | } 138 | } 139 | 140 | func (t *ToProtobufMapper) MapFuncs(in []*engine.StackFunc) []*stackv1.StackFunc { 141 | ret := make([]*stackv1.StackFunc, 0, len(in)) 142 | 143 | for _, v := range in { 144 | ret = append(ret, t.MapStackFunc(v)) 145 | } 146 | 147 | return ret 148 | } 149 | 150 | func (t *ToProtobufMapper) MapTestItemResults(in []*engine.TestItemResult) []*stackv1.TestItemResult { 151 | ret := make([]*stackv1.TestItemResult, 0, len(in)) 152 | 153 | for _, v := range in { 154 | ret = append(ret, t.MapTestItemResult(v)) 155 | } 156 | 157 | return ret 158 | } 159 | 160 | func (t *ToProtobufMapper) MapTestItemResult(in *engine.TestItemResult) *stackv1.TestItemResult { 161 | return &stackv1.TestItemResult{ 162 | Variable: in.Variable, 163 | Result: t.MapValue(in.Result), 164 | BaseType: in.BaseType, 165 | Error: in.Error, 166 | } 167 | } 168 | -------------------------------------------------------------------------------- /internal/stackfuncs/actiontakeprofit.go: -------------------------------------------------------------------------------- 1 | package stackfuncs 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "github.com/liderman/traderstack/internal/apiclient" 7 | "github.com/liderman/traderstack/internal/datamanager" 8 | "github.com/liderman/traderstack/internal/domain" 9 | "github.com/liderman/traderstack/internal/engine" 10 | "time" 11 | ) 12 | 13 | type ActionTakeProfit struct { 14 | api apiclient.ApiClient 15 | md *datamanager.MarketData 16 | inst *datamanager.Instruments 17 | } 18 | 19 | func NewActionTakeProfit( 20 | api apiclient.ApiClient, 21 | md *datamanager.MarketData, 22 | inst *datamanager.Instruments, 23 | ) *ActionTakeProfit { 24 | return &ActionTakeProfit{ 25 | api: api, 26 | md: md, 27 | inst: inst, 28 | } 29 | } 30 | 31 | func (a *ActionTakeProfit) Name() string { 32 | return "ActionTakeProfit" 33 | } 34 | 35 | func (a *ActionTakeProfit) BaseType() string { 36 | return engine.BaseTypeBoolean 37 | } 38 | 39 | func (a *ActionTakeProfit) Run(options *engine.Options, now time.Time, accountId string, isTest bool) (interface{}, error) { 40 | figi, err := options.GetString("figi") 41 | if err != nil { 42 | return nil, err 43 | } 44 | percent, err := options.GetDecimal("percent") 45 | if err != nil { 46 | return nil, err 47 | } 48 | 49 | instrument, err := a.inst.GetShareByFigi(figi) 50 | if err != nil { 51 | return nil, err 52 | } 53 | 54 | err = a.checkWorkingExchange(instrument, now, isTest) 55 | if err != nil { 56 | return nil, err 57 | } 58 | 59 | inPortfolio, err := a.getPortfolioLots(instrument, accountId, isTest) 60 | if err != nil { 61 | return nil, err 62 | } 63 | 64 | if inPortfolio == nil { 65 | return false, nil 66 | } 67 | 68 | if !inPortfolio.ExpectedYield.GreaterThanOrEqual(percent) { 69 | return false, nil 70 | } 71 | 72 | fnc := a.api.PostOrder 73 | if isTest { 74 | fnc = a.api.PostSandboxOrder 75 | } 76 | resp, err := fnc( 77 | figi, 78 | inPortfolio.QuantityLots.IntPart(), 79 | inPortfolio.CurrentPrice.Value, 80 | domain.OrderDirectionSell, 81 | accountId, 82 | domain.OrderTypeMarket, 83 | ) 84 | if err != nil { 85 | return nil, fmt.Errorf("ошибка создания заказа: %w", err) 86 | } 87 | 88 | fmt.Printf("!!! TakeProfit %s with %d lots and price %s\n", resp.Figi, resp.LotsRequested, inPortfolio.CurrentPrice.Value.String()) 89 | return true, nil 90 | } 91 | 92 | func (a *ActionTakeProfit) checkWorkingExchange(instrument *domain.Share, now time.Time, isTest bool) error { 93 | if isTest { 94 | to := now.Truncate(time.Minute).Add(time.Minute) 95 | from := now.Add(-time.Minute * 2) 96 | candles, err := a.md.GetCandles(instrument.Figi, from, to, domain.CandleInterval1Min) 97 | if err != nil { 98 | return err 99 | } 100 | 101 | if len(candles) > 0 { 102 | instrument.TradingStatus = domain.SecurityTradingStatusNormalTrading 103 | } 104 | } 105 | 106 | if !instrument.ApiTradeAvailableFlag { 107 | return errors.New("инструмент не доступен для торговли через API") 108 | } 109 | 110 | if !instrument.SellAvailableFlag { 111 | return errors.New("инструмент не доступен для продажи") 112 | } 113 | 114 | if !instrument.TradingStatus.AllowMarketOrder() { 115 | return errors.New("биржа закрыта для рыночных заявок") 116 | } 117 | 118 | return nil 119 | } 120 | 121 | func (a *ActionTakeProfit) getPortfolioLots(instrument *domain.Share, accountId string, isTest bool) (*domain.PortfolioPosition, error) { 122 | fnc := a.api.GetPortfolio 123 | if isTest { 124 | fnc = a.api.GetSandboxPortfolio 125 | } 126 | portfolio, err := fnc(accountId) 127 | if err != nil { 128 | return nil, fmt.Errorf("ошибка получения портфеля: %w", err) 129 | } 130 | 131 | for _, v := range portfolio { 132 | if v.Figi == instrument.Figi { 133 | return v, nil 134 | } 135 | } 136 | 137 | return nil, nil 138 | } 139 | 140 | func (a *ActionTakeProfit) Arguments() []*engine.Argument { 141 | return []*engine.Argument{ 142 | { 143 | Id: "figi", 144 | Name: "Figi", 145 | Desc: "Например, TCS", 146 | BaseType: "string", 147 | ExtendedType: "figi-select", 148 | Required: true, 149 | Value: "", 150 | }, 151 | { 152 | Id: "percent", 153 | Name: "% роста (decimal)", 154 | Desc: "Например, 15", 155 | BaseType: "decimal", 156 | ExtendedType: "", 157 | Required: true, 158 | Value: 15, 159 | }, 160 | } 161 | } 162 | -------------------------------------------------------------------------------- /web/pages/sandbox.vue: -------------------------------------------------------------------------------- 1 | 77 | 78 | 147 | --------------------------------------------------------------------------------