Skip to content

Commit

Permalink
Adding Fees to Buying and Selling Bets (openpredictionmarkets#287)
Browse files Browse the repository at this point in the history
* Drafting out fees deduction functions and capability.

* Attempting working version of initialBetFee function.

* Draft adding fees.

* Update, adding tests.

* Adding working test for GetBetsForMarket

* Successful fee util test.

* Fees added on backend, tests passing.

* Updating such that user record submitted. However fee summing not working yet evidently.

* Working fees, at least initial fees.

* Updating test scenario passing, made more clear.

* Update Dockerfile

We need to switch to 3.0.14-1~deb12u2

* Removing logging.

* Adding fees, including communicating fees on front end.

* totalBetCount to userBetCount so as not to misconstrue meaning.

* Simplifying function, test passed.

* Simplifying naming.

* Updating new function name in test

* Changing test to got before want convention.

* Update marketid variable

* Add combined fee structure for more througough test.

* Reverting sale amount to 1 share.

* Reversing got want

---------

Co-authored-by: Osnat Katz Moon <[email protected]>
  • Loading branch information
pwdel and astrosnat authored Sep 7, 2024
1 parent cb95bd5 commit 51b1533
Show file tree
Hide file tree
Showing 18 changed files with 430 additions and 21 deletions.
1 change: 1 addition & 0 deletions README/LOCAL_SETUP.md
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@ docker compose -p scripts logs | grep backend
* Likewise, frontend, nginx, database, certbot errors can be filtered out similarly with:

```
docker compose -p scripts logs | grep backend
docker compose -p scripts logs | grep frontend
docker compose -p scripts logs | grep nginx
docker compose -p scripts logs | grep postgres
Expand Down
2 changes: 1 addition & 1 deletion backend/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ SHELL ["/bin/bash", "-euo", "pipefail", "-c"]
# Install Dependencies
RUN apt-get update && apt-get install -y --allow-downgrades \
inotify-tools=3.22.6.0-4 \
openssl=3.0.11-1~deb12u2
openssl=3.0.14-1~deb12u2

# Set the Working Directory inside the container
WORKDIR /backend
Expand Down
6 changes: 4 additions & 2 deletions backend/go.mod
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
module socialpredict

go 1.22.5
go 1.23

require (
github.com/brianvoe/gofakeit v3.18.0+incompatible
Expand All @@ -12,7 +12,8 @@ require (
golang.org/x/crypto v0.17.0
gopkg.in/yaml.v3 v3.0.1
gorm.io/driver/postgres v1.5.4
gorm.io/gorm v1.25.5
gorm.io/driver/sqlite v1.5.6
gorm.io/gorm v1.25.7-0.20240204074919-46816ad31dde
)

require (
Expand All @@ -23,6 +24,7 @@ require (
github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect
github.com/kr/text v0.2.0 // indirect
github.com/mattn/go-sqlite3 v1.14.22 // indirect
github.com/rogpeppe/go-internal v1.11.0 // indirect
golang.org/x/sync v0.1.0 // indirect
golang.org/x/text v0.14.0 // indirect
Expand Down
8 changes: 6 additions & 2 deletions backend/go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@ github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0=
github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rogpeppe/go-internal v1.11.0 h1:cWPaGQEPrBb5/AsnsZesgZZ9yb1OQ+GOISoDNXVBh4M=
Expand All @@ -53,5 +55,7 @@ gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gorm.io/driver/postgres v1.5.4 h1:Iyrp9Meh3GmbSuyIAGyjkN+n9K+GHX9b9MqsTL4EJCo=
gorm.io/driver/postgres v1.5.4/go.mod h1:Bgo89+h0CRcdA33Y6frlaHHVuTdOf87pmyzwW9C/BH0=
gorm.io/gorm v1.25.5 h1:zR9lOiiYf09VNh5Q1gphfyia1JpiClIWG9hQaxB/mls=
gorm.io/gorm v1.25.5/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8=
gorm.io/driver/sqlite v1.5.6 h1:fO/X46qn5NUEEOZtnjJRWRzZMe8nqJiQ9E+0hi+hKQE=
gorm.io/driver/sqlite v1.5.6/go.mod h1:U+J8craQU6Fzkcvu8oLeAQmi50TkwPEhHDEjQZXDah4=
gorm.io/gorm v1.25.7-0.20240204074919-46816ad31dde h1:9DShaph9qhkIYw7QF91I/ynrr4cOO2PZra2PFD7Mfeg=
gorm.io/gorm v1.25.7-0.20240204074919-46816ad31dde/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8=
4 changes: 0 additions & 4 deletions backend/handlers/bets/betutils/betutils.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,6 @@ import (
"gorm.io/gorm"
)

func init() {

}

// CheckMarketStatus checks if the market is resolved or closed.
// It returns an error if the market is not suitable for placing a bet.
func CheckMarketStatus(db *gorm.DB, marketID uint) error {
Expand Down
65 changes: 65 additions & 0 deletions backend/handlers/bets/betutils/feeutils.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
package betutils

import (
"log"
"socialpredict/handlers/tradingdata"
"socialpredict/models"
"socialpredict/setup"

"gorm.io/gorm"
)

// appConfig holds the loaded application configuration accessible within the package
var appConfig *setup.EconomicConfig

func init() {
var err error
appConfig, err = setup.LoadEconomicsConfig()
if err != nil {
log.Fatalf("Failed to load configuration: %v", err)
}
}

// Get initial bet fee, if applicable, for user on market.
// If this is the first bet on this market for the user, apply a fee.
func getUserInitialBetFee(db *gorm.DB, marketID uint, user *models.User) int64 {
// Fetch bets for the market
allBetsOnMarket := tradingdata.GetBetsForMarket(db, marketID)

// Check if the user has placed any bets on this market
for _, bet := range allBetsOnMarket {
if bet.Username == user.Username {
// User has placed a bet, so no initial fee is applicable
return 0
}
}

// This is the user's first bet on this market, apply the initial bet fee
return appConfig.Economics.Betting.BetFees.InitialBetFee
}

func getTransactionFee(betRequest models.Bet) int64 {

var transactionFee int64

// if amount > 0, buying share, else selling share
if betRequest.Amount > 0 {
transactionFee = appConfig.Economics.Betting.BetFees.BuySharesFee
} else {
transactionFee = appConfig.Economics.Betting.BetFees.SellSharesFee
}

return transactionFee
}

func GetBetFees(db *gorm.DB, user *models.User, betRequest models.Bet) int64 {

MarketID := betRequest.MarketID

initialBetFee := getUserInitialBetFee(db, MarketID, user)
transactionFee := getTransactionFee(betRequest)

sumOfBetFees := initialBetFee + transactionFee

return sumOfBetFees
}
200 changes: 200 additions & 0 deletions backend/handlers/bets/betutils/feeutils_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,200 @@
package betutils

import (
"socialpredict/models"
"socialpredict/setup"
"testing"
"time"

"gorm.io/driver/sqlite"
"gorm.io/gorm"
)

func mockEconomicConfig() *setup.EconomicConfig {
return &setup.EconomicConfig{
Economics: struct {
MarketCreation struct {
InitialMarketProbability float64 `yaml:"initialMarketProbability"`
InitialMarketSubsidization int64 `yaml:"initialMarketSubsidization"`
InitialMarketYes int64 `yaml:"initialMarketYes"`
InitialMarketNo int64 `yaml:"initialMarketNo"`
} `yaml:"marketcreation"`
MarketIncentives struct {
CreateMarketCost int64 `yaml:"createMarketCost"`
TraderBonus int64 `yaml:"traderBonus"`
} `yaml:"marketincentives"`
User struct {
InitialAccountBalance int64 `yaml:"initialAccountBalance"`
MaximumDebtAllowed int64 `yaml:"maximumDebtAllowed"`
} `yaml:"user"`
Betting struct {
MinimumBet int64 `yaml:"minimumBet"`
BetFees struct {
InitialBetFee int64 `yaml:"initialBetFee"`
BuySharesFee int64 `yaml:"buySharesFee"`
SellSharesFee int64 `yaml:"sellSharesFee"`
} `yaml:"betFees"`
} `yaml:"betting"`
}{
MarketCreation: struct {
InitialMarketProbability float64 `yaml:"initialMarketProbability"`
InitialMarketSubsidization int64 `yaml:"initialMarketSubsidization"`
InitialMarketYes int64 `yaml:"initialMarketYes"`
InitialMarketNo int64 `yaml:"initialMarketNo"`
}{
InitialMarketProbability: 0.5,
InitialMarketSubsidization: 10,
InitialMarketYes: 0,
InitialMarketNo: 0,
},
MarketIncentives: struct {
CreateMarketCost int64 `yaml:"createMarketCost"`
TraderBonus int64 `yaml:"traderBonus"`
}{
CreateMarketCost: 10,
TraderBonus: 1,
},
User: struct {
InitialAccountBalance int64 `yaml:"initialAccountBalance"`
MaximumDebtAllowed int64 `yaml:"maximumDebtAllowed"`
}{
InitialAccountBalance: 1000,
MaximumDebtAllowed: 500,
},
Betting: struct {
MinimumBet int64 `yaml:"minimumBet"`
BetFees struct {
InitialBetFee int64 `yaml:"initialBetFee"`
BuySharesFee int64 `yaml:"buySharesFee"`
SellSharesFee int64 `yaml:"sellSharesFee"`
} `yaml:"betFees"`
}{
MinimumBet: 1,
BetFees: struct {
InitialBetFee int64 `yaml:"initialBetFee"`
BuySharesFee int64 `yaml:"buySharesFee"`
SellSharesFee int64 `yaml:"sellSharesFee"`
}{
InitialBetFee: 1,
BuySharesFee: 1,
SellSharesFee: 0,
},
},
},
}
}

func TestGetUserInitialBetFee(t *testing.T) {
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
if err != nil {
t.Fatalf("Failed to connect to the database: %v", err)
}
if err := db.AutoMigrate(&models.Bet{}, &models.User{}); err != nil {
t.Fatalf("Failed to migrate models: %v", err)
}

appConfig = mockEconomicConfig()
user := &models.User{Username: "testuser", AccountBalance: 1000, ApiKey: "unique_api_key_1"}
if err := db.Create(user).Error; err != nil {
t.Fatalf("Failed to save user to database: %v", err)
}

marketID := uint(1)

// getUserInitialBetFee function to include both initial and buy share fees
// For testing purpose, assuming getUserInitialBetFee function does this calculation correctly
initialBetFee := getUserInitialBetFee(db, marketID, user) + appConfig.Economics.Betting.BetFees.BuySharesFee
wantFee := appConfig.Economics.Betting.BetFees.InitialBetFee + appConfig.Economics.Betting.BetFees.BuySharesFee
if initialBetFee != wantFee {
t.Errorf("getUserInitialBetFee(db, %d, %s) = %d, want %d", marketID, user.Username, initialBetFee, wantFee)
}

// Place a bet for the user on Market 1
bet := models.Bet{Username: "testuser", MarketID: marketID, Amount: 100, PlacedAt: time.Now()}
if err := db.Create(&bet).Error; err != nil {
t.Fatalf("Failed to save bet to database: %v", err)
}

// Scenario 2: User places another bet on Market 1 where they already have a bet
initialBetFee = getUserInitialBetFee(db, marketID, user)
wantFee = 0
if initialBetFee != wantFee {
t.Errorf("getUserInitialBetFee(db, %d, %s) = %d, want %d after placing a bet", marketID, user.Username, initialBetFee, wantFee)
}

// Update the market ID for a new scenario
marketID = 2

// Scenario 3: User places a bet on Market 2 where they have no prior bets
initialBetFee = getUserInitialBetFee(db, marketID, user)
if initialBetFee != appConfig.Economics.Betting.BetFees.InitialBetFee {
t.Errorf("getUserInitialBetFee(db, %d, %s) = %d, want %d", marketID, user.Username, initialBetFee, appConfig.Economics.Betting.BetFees.InitialBetFee)
}
}

func TestGetTransactionFee(t *testing.T) {
// Mock the appConfig with test data
appConfig = mockEconomicConfig()

// Test buy scenario
buyBet := models.Bet{Amount: 100}
transactionFee := getTransactionFee(buyBet)
if transactionFee != appConfig.Economics.Betting.BetFees.BuySharesFee {
t.Errorf("Expected buy transaction fee to be %d, got %d", appConfig.Economics.Betting.BetFees.BuySharesFee, transactionFee)
}

// Test sell scenario
sellBet := models.Bet{Amount: -100}
transactionFee = getTransactionFee(sellBet)
if transactionFee != appConfig.Economics.Betting.BetFees.SellSharesFee {
t.Errorf("Expected sell transaction fee to be %d, got %d", appConfig.Economics.Betting.BetFees.SellSharesFee, transactionFee)
}
}

func TestGetSumBetFees(t *testing.T) {
// Set up in-memory SQLite database
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
if err != nil {
t.Fatalf("Failed to connect to the database: %v", err)
}

// Migrate the Bet model
db.AutoMigrate(&models.Bet{})

// Mock the appConfig with test data
appConfig = mockEconomicConfig()

// Create a test user
user := &models.User{Username: "testuser"}

// Scenario 1: User has no bets, buys shares, gets initial fee
buyBet := models.Bet{MarketID: 1, Amount: 100}
sumOfBetFees := GetBetFees(db, user, buyBet)
expectedSum := appConfig.Economics.Betting.BetFees.InitialBetFee +
appConfig.Economics.Betting.BetFees.BuySharesFee
if sumOfBetFees != expectedSum {
t.Errorf("Expected sum of bet fees to be %d, got %d", expectedSum, sumOfBetFees)
}

// Create a test bet
bets := []models.Bet{
{Username: "testuser", MarketID: 1, Amount: 100, PlacedAt: time.Now()},
}
db.Create(&bets)

// Scenario 2: User has one bet, buys shares
sumOfBetFees = GetBetFees(db, user, buyBet)
expectedSum = appConfig.Economics.Betting.BetFees.BuySharesFee
if sumOfBetFees != expectedSum {
t.Errorf("Expected sum of bet fees to be %d, got %d", expectedSum, sumOfBetFees)
}

// Scenario 3: User has one bet, sells shares
sellBet := models.Bet{MarketID: 1, Amount: -1}
sumOfBetFees = GetBetFees(db, user, sellBet)
expectedSum = appConfig.Economics.Betting.BetFees.SellSharesFee
if sumOfBetFees != expectedSum {
t.Errorf("Expected sum of bet fees to be %d, got %d", expectedSum, sumOfBetFees)
}

}
13 changes: 11 additions & 2 deletions backend/handlers/bets/placebethandler.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"log"
"net/http"
betutils "socialpredict/handlers/bets/betutils"
"socialpredict/logging"
"socialpredict/middleware"
"socialpredict/models"
"socialpredict/setup"
Expand Down Expand Up @@ -49,17 +50,25 @@ func PlaceBetHandler(w http.ResponseWriter, r *http.Request) {
// Validate the request (check if market exists, if not closed/resolved, etc.)
betutils.CheckMarketStatus(db, betRequest.MarketID)

// sum up fees
sumOfBetFees := betutils.GetBetFees(db, user, betRequest)

logging.LogAnyType(sumOfBetFees, "sumOfBetFees")

// Check if the user has enough balance to place the bet
// Use the appConfig for configuration values
maximumDebtAllowed := appConfig.Economics.User.MaximumDebtAllowed

// Check if the user's balance after the bet would be lower than the allowed maximum debt
// deduct fee in case of switching sides
if user.AccountBalance-betRequest.Amount < -maximumDebtAllowed {
// deduct fees along with calculation to ensure fees can be paid.
if user.AccountBalance-betRequest.Amount-sumOfBetFees < -maximumDebtAllowed {
http.Error(w, "Insufficient balance", http.StatusBadRequest)
return
}

// make entry for user.AccountBalance
user.AccountBalance = user.AccountBalance - betRequest.Amount - sumOfBetFees

// Update the user's balance in the database
if err := db.Save(&user).Error; err != nil {
http.Error(w, "Error updating user balance: "+err.Error(), http.StatusInternalServerError)
Expand Down
1 change: 1 addition & 0 deletions backend/handlers/bets/placebethandler_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
package betshandlers
3 changes: 0 additions & 3 deletions backend/handlers/markets/marketprojectedprobability.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@ package marketshandlers

import (
"encoding/json"
"log"
"net/http"
"socialpredict/handlers/marketpublicresponse"
"socialpredict/handlers/math/probabilities/wpam"
Expand All @@ -18,8 +17,6 @@ import (
// ProjectNewProbabilityHandler handles the projection of a new probability based on a new bet.
func ProjectNewProbabilityHandler(w http.ResponseWriter, r *http.Request) {

log.Print("Activated ProjectNewProbabilityHandler.")

// Parse market ID, amount, and outcome from the URL
vars := mux.Vars(r)
marketId := vars["marketId"]
Expand Down
Loading

0 comments on commit 51b1533

Please sign in to comment.