Moving Prices Experiment Summary
In the Winter season (Jan to Apr 2015), a more sophisticated dynamic ticket pricing mechanism was put in place. Two variants were in place, to be analogous to the +/-25% and +/-50% schemes of Rising Prices in the previous season. In one variant, to be denoted D25, four bands were constructed showing for each day in the sales period where cumulative sales stood relative to anticipated values. The region in which sales lay determined whether the tickets were sold at base price, or discounted by 15% or 25%, or surcharged by 15% or 25%. In the other variance, denoted D50, the same principle applied, but with discounts of 25% or 50%, or surcharges of 25% and 50%. There were 8 shows in the D25 category, and 13 in the D50 category.
Of the 21 shows, 7 generated no price changes over the sales period; 11 showed prices that fell below base (but were never above); none were risen above base (but never below); while 3 showed prices both above and below base.
The price adjustment mechanism was based upon eyeball projections of anticipated sales. In retrospect, this was clearly anticipating too optimistic a sales pattern, meaning that a typical show would - in the early sales period - display under-achievement leading to a price fall.
The over-optimistic prediction of likely early sales, had a detrimental impact on the performance of events under the +/-25% and +/-50% Moving Pricing schemes, with 52% of shows only lowering in price and 86% remaining either at or below the core price.
The performance of the entire scheme may well have been markedly different had the Sell Out, Early, Steady and Late sales curve been closer aligned to actual sales and therefore it would be sensible to recommend that true, automated dynamic pricing - as with Moving Prices - only be deployed where considerable time has been spent using historical sales patterns to inform the dynamic pricing algorithms.
Within the scope of the results included here, it also seems that with a low (.13) elasticity of demand, the fall in price triggered in the box office platform only served to decrease revenues, with no significant beneficial increase in demand. As suggested above, it may be concluded that an upward only Moving Prices scheme may be more productive - and safer - for venues like The Firestation.
That being said, over the course of the January to April events season, the Moving Prices +/-25% and +/50% schemes had no overall detrimental effect on either prices or revenues. It is quite possible that, with predicted sales patterns tailored from the above results, or upward only models, Moving Prices with modest ranges could produce a more positive result in both revenue and audience numbers.
For full details please see the Neo-Ticketing Final Report (link to follow)
1 note
·
View note
Price Crash Experiment Results
During the Winter season 2015, in addition to the Moving Prices +/-25% and +/-50% schemes, there were ‘random price crashes’. In these, the box office system would discount the current price for a show by 75% – for 30 minutes only, to be announced via The Firestation’s social media channels. The price crash idea was as much about generating excitement about the experiment and encouraging sharing and new likes on social media channels, as it was to actually impact upon ticket sales and revenue. Note however that, given that sell-outs are rare, even sales at 75% off will raise revenue if these customers would not otherwise buy tickets to that show.
Although its number of followers moves up and down all the time, The Firestation has been active on Facebook and Twitter for a number of years and during the project time-frame had approximately 2,500 followers on Twitter and 2,800 likes on Facebook.
In order to ensure that Price Crash occurrences were entirely random, all events in the January to April season were run through a ‘dice roll’ process. In the first instance, it was established whether and event would have one, two, three or no Price Crashes. If the result came up one too three, then each occurrence was randomly selected by month, date and time.
Whilst much discussion was had on the length of the crash and at what times these should occur, it was decided that crashes should occur only between 08:00 and 01:00 and last no longer than 30 minutes (to generate a sense of urgency).
These price crashes were then manually set up on the box office system using the “ability to manually add new dynamic calculation rules” outlined above in the ‘Building the Code’ chapter. Scheduled posts on Facebook and scheduled tweets on Twitter were then set up on both platforms to post at the same time as the price dropped on the box office. An example of such a post - for Wind in the Willows can be seen below;
Sales Results
Disappointingly, and to everyone’s surprise, the Price Crash experiments resulted in no sales. Whilst a small number of the early tweets failed (an issue with scheduled posts from Facebook not transferring to Twitter and Twitter Ads rejecting multiple posts with the same copy), the majority posted successfully and were matched with successful price crashes on the box office.
Whilst it is difficult to speculate on the reason for the lack of uptake, it seems likely that it is the result of 4 factors;
That 30 minutes is not long enough for potential customers to digest the information, take time away from whatever else they were doing, or contact friends to arrange tickets and/or availabilities.
That the price crashes suffered from the same effect outlined above in the Rising Prices Results & Analysis conclusion, where customers were reluctant to purchase heavily discounted tickets, preferring instead to support the venue with higher price tickets.
That the Price Crash creates a negative impression in the customers mind, suggesting that ticket sales are slow or that the show is of poor quality.
That the ‘Price Crash’ idea will take longer to ‘bed in’ and be adopted by regular customers and social media followers than the 4 months studied here.
Analytics Results
With no sales results to examine, it was still possible to look at web traffic and analytics, to see if the Price Crash posts performed better than other posts on the venue’s social media platforms. For examples, whilst there were no sales, was a Price Crash tweet re-tweeted more often than others on the same day, or was a Facebook post liked above average?
In fact, as they are the price crashes appear to have an overall similar impact as a normal post. There is nothing to suggest they increase (or decrease activity).
The charts below show the performance by month of both Price Crash and normal tweets and posts on both platforms;
0 notes
Writing the Code
By Ben Curthoys
Part of the problem with dynamic pricing - and this is the general problem with writing any code at all, really - is that making it do only one thing is easy. The first set of dynamic price schemes that we wanted to test were very simple: the prices went up every day, no matter what. With a system like that, taking a request like;
"Make the prices go from 25% off to 25% extra over the course of the 50 days before the performance"
would result in some code that would say;
"At any point in time the adjustment should be (25 minus the number of days until the performance) percent."
Simple. We don't need to store long and elaborate histories of prices, and we don't need to know what we were doing yesterday to calculate what we're doing today; the algorithm is entirely deterministic.
Of course, I knew that after we'd tested simple rising prices, the next step would be to test a more sophisticated price scheme that went up and down according to demand. But we'd be modelling that relatively simply as well (if total sales were between X% and Y% set the adjustment to Z%), so there was a tempation to just build two completely different dynamic price models and call it a day.
The trouble is, when someone dreams up a third - and a fourth, and a fifth - you have to keep adding more and more code, and it gets gradually harder to explain and train people to use and to maintain. The ideal solution is to have just one dynamic price system that works for all the models we can think of - and all the ones that we haven't thought of yet, too.
The solution we arrived at was a rules based one. Each price scheme could have a number of rules, of the form
If (condition has happened) then (change the price by £X)|(set the price to £Y)
This means that we can express the simple, rising prices as
If (anything) then (put the price up by 1%)
and we can express the dynamic prices by saying
If (we've sold fewer tickets than we expected) then (set the price to 25% off)
If (we've sold roughly as many tickets as we expected) then (set the price to the default)
If (we've sold more tickets than we expected) then (set the price to 25% extra)
and we can even express more sophisticated schemes by saying
If (we've sold loads of tickets in the last 30 minutes) then (nudge the price up a bit based on what it was)
If (we've sold no of tickets in weeks) then (drop the price down a bit)
(though we're not even testing that last one in this project because the results will be too hard to analyse.)
So here's the code that goes through all the products using a dynamic price scheme, and updates their adjustments based on which rules are matched:
IF EXISTS (SELECT * FROM sys.objects WHERE object_id = OBJECT_ID(N'[dbo].[spPriceSchemeDynamic_UPDATEPRODUCTS]') AND type in (N'P', N'PC'))
DROP PROCEDURE [dbo].spPriceSchemeDynamic_UPDATEPRODUCTS
GO
CREATE PROCEDURE spPriceSchemeDynamic_UPDATEPRODUCTS (@PriceSchemeId as uniqueidentifier)
AS
DECLARE @DynamicAmount money; DECLARE @DynamicPercentage decimal(5, 4); DECLARE @DynamicRounding money; DECLARE @DynamicMin money; DECLARE @DynamicMax money; DECLARE @DynamicChangeAmount money
DECLARE @DynamicChangePercentage decimal(5, 4);DECLARE @DynamicChangeRounding money;DECLARE @DynamicChangeMin money; DECLARE @DynamicChangeMax money
DECLARE @DynamicTotalChangeAmount money;DECLARE @DynamicTotalChangePercentage decimal(5, 4) ;DECLARE @DynamicTotalChangeRounding money ;DECLARE @DynamicTotalChangeMin money ;DECLARE @DynamicTotalChangeMax money
DECLARE @DynamicSetAmount bit;DECLARE @StartSourceDateType nvarchar(50) ;DECLARE @StartOffset bigint ;DECLARE @EndSourceDateType nvarchar(50) ;DECLARE @EndOffset bigint ;DECLARE @SampleDurationTicks bigint ;DECLARE @SampleMinTickets int ;DECLARE @SampleMaxTickets int ;DECLARE @SampleMinTicketsFunction nvarchar(255);DECLARE @SampleMaxTicketsFunction nvarchar(255)
DECLARE curPriceSchemeDynamicRules CURSOR LOCAL DYNAMIC FOR
SELECT DynamicChangeAmount,
DynamicChangePercentage,
DynamicChangeRounding,
DynamicChangeMin,
DynamicChangeMax,
StartSourceDateType,
StartOffset,
EndSourceDateType,
EndOffset,
SampleDurationTicks,
SampleMinTickets,
SampleMaxTickets,
DynamicSetAmount,
SampleMinTicketsFunction,
SampleMaxTicketsFunction
FROM PriceSchemeDynamicRules
WHERE PriceSchemeId = @PriceSchemeId
OPEN curPriceSchemeDynamicRules
DECLARE @ProductId uniqueidentifier
DECLARE @PerfStartDateTime DATETIME
DECLARE @PerfEndDateTime DATETIME
DECLARE @ShowStartDate DATE
DECLARE @ShowEndDate DATE
DECLARE @OldProductDynamicPriceCalculationId uniqueidentifier
DECLARE curProductDynamicData CURSOR STATIC LOCAL FOR
SELECT
Products.ProductId,
FolderPerformanceData.StartDateTimeUTC, FolderPerformanceData.EndDateTimeUTC,
FolderShowData.ShowStartDate, FolderShowData.ShowEndDate,
ProductDynamicPriceCalculationId,
DynamicAmount, DynamicPercentage, DynamicRounding, DynamicMin, DynamicMax
FROM
Products
INNER JOIN ProductsActive ON Products.ProductId = ProductsActive.ProductId
INNER JOIN FolderDataCache ON Products.FolderId = FolderDataCache.FolderId
INNER JOIN PriceSchemeDynamic ON Products.PriceSchemeId = PriceSchemeDynamic.PriceSchemeId
LEFT JOIN ProductDynamicPriceCalculation ON Products.ProductId = ProductDynamicPriceCalculation.ProductId
LEFT JOIN FolderShowData ON FolderDataCache.ShowFolderId = FolderShowData.ShowFolderId
LEFT JOIN FolderPerformanceData ON FolderDataCache.PerformanceFolderId = FolderPerformanceData.PerformanceFolderId
WHERE Products.PriceSchemeId = @PriceSchemeId AND ProductDynamicPriceCalculation.ActiveTo IS NULL
OPEN curProductDynamicData
FETCH NEXT FROM curProductDynamicData INTO @ProductId, @PerfStartDateTime, @PerfEndDateTime, @ShowStartDate, @ShowEndDate, @OldProductDynamicPriceCalculationId, @DynamicAmount, @DynamicPercentage, @DynamicRounding, @DynamicMin, @DynamicMax
WHILE @@FETCH_STATUS = 0
BEGIN
-- Reset the total changes
SELECT @DynamicTotalChangeAmount = NULL, @DynamicTotalChangePercentage = NULL, @DynamicTotalChangeRounding = NULL, @DynamicTotalChangeMin = NULL, @DynamicTotalChangeMax = NULL
-- PRINT 'ProductId ' + ISNULL(CONVERT(nvarchar(255), @ProductId), 'null')
FETCH FIRST FROM curPriceSchemeDynamicRules INTO @DynamicChangeAmount, @DynamicChangePercentage, @DynamicChangeRounding, @DynamicChangeMin, @DynamicChangeMax, @StartSourceDateType, @StartOffset, @EndSourceDateType, @EndOffset, @SampleDurationTicks, @SampleMinTickets, @SampleMaxTickets, @DynamicSetAmount, @SampleMinTicketsFunction, @SampleMaxTicketsFunction
WHILE @@FETCH_STATUS = 0
BEGIN
-- PRINT '------------------'
-- PRINT 'Rule ' + CONVERT(NVARCHAR(255), @DynamicChangePercentage)
-- PRINT 'Performance Date ' + CONVERT(NVARCHAR(255), @PerfStartDateTime)
-- If this performance matches this rule, then modify the Dynamic Data by the amounts specified
DECLARE @doUpdate bit = 1
IF NOT EXISTS (
SELECT 1 FROM
(
SELECT
CASE @StartSourceDateType
WHEN 'NONE' THEN NULL
WHEN 'EXACT' THEN dbo.fnTicksToDate(@StartOffset)
WHEN 'SHOWSTART' THEN DATEADD(s, @StartOffset * POWER(10.00000000000,-7), COALESCE(@ShowStartDate, @PerfStartDateTime))
WHEN 'SHOWEND' THEN DATEADD(s, @StartOffset * POWER(10.00000000000,-7), COALESCE(DATEADD(minute, -1, DATEADD(day, 1, @ShowEndDate)), @PerfEndDateTime))
WHEN 'PERFSTART' THEN DATEADD(s, @StartOffset * POWER(10.00000000000,-7), COALESCE(@PerfStartDateTime, @ShowStartDate))
WHEN 'PERFEND' THEN DATEADD(s, @StartOffset * POWER(10.00000000000,-7), COALESCE(@PerfEndDateTime, @ShowStartDate))
WHEN 'PERFSTARTDATE' THEN DATEADD(s, @StartOffset * POWER(10.00000000000,-7), dbo.fnDateOnly(COALESCE(@PerfStartDateTime, @ShowStartDate)))
WHEN 'PERFENDDATE' THEN DATEADD(s, @StartOffset * POWER(10.00000000000,-7), dbo.fnDateOnly(COALESCE(@PerfEndDateTime, @ShowStartDate)))
END AS StartDateTime,
CASE @EndSourceDateType
WHEN 'NONE' THEN NULL
WHEN 'EXACT' THEN dbo.fnTicksToDate(@EndOffset)
WHEN 'SHOWSTART' THEN DATEADD(s, @EndOffset * POWER(10.00000000000,-7), COALESCE(@ShowStartDate, @PerfStartDateTime))
WHEN 'SHOWEND' THEN DATEADD(s, @EndOffset * POWER(10.00000000000,-7), COALESCE(DATEADD(minute, -1, DATEADD(day, 1, @ShowEndDate)), @PerfEndDateTime))
WHEN 'PERFSTART' THEN DATEADD(s, @EndOffset * POWER(10.00000000000,-7), COALESCE(@PerfStartDateTime, DATEADD(minute, -1, DATEADD(day, 1, @ShowEndDate))))
WHEN 'PERFEND' THEN DATEADD(s, @EndOffset * POWER(10.00000000000,-7), COALESCE(@PerfEndDateTime, DATEADD(minute, -1, DATEADD(day, 1, @ShowEndDate))))
WHEN 'PERFSTARTDATE' THEN DATEADD(s, @EndOffset * POWER(10.00000000000,-7), dbo.fnDateOnly(COALESCE(@PerfStartDateTime, DATEADD(minute, -1, DATEADD(day, 1, @ShowEndDate)))))
WHEN 'PERFENDDATE' THEN DATEADD(s, @EndOffset * POWER(10.00000000000,-7), dbo.fnDateOnly(COALESCE(@PerfEndDateTime, DATEADD(minute, -1, DATEADD(day, 1, @ShowEndDate)))))
END AS EndDateTime
) AS ConcreteDates
WHERE
(GETDATE() > StartDateTime OR StartDateTime IS NULL) AND
(GETDATE() < EndDateTime OR EndDateTime IS NULL)
)
BEGIN
SET @doUpdate = 0
END
IF (@SampleMinTickets IS NOT NULL OR @SampleMinTicketsFunction IS NOT NULL OR @SampleMaxTickets IS NOT NULL OR @SampleMaxTicketsFunction IS NOT NULL)
BEGIN
DECLARE @SampleTicketsSold INT
SET @SampleTicketsSold = (SELECT COUNT(*) FROM Items INNER JOIN OrderItems ON Items.ItemId = OrderItems.ItemId INNER JOIN Orders ON OrderItems.OrderId = Orders.OrderId WHERE Items.ProductId = @ProductId AND (NULLIF(@SampleDurationTicks, 0) IS NULL OR OrderDate > DATEADD(s, -1 * @SampleDurationTicks * POWER(10.00000000000,-7), GETDATE())))
IF @SampleMaxTicketsFunction IS NOT NULL OR @SampleMaxTicketsFunction IS NOT NULL
BEGIN
-- Just get earliest date - no matter what permissions are attached.
DECLARE @OnSaleDateTime DATETIME =
(
SELECT
MIN(
CASE StartSourceDateType
WHEN 'EXACT' THEN dbo.fnTicksToDate(StartOffset)
WHEN 'SHOWSTART' THEN DATEADD(s, StartOffset * POWER(10.00000000000,-7), COALESCE(@ShowStartDate, @PerfStartDateTime))
WHEN 'SHOWEND' THEN DATEADD(s, StartOffset * POWER(10.00000000000,-7), COALESCE(DATEADD(minute, -1, DATEADD(day, 1, @ShowEndDate)), @PerfEndDateTime))
WHEN 'PERFSTART' THEN DATEADD(s, StartOffset * POWER(10.00000000000,-7), COALESCE(@PerfStartDateTime, @ShowStartDate))
WHEN 'PERFEND' THEN DATEADD(s, StartOffset * POWER(10.00000000000,-7), COALESCE(@PerfEndDateTime, @ShowStartDate))
END) AS StartDateTime
FROM
OnSaleSchemeValues
INNER JOIN Products ON Products.OnSaleSchemeId = OnSaleSchemeValues.OnSaleSchemeId
WHERE
Products.ProductId = @ProductId
)
-- If there are no dates, get the date it was activated
IF (@OnSaleDateTime = '1900-01-01' OR @OnSaleDateTime IS NULL)
SET @OnSaleDateTime = (SELECT ActivatedDateTime FROM ProductsActive WHERE ProductId = @ProductId)
-- PRINT 'On Sale Date ' + CONVERT(Nvarchar(255), @OnSaleDateTime)
DECLARE @TotalCapacity INT = (SELECT COUNT(*) FROM Items WHERE ProductId = @ProductId)
-- PRINT 'Total Capacity ' + CONVERT(Nvarchar(255), @TotalCapacity)
IF (NULLIF(@SampleMinTicketsFunction, '') IS NOT NULL)
BEGIN
SELECT @SampleMinTickets = dbo.fnDynamicExpectedSalesCount(@SampleMinTicketsFunction, @OnSaleDateTime, @PerfStartDateTime, @TotalCapacity)
END
IF (NULLIF(@SampleMaxTicketsFunction, '') IS NOT NULL)
BEGIN
SELECT @SampleMaxTickets = dbo.fnDynamicExpectedSalesCount(@SampleMaxTicketsFunction, @OnSaleDateTime, @PerfStartDateTime, @TotalCapacity)
END
END
-- PRINT 'Rule Min: ' + ISNULL(CONVERT(nvarchar(255), @SampleMinTickets), 'null')
-- PRINT 'Rule Max: ' + ISNULL(CONVERT(nvarchar(255), @SampleMaxTickets) , 'null')
-- PRINT 'Actual: ' + ISNULL(CONVERT(nvarchar(255), @SampleTicketsSold) , 'null')
IF (@SampleMinTickets IS NOT NULL AND @SampleTicketsSold <= @SampleMinTickets)
SET @doUpdate = 0
IF (@SampleMaxTickets IS NOT NULL AND @SampleTicketsSold >= @SampleMaxTickets)
SET @doUpdate = 0
IF (@SampleMinTickets = @SampleMaxTickets)
SET @doUpdate = 0
END
IF @doUpdate = 1
BEGIN
-- PRINT 'Doing Update'
IF @DynamicSetAmount = 1
BEGIN
-- Just set the base values
SELECT
@DynamicAmount = @DynamicChangeAmount,
@DynamicPercentage = @DynamicChangePercentage,
@DynamicRounding = @DynamicChangeRounding,
@DynamicMin = @DynamicChangeMin,
@DynamicMax = @DynamicChangeMax
END
ELSE
BEGIN
-- Add this record to the change
SELECT
@DynamicAmount = COALESCE(@DynamicAmount + @DynamicChangeAmount, @DynamicAmount, @DynamicChangeAmount),
@DynamicPercentage = COALESCE(@DynamicPercentage + @DynamicChangePercentage, @DynamicPercentage, @DynamicChangePercentage),
@DynamicRounding = COALESCE(@DynamicRounding + @DynamicChangeRounding, @DynamicRounding, @DynamicChangeRounding),
@DynamicMin = COALESCE(@DynamicMin + @DynamicChangeMin, @DynamicMin, @DynamicChangeMin),
@DynamicMax = COALESCE(@DynamicMax + @DynamicChangeMax, @DynamicMax, @DynamicChangeMax)
END
END
-- PRINT 'RESULT'
-- PRINT @DynamicPercentage
FETCH NEXT FROM curPriceSchemeDynamicRules INTO @DynamicChangeAmount, @DynamicChangePercentage, @DynamicChangeRounding, @DynamicChangeMin, @DynamicChangeMax, @StartSourceDateType, @StartOffset, @EndSourceDateType, @EndOffset, @SampleDurationTicks, @SampleMinTickets, @SampleMaxTickets, @DynamicSetAmount, @SampleMinTicketsFunction, @SampleMaxTicketsFunction
END
-- If the value has changed in any way
-- PRINT '@OldProductDynamicPriceCalculationId'
-- PRINT @OldProductDynamicPriceCalculationId
-- SELECT * WHERE null = null returns no records
-- So I'm going to convert them into a value and hope no one ever uses it.
IF (@OldProductDynamicPriceCalculationId IS NULL OR NOT EXISTS (SELECT * FROM ProductDynamicPriceCalculation WHERE ProductDynamicPriceCalculationId = @OldProductDynamicPriceCalculationId AND
ISNULL(DynamicAmount, -999) = ISNULL(@DynamicAmount, -999) AND
ISNULL(DynamicPercentage, -9) = ISNULL(@DynamicPercentage, -9) AND
ISNULL(DynamicRounding, -999) = ISNULL(@DynamicRounding, -999) AND
ISNULL(DynamicMin, -999) = ISNULL(@DynamicMin, -999) AND
ISNULL(DynamicMax, -999) = ISNULL(@DynamicMax, -999)))
BEGIN
-- PRINT 'Doing Insert'
DECLARE @ChangeDate DATETIME = GETDATE()
UPDATE ProductDynamicPriceCalculation SET ActiveTo = @ChangeDate
WHERE ProductDynamicPriceCalculationId = @OldProductDynamicPriceCalculationId
INSERT INTO ProductDynamicPriceCalculation
(ProductId, DynamicAmount, DynamicPercentage, DynamicRounding, DynamicMin, DynamicMax, ActiveFrom, ActiveTo)
VALUES
(@ProductId, @DynamicAmount, @DynamicPercentage, @DynamicRounding, @DynamicMin, @DynamicMax, @ChangeDate, NULL)
END
FETCH NEXT FROM curProductDynamicData INTO @ProductId, @PerfStartDateTime, @PerfEndDateTime, @ShowStartDate, @ShowEndDate, @OldProductDynamicPriceCalculationId, @DynamicAmount, @DynamicPercentage, @DynamicRounding, @DynamicMin, @DynamicMax
END
0 notes