[pshaiBot] Oscillator Scalper (SPOT)

stable
By pshai in Trading Bots Published November 2021 👁 2,246 views 💬 3 comments

Description

Oscillator Scalper for SPOT markets A scalper with a bunch of customization; one out of 3 implemented oscillator and method for how you would like trades to trigger. The scalper is a multi-position bot and will monitor all positions individually! For gains and minimizing losses, this scalper utilizes fixed TP, fixed SL and TASL to secure some of the achieved profits. This scalper can also be set to monitor a so-called "Master Market", for example BTC/USD(T). This master market is monitored for any possible dumps within set minutes. You are able to define the size and lookback of the dump. For example, if BTC/USD(T) drops 2% within 5 minutes; dump all open positions. Required CC's: [pshaiCmd] Input Converted Trade Amount [pshaiCmd] Trailing Arm Stop-Loss (TASL)
HaasScript
-- [pshaiBot] Oscillator Scalper
-- Author: pshai


EnableHighSpeedUpdates(true)
HideOrderSettings()
HideTradeAmountSettings()

local oscillators = {
    rsi = 'RSI',
    mfi = 'MFI',
    cci = 'CCI'
}

local signalTypes = {
    crossOver = 'Cross-over Level',
    crossUnder = 'Cross-under Level',
    isAbove = 'Is Above Level',
    isBelow = 'Is Below Level',
}

local tradingDirections = {
    long = 'Buy/Long',
    short = 'Sell/Short'
}

InputGroupHeader('User Controls')
local forceExit = Input('Force ALL positions to close', false, 'If true, bot will force all positions to exit. This also prevents bot from opening new positions.')
local stopNoPos = Input('Deactivate on NoPosition', false, 'If true, bot will deactivate itself when it has no more open positions. This also prevents bot from opening new positions.')

local tradeAmount = CC_InputConvertedTradeAmount('Per Position')

InputGroupHeader('Oscillator Settings')
local oscillator = InputOptions('1. Oscillator', oscillators.rsi, oscillators)
local osc_len = Input('2. Oscillator Length', 14)
local osc_buy = Input('3. Buy Level', 30)
local osc_sell = Input('4. Sell Level', 70)

InputGroupHeader('Signal Settings')
local buySignalType = InputOptions('1. Buy Signal Type (Buy Level)', signalTypes.isBelow, signalTypes)
local sellSignalType = InputOptions('2. Sell Signal Type (Sell Level)', signalTypes.isAbove, signalTypes)

InputGroupHeader('Trading Settings')
local tradingDirection = InputOptions('1. Trading Direction', tradingDirections.long, tradingDirections)
local maxPositions = Input('2. Max Positions', 3)
local orderCooldown = Input('3. Order Cooldown (minutes)', 60)
local orderTimeout = Input('4. Order Timeout (seconds)', 360)
local entryOrderType = InputOrderType('5. Entry Order Type', MarketOrderType)

InputGroupHeader('Safety Settings')
local takeProfit = Input('1. Take-Profit %', 1)
local stopLoss = Input('2. Stop-Loss %', 1)
local armLength = Input('3. Trailing Activation %', 0.5)
local trailDist = Input('4. Trailing Distance %', 1)
local stopCooldown = Input('5. Stop Cooldown (minutes)', 60, 'If position is closed by Stop-Loss, trading is then put on cooldown.')
local exitOrderType = InputOrderType('6. Exit Order Type', MarketOrderType)
local masterMarket = InputPriceSourceMarket('7. Master', 'BINANCE_BTC_USDT_')
local masterDumpSize = Input('8. Master Market Dump %', 5)
local masterDumpLb = Input('9. Master Market Lookback', 2, 'Lookback value in minutes')
local masterCooldown = Input('10. Master Market Cooldown (minutes)', 60, 'If positions are closed by master market dumpage, trading is then put on cooldown.')

