Compare commits
No commits in common. "215a9fb3b6e47280b291e20f3d90cedf1f99ecd3" and "607e1007d06c0c842329ea44391a53907eae75ae" have entirely different histories.
215a9fb3b6
...
607e1007d0
@ -42,19 +42,34 @@ CREATE TABLE markets(
|
|||||||
end_date TIMESTAMP WITH TIME ZONE NOT NULL,
|
end_date TIMESTAMP WITH TIME ZONE NOT NULL,
|
||||||
settled_at TIMESTAMP WITH TIME ZONE,
|
settled_at TIMESTAMP WITH TIME ZONE,
|
||||||
user_id INTEGER NOT NULL REFERENCES users(id),
|
user_id INTEGER NOT NULL REFERENCES users(id),
|
||||||
invoice_id INTEGER NOT NULL UNIQUE REFERENCES invoices(id),
|
invoice_id INTEGER NOT NULL UNIQUE REFERENCES invoices(id)
|
||||||
lmsr_b FLOAT NOT NULL
|
|
||||||
);
|
);
|
||||||
|
|
||||||
|
CREATE TABLE shares(
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
market_id INTEGER NOT NULL REFERENCES markets(id),
|
||||||
|
description TEXT NOT NULL,
|
||||||
|
win BOOLEAN
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TYPE order_side AS ENUM ('BUY', 'SELL');
|
||||||
|
|
||||||
CREATE TABLE orders(
|
CREATE TABLE orders(
|
||||||
id SERIAL PRIMARY KEY,
|
id SERIAL PRIMARY KEY,
|
||||||
market_id INTEGER NOT NULL REFERENCES markets(id),
|
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
deleted_at TIMESTAMP WITH TIME ZONE,
|
||||||
|
share_id INTEGER NOT NULL REFERENCES shares(id),
|
||||||
user_id INTEGER NOT NULL REFERENCES users(id),
|
user_id INTEGER NOT NULL REFERENCES users(id),
|
||||||
|
side ORDER_SIDE NOT NULL,
|
||||||
quantity BIGINT NOT NULL,
|
quantity BIGINT NOT NULL,
|
||||||
outcome INTEGER NOT NULL,
|
price BIGINT NOT NULL,
|
||||||
invoice_id INTEGER REFERENCES invoices(id)
|
invoice_id INTEGER REFERENCES invoices(id),
|
||||||
|
order_id INTEGER REFERENCES orders(id)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
ALTER TABLE orders ADD CONSTRAINT order_price CHECK(price > 0 AND price < 100);
|
||||||
|
ALTER TABLE orders ADD CONSTRAINT order_quantity CHECK(quantity > 0);
|
||||||
|
|
||||||
CREATE TABLE withdrawals(
|
CREATE TABLE withdrawals(
|
||||||
id SERIAL PRIMARY KEY,
|
id SERIAL PRIMARY KEY,
|
||||||
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
@ -14,8 +14,7 @@ services:
|
|||||||
- delphi:/var/lib/postgresql/data
|
- delphi:/var/lib/postgresql/data
|
||||||
- ./db/init.sql:/docker-entrypoint-initdb.d/init.sql
|
- ./db/init.sql:/docker-entrypoint-initdb.d/init.sql
|
||||||
- ./db/schema.sql:/docker-entrypoint-initdb.d/schema.sql
|
- ./db/schema.sql:/docker-entrypoint-initdb.d/schema.sql
|
||||||
# for some reason this can't be mounted on first run
|
- ./postgresql.conf:/var/lib/postgresql/data/postgresql.conf # for some reason this can't be mounted on first run
|
||||||
- ./db/postgresql.conf:/var/lib/postgresql/data/postgresql.conf
|
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
delphi:
|
delphi:
|
@ -1,51 +0,0 @@
|
|||||||
package lmsr
|
|
||||||
|
|
||||||
import "math"
|
|
||||||
|
|
||||||
// Robin Hanson's Logarithmic Market Scoring Rule (LMSR) market maker
|
|
||||||
// https://mason.gmu.edu/~rhanson/mktscore.pdf
|
|
||||||
|
|
||||||
// "The market maker keeps track of how many shares have been purchased by
|
|
||||||
// traders in total so far for each outcome: that is, the number of shares
|
|
||||||
// outstanding for each outcome. Let q1 and q2 be the number (“quantity”)
|
|
||||||
// of shares outstanding for each of the two outcomes. The market maker also
|
|
||||||
// maintains a cost function C(q1,q2) which records how much money traders
|
|
||||||
// have collectively spent so far, and depends only on the number of shares
|
|
||||||
// outstanding, q1 and q2. For LMSR, the cost function is [this]:
|
|
||||||
//
|
|
||||||
// <see code>
|
|
||||||
// ...
|
|
||||||
//
|
|
||||||
// The parameter “b” controls the maximum possible amount of money the market
|
|
||||||
// maker can lose (which happens to be b*ln2 in the two-outcome case). The
|
|
||||||
// larger “b” is, the more money the market maker can lose. But a larger “b”
|
|
||||||
// also means the market has more liquidity or depth, meaning that traders can
|
|
||||||
// buy more shares at or near the current price without causing massive price swings."
|
|
||||||
//
|
|
||||||
// -- David Pennock, http://blog.oddhead.com/2006/10/30/implementing-hansons-market-maker/
|
|
||||||
func binaryLMSRcost(b float64, q1 float64, q2 float64) float64 {
|
|
||||||
return b * math.Log(math.Pow(math.E, q1/b)+math.Pow(math.E, q2/b))
|
|
||||||
}
|
|
||||||
|
|
||||||
func Quote(b float64, q1 int, q2 int, dq1 int) float64 {
|
|
||||||
// q1 must always be the quantity of shares that are bought / sold.
|
|
||||||
// If you want to change q2, you need to swap q1 and q2 as input arguments.
|
|
||||||
fq1 := float64(q1)
|
|
||||||
fq2 := float64(q2)
|
|
||||||
fdq1 := float64(dq1)
|
|
||||||
return binaryLMSRcost(b, fq1+fdq1, fq2) - binaryLMSRcost(b, fq1, fq2)
|
|
||||||
}
|
|
||||||
|
|
||||||
func Price(b float64, q1 int, q2 int) float64 {
|
|
||||||
//
|
|
||||||
// q1 must always be the quantity of shares that are bought / sold.
|
|
||||||
// If you want to change q2, you need to swap q1 and q2 as input arguments.
|
|
||||||
//
|
|
||||||
// An infinitesimal share would cost this much:
|
|
||||||
//
|
|
||||||
// p = e^{q1/b} / ( e^{q1/b} + e^{q2/b} )
|
|
||||||
//
|
|
||||||
// However, to find the price of buying one full share
|
|
||||||
// we need to use the cost function.
|
|
||||||
return Quote(b, q1, q2, 1)
|
|
||||||
}
|
|
@ -71,7 +71,7 @@ func (lnd *LNDClient) CheckInvoices(db *db.DB) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if !inv.State.IsFinal() {
|
if !inv.State.IsFinal() {
|
||||||
log.Printf("invoice pending: %s expiry=%s", h, time.Until(expiresAt))
|
log.Printf("invoice pending: %s %s", h, time.Until(expiresAt))
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -78,15 +78,6 @@
|
|||||||
padding: 0 0.25em;
|
padding: 0 0.25em;
|
||||||
}
|
}
|
||||||
|
|
||||||
button:disabled {
|
|
||||||
color: var(--muted);
|
|
||||||
border-color: var(--muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
.htmx-request {
|
|
||||||
color: var(--muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
h1 {
|
h1 {
|
||||||
font-size: 24px
|
font-size: 24px
|
||||||
}
|
}
|
||||||
@ -96,10 +87,6 @@
|
|||||||
aspect-ratio: 560/315;
|
aspect-ratio: 560/315;
|
||||||
}
|
}
|
||||||
|
|
||||||
input {
|
|
||||||
color: var(--black);
|
|
||||||
}
|
|
||||||
|
|
||||||
.text-muted {
|
.text-muted {
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
}
|
}
|
||||||
@ -124,7 +111,7 @@
|
|||||||
color: var(--fg-success);
|
color: var(--fg-success);
|
||||||
}
|
}
|
||||||
|
|
||||||
.neon.success:hover, .neon.success.active {
|
.neon.success:hover {
|
||||||
background-color: var(--fg-success);
|
background-color: var(--fg-success);
|
||||||
color: var(--white);
|
color: var(--white);
|
||||||
}
|
}
|
||||||
@ -134,7 +121,7 @@
|
|||||||
color: var(--fg-error);
|
color: var(--fg-error);
|
||||||
}
|
}
|
||||||
|
|
||||||
.neon.error:hover, .neon.error.active {
|
.neon.error:hover {
|
||||||
background-color: var(--fg-error);
|
background-color: var(--fg-error);
|
||||||
color: var(--white);
|
color: var(--white);
|
||||||
}
|
}
|
||||||
|
File diff suppressed because one or more lines are too long
@ -57,7 +57,6 @@ func HandleInvoice(sc context.Context) echo.HandlerFunc {
|
|||||||
|
|
||||||
var (
|
var (
|
||||||
marketRegexp = regexp.MustCompile("^create market (?P<id>[0-9]+)$")
|
marketRegexp = regexp.MustCompile("^create market (?P<id>[0-9]+)$")
|
||||||
orderRegexp = regexp.MustCompile("^create order [0-9]+ for market (?P<id>[0-9]+)$")
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func toRedirectUrl(description string) templ.SafeURL {
|
func toRedirectUrl(description string) templ.SafeURL {
|
||||||
@ -66,9 +65,5 @@ func toRedirectUrl(description string) templ.SafeURL {
|
|||||||
marketId := m[marketRegexp.SubexpIndex("id")]
|
marketId := m[marketRegexp.SubexpIndex("id")]
|
||||||
return templ.SafeURL(fmt.Sprintf("/market/%s", marketId))
|
return templ.SafeURL(fmt.Sprintf("/market/%s", marketId))
|
||||||
}
|
}
|
||||||
if m = orderRegexp.FindStringSubmatch(description); m != nil {
|
|
||||||
marketId := m[marketRegexp.SubexpIndex("id")]
|
|
||||||
return templ.SafeURL(fmt.Sprintf("/market/%s", marketId))
|
|
||||||
}
|
|
||||||
return "/"
|
return "/"
|
||||||
}
|
}
|
||||||
|
@ -3,12 +3,9 @@ package handler
|
|||||||
import (
|
import (
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"fmt"
|
"fmt"
|
||||||
"math"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.ekzyis.com/ekzyis/delphi.market/lib/lmsr"
|
|
||||||
"git.ekzyis.com/ekzyis/delphi.market/server/router/context"
|
"git.ekzyis.com/ekzyis/delphi.market/server/router/context"
|
||||||
"git.ekzyis.com/ekzyis/delphi.market/server/router/pages"
|
"git.ekzyis.com/ekzyis/delphi.market/server/router/pages"
|
||||||
"git.ekzyis.com/ekzyis/delphi.market/server/router/pages/components"
|
"git.ekzyis.com/ekzyis/delphi.market/server/router/pages/components"
|
||||||
@ -33,14 +30,7 @@ func HandleCreate(sc context.Context) echo.HandlerFunc {
|
|||||||
endDate = c.FormValue("end_date")
|
endDate = c.FormValue("end_date")
|
||||||
hash lntypes.Hash
|
hash lntypes.Hash
|
||||||
paymentRequest string
|
paymentRequest string
|
||||||
cost = lnwire.MilliSatoshi(10_000e3) // creating a market costs 10k sats
|
cost = lnwire.MilliSatoshi(1000e3)
|
||||||
|
|
||||||
// The cost is used to fund the market.
|
|
||||||
// Maximum possible amount of money the market maker can lose is b*ln2.
|
|
||||||
// This means if we can only payout as many sats as we paid for the market,
|
|
||||||
// we need to solve for b: b = cost / ln2
|
|
||||||
b = (float64(cost) / 1000) / math.Log(2)
|
|
||||||
|
|
||||||
expiry = int64(600)
|
expiry = int64(600)
|
||||||
expiresAt = time.Now().Add(time.Second * time.Duration(expiry))
|
expiresAt = time.Now().Add(time.Second * time.Duration(expiry))
|
||||||
invoiceId int
|
invoiceId int
|
||||||
@ -72,10 +62,10 @@ func HandleCreate(sc context.Context) echo.HandlerFunc {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if err = tx.QueryRowContext(ctx, ""+
|
if err = tx.QueryRowContext(ctx, ""+
|
||||||
"INSERT INTO markets (question, description, end_date, user_id, invoice_id, lmsr_b) "+
|
"INSERT INTO markets (question, description, end_date, user_id, invoice_id) "+
|
||||||
"VALUES ($1, $2, $3, $4, $5, $6) "+
|
"VALUES ($1, $2, $3, $4, $5) "+
|
||||||
"RETURNING id",
|
"RETURNING id",
|
||||||
question, description, endDate, u.Id, invoiceId, b).Scan(&marketId); err != nil {
|
question, description, endDate, u.Id, invoiceId).Scan(&marketId); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -99,224 +89,29 @@ func HandleCreate(sc context.Context) echo.HandlerFunc {
|
|||||||
func HandleMarket(sc context.Context) echo.HandlerFunc {
|
func HandleMarket(sc context.Context) echo.HandlerFunc {
|
||||||
return func(c echo.Context) error {
|
return func(c echo.Context) error {
|
||||||
var (
|
var (
|
||||||
db = sc.Db
|
db = sc.Db
|
||||||
ctx = c.Request().Context()
|
ctx = c.Request().Context()
|
||||||
u = types.User{}
|
id = c.Param("id")
|
||||||
id = c.Param("id")
|
m = types.Market{}
|
||||||
quantity = c.QueryParam("q")
|
u = types.User{}
|
||||||
q int64
|
err error
|
||||||
m = types.Market{}
|
|
||||||
mU = types.User{}
|
|
||||||
l = types.LMSR{}
|
|
||||||
total float64
|
|
||||||
quote0 = types.MarketQuote{}
|
|
||||||
quote1 = types.MarketQuote{}
|
|
||||||
uQ0 int
|
|
||||||
uQ1 int
|
|
||||||
err error
|
|
||||||
)
|
)
|
||||||
|
|
||||||
if c.Get("session") != nil {
|
|
||||||
u = c.Get("session").(types.User)
|
|
||||||
} else {
|
|
||||||
u.Id = -1
|
|
||||||
}
|
|
||||||
|
|
||||||
if quantity == "" {
|
|
||||||
q = 1
|
|
||||||
} else if q, err = strconv.ParseInt(quantity, 10, 64); err != nil {
|
|
||||||
return echo.NewHTTPError(http.StatusBadRequest, "q must be integer")
|
|
||||||
}
|
|
||||||
|
|
||||||
if err = db.QueryRowContext(ctx, ""+
|
if err = db.QueryRowContext(ctx, ""+
|
||||||
"SELECT m.id, m.question, m.description, m.created_at, m.end_date, m.lmsr_b, "+
|
"SELECT m.id, m.question, m.description, m.created_at, m.end_date, "+
|
||||||
"u.id, u.name, u.created_at, u.ln_pubkey, u.nostr_pubkey, u.msats "+
|
"u.id, u.name, u.created_at, u.ln_pubkey, u.nostr_pubkey, msats "+
|
||||||
"FROM markets m "+
|
"FROM markets m JOIN users u ON m.user_id = u.id "+
|
||||||
"JOIN users u ON m.user_id = u.id "+
|
"WHERE m.id = $1", id).Scan(
|
||||||
"JOIN invoices i ON m.invoice_id = i.id "+
|
&m.Id, &m.Question, &m.Description, &m.CreatedAt, &m.EndDate,
|
||||||
"WHERE m.id = $1 AND i.confirmed_at IS NOT NULL", id).Scan(
|
&u.Id, &u.Name, &u.CreatedAt, &u.LnPubkey, &u.NostrPubkey, &u.Msats); err != nil {
|
||||||
&m.Id, &m.Question, &m.Description, &m.CreatedAt, &m.EndDate, &l.B,
|
|
||||||
&mU.Id, &mU.Name, &mU.CreatedAt, &mU.LnPubkey, &mU.NostrPubkey, &mU.Msats); err != nil {
|
|
||||||
if err == sql.ErrNoRows {
|
|
||||||
return echo.NewHTTPError(http.StatusNotFound)
|
|
||||||
}
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
m.User = mU
|
|
||||||
|
|
||||||
if err = db.QueryRowContext(ctx, ""+
|
|
||||||
"SELECT "+
|
|
||||||
"COALESCE(SUM(o.quantity) FILTER(WHERE o.outcome = 0), 0) AS q1, "+
|
|
||||||
"COALESCE(SUM(o.quantity) FILTER(WHERE o.outcome = 1), 0) AS q2, "+
|
|
||||||
"COALESCE(SUM(o.quantity) FILTER(WHERE o.outcome = 0 AND o.user_id = $2), 0) AS uq1, "+
|
|
||||||
"COALESCE(SUM(o.quantity) FILTER(WHERE o.outcome = 1 AND o.user_id = $2), 0) AS uq2 "+
|
|
||||||
"FROM orders o "+
|
|
||||||
"JOIN markets m ON o.market_id = m.id "+
|
|
||||||
"JOIN invoices i ON o.invoice_id = i.id "+
|
|
||||||
// QUESTION: Should unpaid orders contribute to quantity or not?
|
|
||||||
//
|
|
||||||
// The answer is relevant for concurrent orders:
|
|
||||||
// If they do, one can artificially increase the price for others
|
|
||||||
// by creating a lot of pending orders.
|
|
||||||
// If they don't, one can buy infinite amount of shares at the same price
|
|
||||||
// by creating a lot of small but concurrent orders.
|
|
||||||
// I think this means that pending order must be scoped to a user
|
|
||||||
// but this isn't sybil resistant.
|
|
||||||
//
|
|
||||||
// For now, we will ignore pending orders.
|
|
||||||
"WHERE o.market_id = $1 AND i.confirmed_at IS NOT NULL", id, u.Id).Scan(
|
|
||||||
&l.Q1, &l.Q2, &uQ0, &uQ1); err != nil {
|
|
||||||
if err == sql.ErrNoRows {
|
if err == sql.ErrNoRows {
|
||||||
return echo.NewHTTPError(http.StatusNotFound)
|
return echo.NewHTTPError(http.StatusNotFound)
|
||||||
}
|
}
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
total = lmsr.Quote(l.B, l.Q1, l.Q2, int(q))
|
m.User = u
|
||||||
quote0 = types.MarketQuote{
|
|
||||||
Outcome: 0,
|
|
||||||
AvgPrice: total / float64(q),
|
|
||||||
TotalPrice: total,
|
|
||||||
Reward: float64(q) - total,
|
|
||||||
}
|
|
||||||
|
|
||||||
total = lmsr.Quote(l.B, l.Q2, l.Q1, int(q))
|
return pages.Market(m).Render(context.RenderContext(sc, c), c.Response().Writer)
|
||||||
quote1 = types.MarketQuote{
|
|
||||||
Outcome: 1,
|
|
||||||
AvgPrice: total / float64(q),
|
|
||||||
TotalPrice: total,
|
|
||||||
Reward: float64(q) - total,
|
|
||||||
}
|
|
||||||
|
|
||||||
return pages.Market(m, quote0, quote1, uQ0, uQ1).Render(context.RenderContext(sc, c), c.Response().Writer)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func HandleOrder(sc context.Context) echo.HandlerFunc {
|
|
||||||
return func(c echo.Context) error {
|
|
||||||
var (
|
|
||||||
db = sc.Db
|
|
||||||
lnd = sc.Lnd
|
|
||||||
tx *sql.Tx
|
|
||||||
ctx = c.Request().Context()
|
|
||||||
u = c.Get("session").(types.User)
|
|
||||||
id = c.Param("id")
|
|
||||||
quantity = c.FormValue("q")
|
|
||||||
outcome = c.FormValue("o")
|
|
||||||
q int64
|
|
||||||
o int64
|
|
||||||
m = types.Market{}
|
|
||||||
mU = types.User{}
|
|
||||||
l = types.LMSR{}
|
|
||||||
totalF float64
|
|
||||||
total int
|
|
||||||
hash lntypes.Hash
|
|
||||||
paymentRequest string
|
|
||||||
expiry = int64(60)
|
|
||||||
expiresAt = time.Now().Add(time.Second * time.Duration(expiry))
|
|
||||||
invoiceId int
|
|
||||||
invDescription string
|
|
||||||
orderId int
|
|
||||||
qr templ.Component
|
|
||||||
err error
|
|
||||||
)
|
|
||||||
|
|
||||||
if quantity == "" {
|
|
||||||
return echo.NewHTTPError(http.StatusBadRequest, "q must be given")
|
|
||||||
} else if q, err = strconv.ParseInt(quantity, 10, 64); err != nil {
|
|
||||||
return echo.NewHTTPError(http.StatusBadRequest, "q must be integer")
|
|
||||||
}
|
|
||||||
|
|
||||||
if outcome == "" {
|
|
||||||
return echo.NewHTTPError(http.StatusBadRequest, "o must be given")
|
|
||||||
} else if o, err = strconv.ParseInt(outcome, 10, 64); err != nil {
|
|
||||||
return echo.NewHTTPError(http.StatusBadRequest, "o must be integer")
|
|
||||||
}
|
|
||||||
if o < 0 && o > 1 {
|
|
||||||
return echo.NewHTTPError(http.StatusBadRequest, "o must be 0 or 1")
|
|
||||||
}
|
|
||||||
|
|
||||||
// TODO: refactor since this uses same queries as function above
|
|
||||||
if err = db.QueryRowContext(ctx, ""+
|
|
||||||
"SELECT m.id, m.question, m.description, m.created_at, m.end_date, m.lmsr_b, "+
|
|
||||||
"u.id, u.name, u.created_at, u.ln_pubkey, u.nostr_pubkey, u.msats "+
|
|
||||||
"FROM markets m "+
|
|
||||||
"JOIN users u ON m.user_id = u.id "+
|
|
||||||
"JOIN invoices i ON m.invoice_id = i.id "+
|
|
||||||
"WHERE m.id = $1 AND i.confirmed_at IS NOT NULL", id).Scan(
|
|
||||||
&m.Id, &m.Question, &m.Description, &m.CreatedAt, &m.EndDate, &l.B,
|
|
||||||
&mU.Id, &mU.Name, &mU.CreatedAt, &mU.LnPubkey, &mU.NostrPubkey, &mU.Msats); err != nil {
|
|
||||||
if err == sql.ErrNoRows {
|
|
||||||
return echo.NewHTTPError(http.StatusNotFound)
|
|
||||||
}
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
m.User = mU
|
|
||||||
|
|
||||||
if err = db.QueryRowContext(ctx, ""+
|
|
||||||
"SELECT "+
|
|
||||||
"COALESCE(SUM(o.quantity) FILTER(WHERE o.outcome = 0), 0) AS q1, "+
|
|
||||||
"COALESCE(SUM(o.quantity) FILTER(WHERE o.outcome = 1), 0) AS q2 "+
|
|
||||||
"FROM orders o "+
|
|
||||||
"JOIN markets m ON o.market_id = m.id "+
|
|
||||||
"JOIN invoices i ON o.invoice_id = i.id "+
|
|
||||||
"WHERE o.market_id = $1 AND i.confirmed_at IS NOT NULL", id).Scan(
|
|
||||||
&l.Q1, &l.Q2); err != nil {
|
|
||||||
if err == sql.ErrNoRows {
|
|
||||||
return echo.NewHTTPError(http.StatusNotFound)
|
|
||||||
}
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
if o == 0 {
|
|
||||||
totalF = lmsr.Quote(l.B, l.Q1, l.Q2, int(q))
|
|
||||||
} else if o == 1 {
|
|
||||||
totalF = lmsr.Quote(l.B, l.Q2, l.Q1, int(q))
|
|
||||||
}
|
|
||||||
|
|
||||||
total = int(math.Round(totalF * 1000))
|
|
||||||
|
|
||||||
if tx, err = db.BeginTx(ctx, &sql.TxOptions{Isolation: sql.LevelReadCommitted}); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
if hash, paymentRequest, err = lnd.Client.AddInvoice(ctx,
|
|
||||||
&invoicesrpc.AddInvoiceData{
|
|
||||||
Value: lnwire.MilliSatoshi(total),
|
|
||||||
Expiry: expiry,
|
|
||||||
}); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
if err = tx.QueryRowContext(ctx, ""+
|
|
||||||
"INSERT INTO invoices (user_id, msats, hash, bolt11, expires_at) "+
|
|
||||||
"VALUES ($1, $2, $3, $4, $5) "+
|
|
||||||
"RETURNING id",
|
|
||||||
u.Id, total, hash.String(), paymentRequest, expiresAt).Scan(&invoiceId); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
if err = tx.QueryRowContext(ctx, ""+
|
|
||||||
"INSERT INTO orders (market_id, user_id, quantity, outcome, invoice_id) "+
|
|
||||||
"VALUES ($1, $2, $3, $4, $5) "+
|
|
||||||
"RETURNING id",
|
|
||||||
id, u.Id, q, o, invoiceId).Scan(&orderId); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
invDescription = fmt.Sprintf("create order %d for market %s", orderId, id)
|
|
||||||
if _, err = tx.ExecContext(ctx, ""+
|
|
||||||
"UPDATE invoices SET description = $1 WHERE id = $2",
|
|
||||||
invDescription, invoiceId); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
if err = tx.Commit(); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
qr = components.Invoice(hash.String(), paymentRequest, total, int(expiry), false, toRedirectUrl(invDescription))
|
|
||||||
|
|
||||||
return components.Modal(qr).Render(context.RenderContext(sc, c), c.Response().Writer)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -24,7 +24,6 @@ templ Head() {
|
|||||||
}'
|
}'
|
||||||
/>
|
/>
|
||||||
<script src="/js/htmx.js" integrity="sha384-Xh+GLLi0SMFPwtHQjT72aPG19QvKB8grnyRbYBNIdHWc2NkCrz65jlU7YrzO6qRp" crossorigin="anonymous"></script>
|
<script src="/js/htmx.js" integrity="sha384-Xh+GLLi0SMFPwtHQjT72aPG19QvKB8grnyRbYBNIdHWc2NkCrz65jlU7YrzO6qRp" crossorigin="anonymous"></script>
|
||||||
<script defer src="/js/alpine.js" crossorigin="anonymous"></script>
|
|
||||||
if ctx.Value(c.EnvContextKey) == "development" {
|
if ctx.Value(c.EnvContextKey) == "development" {
|
||||||
<script defer src="/js/hotreload.js"></script>
|
<script defer src="/js/hotreload.js"></script>
|
||||||
}
|
}
|
||||||
|
@ -1,67 +0,0 @@
|
|||||||
package components
|
|
||||||
|
|
||||||
import (
|
|
||||||
"git.ekzyis.com/ekzyis/delphi.market/types"
|
|
||||||
|
|
||||||
"fmt"
|
|
||||||
"strconv"
|
|
||||||
)
|
|
||||||
|
|
||||||
templ MarketForm(m types.Market, outcome int, q types.MarketQuote, uQ int) {
|
|
||||||
<form
|
|
||||||
id={ formId(outcome) }
|
|
||||||
autocomplete="off"
|
|
||||||
class="grid grid-cols-2 gap-3"
|
|
||||||
hx-post={ fmt.Sprintf("/market/%d/order", m.Id) }
|
|
||||||
hx-target="#modal"
|
|
||||||
hx-swap="outerHTML"
|
|
||||||
hx-select="#modal"
|
|
||||||
>
|
|
||||||
<input type="hidden" name="o" value={ fmt.Sprint(outcome) } />
|
|
||||||
<div class="none col-span-2 htmx-request" />
|
|
||||||
<label for="p">avg price per share:</label>
|
|
||||||
<div id="p">{formatPrice(q.AvgPrice)}</div>
|
|
||||||
<label for="q">how many?</label>
|
|
||||||
<input
|
|
||||||
id={ inputId(outcome) }
|
|
||||||
name="q"
|
|
||||||
class="text-black px-1"
|
|
||||||
type="number"
|
|
||||||
autofocus
|
|
||||||
hx-get={ fmt.Sprintf("/market/%d", m.Id) }
|
|
||||||
hx-replace-url="true"
|
|
||||||
hx-target={ fmt.Sprintf("#%s", formId(outcome)) }
|
|
||||||
hx-swap="outerHTML"
|
|
||||||
hx-select={ fmt.Sprintf("#%s", formId(outcome)) }
|
|
||||||
hx-trigger="input changed delay:1s"
|
|
||||||
hx-preserve
|
|
||||||
hx-disabled-elt="next button"
|
|
||||||
hx-indicator={ hxIndicator(outcome) }
|
|
||||||
/>
|
|
||||||
<label for="total">you pay:</label>
|
|
||||||
<div id="total">{formatPrice(q.TotalPrice)}</div>
|
|
||||||
<label for="reward">{ "if you win:" }</label>
|
|
||||||
<div id="reward">+{formatPrice(q.Reward)}</div>
|
|
||||||
<label for="uQ">you have:</label>
|
|
||||||
<div id="uQ">{ fmt.Sprint(uQ) }</div>
|
|
||||||
<button type="submit" class="col-span-2">submit</button>
|
|
||||||
</form>
|
|
||||||
}
|
|
||||||
|
|
||||||
func formId (outcome int) string {
|
|
||||||
return fmt.Sprintf("outcome-%d-form", outcome)
|
|
||||||
}
|
|
||||||
|
|
||||||
func inputId (outcome int) string {
|
|
||||||
return fmt.Sprintf("outcome-%d-q", outcome)
|
|
||||||
}
|
|
||||||
|
|
||||||
func hxIndicator (outcome int) string {
|
|
||||||
return fmt.Sprintf(
|
|
||||||
"#%s>#p, #%s>#total, #%s>#reward",
|
|
||||||
formId(outcome), formId(outcome), formId(outcome))
|
|
||||||
}
|
|
||||||
|
|
||||||
func formatPrice(p float64) string {
|
|
||||||
return fmt.Sprintf("%v sats", strconv.FormatFloat(p, 'f', 3, 64))
|
|
||||||
}
|
|
@ -8,13 +8,10 @@ import (
|
|||||||
|
|
||||||
// TODO: Add countdown? Use or at least show somewhere precise timestamps?
|
// TODO: Add countdown? Use or at least show somewhere precise timestamps?
|
||||||
|
|
||||||
templ Market(m types.Market, q0 types.MarketQuote, q1 types.MarketQuote, uQ0 int, uQ1 int) {
|
templ Market(m types.Market) {
|
||||||
<html>
|
<html>
|
||||||
@components.Head()
|
@components.Head()
|
||||||
<body
|
<body class="container">
|
||||||
x-data="{ outcome: undefined }"
|
|
||||||
class="container"
|
|
||||||
hx-preserve>
|
|
||||||
@components.Nav()
|
@components.Nav()
|
||||||
<div id="content" class="flex flex-col">
|
<div id="content" class="flex flex-col">
|
||||||
<small>
|
<small>
|
||||||
@ -24,42 +21,16 @@ templ Market(m types.Market, q0 types.MarketQuote, q1 types.MarketQuote, uQ0 int
|
|||||||
<div class="text-center text-muted my-1">{humanize.Time(m.EndDate)}</div>
|
<div class="text-center text-muted my-1">{humanize.Time(m.EndDate)}</div>
|
||||||
<div class="text-center text-muted my-1"></div>
|
<div class="text-center text-muted my-1"></div>
|
||||||
<blockquote cite="ekzyis" class="p-4 mb-4 border-s-4 border-muted">
|
<blockquote cite="ekzyis" class="p-4 mb-4 border-s-4 border-muted">
|
||||||
if m.Description != "" {
|
{ m.Description }
|
||||||
m.Description
|
|
||||||
} else {
|
|
||||||
<empty>
|
|
||||||
}
|
|
||||||
<div class="text-muted text-right pt-4">― {m.User.Name}, { humanize.Time(m.CreatedAt) }</div>
|
<div class="text-muted text-right pt-4">― {m.User.Name}, { humanize.Time(m.CreatedAt) }</div>
|
||||||
</blockquote>
|
</blockquote>
|
||||||
<div class="flex flex-col justify-center my-1">
|
<div class="flex justify-center my-1">
|
||||||
<div class="flex flex-row justify-center">
|
<button class="neon success mx-1">BET YES</button>
|
||||||
<button
|
<button class="neon error mx-1">BET NO</button>
|
||||||
class="neon success mx-1"
|
|
||||||
x-on:click="outcome = outcome === 1 ? undefined : 1"
|
|
||||||
:class="{ 'active' : outcome === 1 }"
|
|
||||||
>
|
|
||||||
BET YES
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
class="neon error mx-1"
|
|
||||||
x-on:click="outcome = outcome === 0 ? undefined : 0"
|
|
||||||
:class="{ 'active' : outcome === 0 }"
|
|
||||||
>
|
|
||||||
BET NO
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div class="mx-auto my-5" x-show="outcome === 1">
|
|
||||||
@components.MarketForm(m, 1, q1, uQ1)
|
|
||||||
</div>
|
|
||||||
<div class="mx-auto my-5" x-show="outcome === 0">
|
|
||||||
@components.MarketForm(m, 0, q0, uQ0)
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
@components.Modal(nil)
|
@components.Modal(nil)
|
||||||
@components.Footer()
|
@components.Footer()
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -17,7 +17,6 @@ func Init(e *echo.Echo, sc Context) {
|
|||||||
e.GET("/create", handler.HandleIndex(sc))
|
e.GET("/create", handler.HandleIndex(sc))
|
||||||
e.POST("/create", handler.HandleCreate(sc), middleware.SessionGuard(sc))
|
e.POST("/create", handler.HandleCreate(sc), middleware.SessionGuard(sc))
|
||||||
e.GET("/market/:id", handler.HandleMarket(sc))
|
e.GET("/market/:id", handler.HandleMarket(sc))
|
||||||
e.POST("/market/:id/order", handler.HandleOrder(sc), middleware.SessionGuard(sc))
|
|
||||||
e.GET("/about", handler.HandleAbout(sc))
|
e.GET("/about", handler.HandleAbout(sc))
|
||||||
|
|
||||||
e.GET("/login", handler.HandleAuth(sc, "login"))
|
e.GET("/login", handler.HandleAuth(sc, "login"))
|
||||||
|
@ -37,16 +37,3 @@ type Market struct {
|
|||||||
CreatedAt time.Time
|
CreatedAt time.Time
|
||||||
EndDate time.Time
|
EndDate time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
type LMSR struct {
|
|
||||||
B float64
|
|
||||||
Q1 int
|
|
||||||
Q2 int
|
|
||||||
}
|
|
||||||
|
|
||||||
type MarketQuote struct {
|
|
||||||
Outcome int
|
|
||||||
AvgPrice float64
|
|
||||||
TotalPrice float64
|
|
||||||
Reward float64
|
|
||||||
}
|
|
||||||
|
Loading…
x
Reference in New Issue
Block a user