-- ===============================================================
-- Handy functions

    -- deep clone an object
    function clone(original)
        local copy = {}
        for k, v in pairs(original) do
            if GetType(v) == ArrayDataType then
                v = clone(v)
            end
            copy[k] = v
        end
        return copy
    end

    
    -- check and initialize a module
    function IsModuleInitialized(module)
        if module._init then
            LogError('Module "' .. module.name .. '" already initialized!')
            return true
        end

        module._init = true

        return false
    end


    function arrayAdd(array, value)
        if GetHaasScriptVersion() == 1 then
            array[#array +1] = value
            return array
        else
            return ArrayAdd(array, value)
        end
    end

    function arrayUnshift(array, value)
        if GetHaasScriptVersion() == 1 then
            local newArr = {}
            for i = 1, #array do
                newArr[i +1] = array[i]
            end
            newArr[1] = value
            return newArr
        else
            return ArrayUnshift(array, value)
        end
    end

    function arrayRemove(array, index)
        if GetHaasScriptVersion() == 1 then
            local newArr = {}
            for i = 1, #array do
                if i < index then
                    newArr[i] = array[i]
                elseif i > index then
                    newArr[i-1] = array[i]
                end
            end
            return newArr
        else
            return ArrayRemove(array, index)
        end
    end

-- ===============================================================
-- Modules 

    -- wrapper for script settings
    local Config = {}

    -- position object
    local Position = {}

    -- position manager
    local PosMan = {}

    -- master dumpage
    local MasterDumpage = {}


    -- ---------------------------------------------------------------
    -- Config

        function Config:init()
            if IsModuleInitialized(self) then
                return
            end

            -- thought i'd need this, but turns out i dont.
        end     


    

    --

        function MasterDumpage:init()
            if IsModuleInitialized(self) then
                return
            end

            self.PriceList = Load('md:pl', {})
            self.Timestamps = Load('md:ts', {})
            self.DumpAll = false
            self.Direction = Sign(masterDumpSize)
        end

        function MasterDumpage:save()
            Save('md:pl', self.PriceList)
            Save('md:ts', self.Timestamps)
        end

        function MasterDumpage:getDumpAll()
            return self.DumpAll, self.Direction
        end

        function MasterDumpage:update()
            local mkt = masterMarket
            local cp = CurrentPrice(mkt)
            local remove = 0

            self.Timestamps = arrayUnshift(self.Timestamps, Time())
            self.PriceList = arrayUnshift(self.PriceList, cp.close)

            -- remove old values by timestamp
            for i = 1, #self.Timestamps do
                local ts = self.Timestamps[i]

                if ts < Time() - masterDumpLb * 60 then
                    remove = i
                    break
                end
            end

            if remove > 0 then
                self.PriceList = Grab(self.PriceList, 0, remove-1) --arrayRemove(self.PriceList, remove[i] -i)
                self.Timestamps = Grab(self.Timestamps, 0, remove-1) --arrayRemove(self.Timestamps, remove[i] -i)
            end


            -- we want to anyway update prices even though the system is not in use...
            -- so we return here, after we recorded the prices.
            if masterDumpSize <= 0 then
                return
            end

            --local sign = self.Direction
            local oldPrice = self.PriceList[ #self.PriceList ]
            local curPrice = self.PriceList[ 1 ]
            --local priceDiff = sign > 0
            --        and oldPrice / curPrice * 100 - 100
            --        or curPrice / oldPrice * 100 - 100
            local priceDiff = oldPrice / curPrice * 100 - 100
            local timeNow = self.Timestamps[1]
            local timeThen = self.Timestamps[#self.Timestamps]
            local timeDiff = ((timeNow - timeThen) / 60)

            Log('[MasterDumpage] Current time difference: ' .. Round(timeDiff, 2) .. ' minutes', SkyBlue)
            Log('[MasterDumpage] Current price difference: ' .. Round(priceDiff, 2) .. ' % / ' .. masterDumpSize .. ' %', SkyBlue)
            
            if priceDiff > masterDumpSize
            then
                self.DumpAll = true
            end


            local logDumpAll = Load('md:lda', true)
            
            if self.DumpAll and logDumpAll then
                LogWarning('Master Market is dumping: DUMP ALL!')
                logDumpAll = false
            elseif not self.DumpAll and not logDumpAll then
                LogWarning('Looks like Master Market dump is over...')
                logDumpAll = true
            end

            Save('md:lda', logDumpAll)
        end



    -- ---------------------------------------------------------------
    -- Position object

        function Position:load(id, isBuy)
            local pos = clone(Position)

            pos.Pid = id
            pos.IsBuy = Load(id..':ib', isBuy)
            pos.Bid = Load(id..':bid', '')
            pos.Sid = Load(id..':sid', '')
            pos.Completed = Load(id..':completed', false)

            return pos
        end

        function Position:save()
            Save(self.Pid .. ':ib', self.IsBuy)
            Save(self.Pid .. ':bid', self.Bid)
            Save(self.Pid .. ':sid', self.Sid)
            Save(self.Pid .. ':completed', self.Completed)
        end

    -- ---------------------------------------------------------------
    -- Position Manager

        function PosMan:init()
            if IsModuleInitialized(self) then
                return
            end

            self.Pids = Load('posman:pids', {})
            self.Positions = {}
            self.Timer = Load('posman:t', 0)
            self.MasterTimer = Load('posman:mt', 0)

            for i = 1, #self.Pids do
                local pid = self.Pids[i] -- get ID from array
                local pos = Position:load(pid) -- load position per ID

                -- add position to array
                self.Positions = arrayAdd(self.Positions, pos)
            end

        end

        function PosMan:save()
            Save('posman:pids', self.Pids)
            Save('posman:t', self.Timer)
            Save('posman:mt', self.MasterTimer)
        end

        function PosMan:getPositionCount()
            return #self.Pids
        end

        function PosMan:newPosition(isBuy, price, amount, orderType, orderTimeout)

            local timeLeft = self.Timer + stopCooldown * 60 - Time()
            if timeLeft > 0 then
                LogWarning('Blocking new position; system in Stop Cooldown: ' .. Round(timeLeft/60.0, 2) .. ' minutes left')
                return 
            end

            local masterTimeLeft = self.MasterTimer + masterCooldown * 60 - Time()
            if masterTimeLeft > 0 then
                LogWarning('Blocking new position; system in Master Market Dumpage Cooldown: ' .. Round(masterTimeLeft/60.0, 2) .. ' minutes left')
                return 
            end


            local newId = NewGuid()
            local sideStr = isBuy and 'Buy' or 'Sell'
            Log('Creating new '..sideStr..' position, ID: ' .. SubString(newId, 0, 4) .. '...', DarkGray)

            -- add id to mem
            self.Pids = arrayAdd(self.Pids, newId)

            -- load new position
            local pos = Position:load(newId, isBuy)

            -- add position to array
            self.Positions = arrayAdd(self.Positions, pos)

            if pos.IsBuy then
                -- set buy order ID
                pos.Bid = PlaceBuyOrder(price, amount, {type = orderType, positionId = newId, timeout = orderTimeout, note = 'Buy Entry'})
            else 
                -- set sell order ID
                pos.Sid = PlaceSellOrder(price, amount, {type = orderType, positionId = newId, timeout = orderTimeout, note = 'Sell Entry'})
            end
        end


        function PosMan:update(pos)
            local pc = PositionContainer(pos.Pid)
            local cp = CurrentPrice(pc.market)

            if pc.positionId == '' and pos.Bid == '' then
                Log('Trying to update a nil position?', DarkGray)
                return 
            end

            if pos.Bid != '' then
                local order = OrderContainer(pos.Bid)

                if order.isOpen then
                    -- do smth
                else
                    if not pos.IsBuy and order.isFilled then
                        pos.Completed = true
                    end

                    pos.Bid = ''
                end
            end

            if pos.Sid != '' then
                local order = OrderContainer(pos.Sid)

                if order.isOpen then
                    -- do smth
                else
                    if pos.IsBuy and order.isFilled then
                        pos.Completed = true
                    end

                    pos.Sid = ''
                end
            end

            if pc.amount > 0 and ((pos.IsBuy and pc.isLong) or (not pos.IsBuy and pc.isShort)) then
                if IsTradeAmountEnough(PriceMarket(), cp.close, pc.amount, false) then
                    -- TODO: place sell order
                    -- TODO: stop-loss, take-profit, trailing-arm stop-loss

                    local tp = TakeProfit(takeProfit, pos.Pid)
                    local sl = StopLoss(stopLoss, pos.Pid)
                    local tsl = CC_TrailingArmStopLoss(trailDist, armLength, pos.Pid)

                    if tp then
                        self:exit(pos, 'Take-Profit')
                    elseif sl then
                        self:exit(pos, 'Stop-Loss')
                        self.Timer = Time() -- put on cooldown...
                    elseif tsl then
                        self:exit(pos, 'Trailing-Stop')
                        --self.Timer = Time() -- put on cooldown...
                    end

                else
                    Log('Position amount too small; not able to sell (will be removed)', Cyan)
                    pos.Completed = true
                end
            end
        end

        function PosMan:exit(pos, note)
            local pc = PositionContainer(pos.Pid)
            local cp = CurrentPrice()

            if pos.IsBuy and pos.Sid == '' then
                -- set sell order ID
                pos.Sid = PlaceSellOrder(cp.ask, pc.amount, {type = exitOrderType, positionId = pos.Pid, timeout = orderTimeout, note = note})
            
            elseif not pos.IsBuy and pos.Bid == '' then
                -- set buy order ID
                pos.Bid = PlaceBuyOrder(cp.bid, pc.amount, {type = exitOrderType, positionId = pos.Pid, timeout = orderTimeout, note = note})
            end
        end

        function PosMan:updateAll()
            -- update master market stuff
            local dumpAll, direction = MasterDumpage:getDumpAll()
            
            -- update positions
            local positions = self.Positions
            local i = 1
            local cp = CurrentPrice()
            local removed = {}

            while i <= #positions do
                local pos = positions[i]
                local pc = PositionContainer(pos.Pid)
                
                if not dumpAll and not forceExit then
                    self:update(pos)
                    pos:save()
                else
                    local dumpage = dumpAll --and ((direction > 0 and pos.IsBuy) or (direction < 0 and not pos.IsBuy))
                    local reason = 'Master Market Dump'

                    if forceExit then
                        reason = 'Force Exit'
                    end

                    if dumpage or forceExit then
                        if pc.amount > MinimumTradeAmount() then
                            PlaceExitPositionOrder(pos.Pid, {type = MarketOrderType, note = reason})
                        end

                        pos.Completed = true
                    end
                end

                if pos.Completed then
                    if not IsPositionClosed(pos.Pid) then
                        if not dumpAll then
                            Log('Position is completed, but left out with dust; simulated cleaning', DarkGray)
                            CloseVPosition(cp.close, pos.Pid)
                        else
                            Log('Waiting for dumpage to be over...')
                        end
                    
                    else
                        Log('Completed position cycle; removing position.', Gold)
                        removed = arrayAdd(removed, i)
                    end
                
                elseif pc.positionId == '' and pos.Bid == '' then
                    removed = arrayAdd(removed, i)

                    Log('Found empty position ID; removing position.', Gold)
                end

                i = i + 1
            end

            if dumpAll and self.MasterTimer == 0 then 
                self.MasterTimer = Time()
            elseif self.MasterTimer + masterCooldown * 60 - Time() <= 0 then
                self.MasterTimer = 0
            end

            if #removed > 0 then
                local newPids = {}

                for i = 1, #self.Pids do
                    if not ArrayContains(removed, i) then
                        if IsAnyOrderOpen(self.Pids[i]) then
                            CancelAllOrders(self.Pids[i])
                        end
                        
                        newPids = arrayAdd(newPids, self.Pids[i])
                    end
                end

                self.Pids = newPids
            end
        end






-- ===============================================================
-- Trading logic

    function hasSignal(value, level, type)
        if type == signalTypes.isBelow then
            return value <= level
        elseif type == signalTypes.isAbove then
            return value >= level
        elseif type == signalTypes.crossOver then
            return CrossOver(value, level)
        elseif type == signalTypes.crossUnder then
            return CrossUnder(value, level)
        end

        LogWarning('Signal type "' .. type .. '" is not defined in hasSignal() function!')
        return false
    end


    -- fire up position manager
    PosMan:init()

    if stopNoPos and PosMan:getPositionCount() == 0 then
        DeactivateBot('Deactivation on NoPosition triggered.', true)
    end

    -- handle master market dumpage check
    MasterDumpage:init()
    MasterDumpage:update()

    local orderTimer = Load('ot', 0)
    local logNotOK = Load('lnok', true)

    local o = OpenPrices()
    local h = HighPrices()
    local l = LowPrices()
    local c = ClosePrices()
    local v = GetVolume()
    local cp = CurrentPrice()

    local osc

    if oscillator == oscillators.rsi then
        osc = RSI(c, osc_len)
    elseif oscillator == oscillators.mfi then
        osc = MFI(h, l, c, v, osc_len)
    elseif oscillator == oscillators.cci then
        osc = CCI(h, l, c, osc_len)
    end

    Plot(2, 'Oscillator', osc, Purple)
    PlotHorizontalLine(2, 'Buy', Green, osc_buy, Dashed)
    PlotHorizontalLine(2, 'Sell', Red, osc_sell, Dashed)

    local buySignal = hasSignal(osc, osc_buy, buySignalType)
    local sellSignal = hasSignal(osc, osc_sell, sellSignalType)

    local shouldBuy = tradingDirection == tradingDirections.long and buySignal
    local shouldSell = tradingDirection == tradingDirections.short and sellSignal
    local timerOK = orderTimer + orderCooldown * 60 <= Time() -- cooldown check
    local positionCount = PosMan:getPositionCount()
    local positionsOK = positionCount < maxPositions -- max positions check
    local dumpAll = MasterDumpage:getDumpAll()

    Log('Buy signal: ' .. (shouldBuy and 'Yes' or 'No'))
    Log('Sell signal: ' .. (shouldSell and 'Yes' or 'No'))
    Log('Timer OK: ' .. (timerOK and 'Yes' or 'No'))
    Log('Position count OK: ' .. (positionsOK and 'Yes' or 'No'))

    if timerOK and positionsOK and not dumpAll then
        logNotOK = true

        if not forceExit and not stopNoPos then
            if shouldBuy then
                PosMan:newPosition(true, cp.bid, tradeAmount, GetOrderType(), orderTimeout)

                orderTimer = Time()
            
            elseif shouldSell then
                PosMan:newPosition(false, cp.ask, tradeAmount, GetOrderType(), orderTimeout)

                orderTimer = Time()
            end
        end
    else
        if logNotOK then
            if not timerOK then
                Log('Blocking new entries; entry cooldown', DarkGray)
                logNotOK = false
            elseif not positionsOK then
                Log('Blocking new entries; max positions open', DarkGray)
                logNotOK = false
            elseif dumpAll then
                Log('Blocking new entries; master market is dumping', DarkGray)
                logNotOK = false
            end
        end
    end

    Save('lnok', logNotOK)
    Save('ot', orderTimer)

    Plot(1, 'Positions', positionCount)

    -- update all positions and save
    PosMan:updateAll()
    PosMan:save()
    MasterDumpage:save()

3 Comments

Sign in to leave a comment.

P
Patjekadetje over 4 years ago

36. 11 Nov 2021 16:14:41 ERROR: Log(): Invalid input count of 2 (only takes 1)

It throws this error at me, when I start it and or run it through debug

M
m00k over 4 years ago

A Log error like this is usually because in the updated beta version you can use colours, if you don’t have this, remove the colour after the “ , “ in the Log. Eg. Log(‘foo’, Green) => Log(‘foo’)

P
pshai over 4 years ago

Hmm, looks like you are running an older version of HTS. I can't remember the version, but a custom color input was introduced for Log command. You can remove the color inputs from the Log() command calls as well.