Update market form on input change

This commit is contained in:
ekzyis 2024-08-25 18:51:10 -05:00
parent bd7b2715bd
commit df7726e313
9 changed files with 281 additions and 50 deletions

View File

@ -78,6 +78,15 @@
padding: 0 0.25em;
}
button:disabled {
color: var(--muted);
border-color: var(--muted);
}
.htmx-request {
color: var(--muted);
}
h1 {
font-size: 24px
}
@ -87,6 +96,10 @@
aspect-ratio: 560/315;
}
input {
color: var(--black);
}
.text-muted {
color: var(--muted);
}
@ -111,7 +124,7 @@
color: var(--fg-success);
}
.neon.success:hover {
.neon.success:hover, .neon.success.active {
background-color: var(--fg-success);
color: var(--white);
}
@ -121,7 +134,7 @@
color: var(--fg-error);
}
.neon.error:hover {
.neon.error:hover, .neon.error.active {
background-color: var(--fg-error);
color: var(--white);
}

5
public/js/alpine.js Normal file

File diff suppressed because one or more lines are too long

View File

@ -57,6 +57,7 @@ func HandleInvoice(sc context.Context) echo.HandlerFunc {
var (
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 {
@ -65,5 +66,9 @@ func toRedirectUrl(description string) templ.SafeURL {
marketId := m[marketRegexp.SubexpIndex("id")]
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 "/"
}

View File

@ -5,6 +5,7 @@ import (
"fmt"
"math"
"net/http"
"strconv"
"time"
"git.ekzyis.com/ekzyis/delphi.market/lib/lmsr"
@ -98,15 +99,26 @@ func HandleCreate(sc context.Context) echo.HandlerFunc {
func HandleMarket(sc context.Context) echo.HandlerFunc {
return func(c echo.Context) error {
var (
db = sc.Db
ctx = c.Request().Context()
id = c.Param("id")
m = types.Market{}
u = types.User{}
l = types.LSMR{}
err error
db = sc.Db
ctx = c.Request().Context()
id = c.Param("id")
quantity = c.QueryParam("q")
q int64
m = types.Market{}
u = types.User{}
l = types.LMSR{}
total float64
quote0 = types.MarketQuote{}
quote1 = types.MarketQuote{}
err error
)
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, ""+
"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 "+
@ -149,41 +161,151 @@ func HandleMarket(sc context.Context) echo.HandlerFunc {
return err
}
return pages.Market(m, types.MarketP{
Pyes: lmsr.Price(l.B, l.Q2, l.Q1),
Pno: lmsr.Price(l.B, l.Q1, l.Q2), // prices
total = lmsr.Quote(l.B, l.Q1, l.Q2, int(q))
quote0 = types.MarketQuote{
Outcome: 0,
AvgPrice: total / float64(q),
TotalPrice: total,
Reward: float64(q) - total,
}
}).Render(context.RenderContext(sc, c), c.Response().Writer)
total = lmsr.Quote(l.B, l.Q2, l.Q1, int(q))
quote1 = types.MarketQuote{
Outcome: 1,
AvgPrice: total / float64(q),
TotalPrice: total,
Reward: float64(q) - total,
}
return pages.Market(m, quote0, quote1).Render(context.RenderContext(sc, c), c.Response().Writer)
}
}
func GetPrice(sc context.Context) echo.HandlerFunc {
func HandleOrder(sc context.Context) echo.HandlerFunc {
return func(c echo.Context) error {
var (
db = sc.Db
ctx = c.Request().Context()
id = c.Param("id")
m = types.Market{}
u = types.User{}
err error
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, "+
"u.id, u.name, u.created_at, u.ln_pubkey, u.nostr_pubkey, msats "+
"FROM markets m JOIN users u ON m.user_id = u.id "+
"WHERE m.id = $1", id).Scan(
&m.Id, &m.Question, &m.Description, &m.CreatedAt, &m.EndDate,
&u.Id, &u.Name, &u.CreatedAt, &u.LnPubkey, &u.NostrPubkey, &u.Msats); err != nil {
"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 "+
"COUNT(o.quantity) FILTER(WHERE o.outcome = 0) AS q1, "+
"COUNT(o.quantity) FILTER(WHERE o.outcome = 1) 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
}
m.User = u
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))
}
return nil
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)
}
}

View File

@ -24,6 +24,7 @@ templ Head() {
}'
/>
<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" {
<script defer src="/js/hotreload.js"></script>
}

View File

@ -0,0 +1,65 @@
package components
import (
"git.ekzyis.com/ekzyis/delphi.market/types"
"fmt"
"strconv"
)
templ MarketForm(m types.Market, outcome int, q types.MarketQuote) {
<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>
<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))
}

View File

@ -4,16 +4,17 @@ import (
"git.ekzyis.com/ekzyis/delphi.market/server/router/pages/components"
"git.ekzyis.com/ekzyis/delphi.market/types"
"github.com/dustin/go-humanize"
"strconv"
"fmt"
)
// TODO: Add countdown? Use or at least show somewhere precise timestamps?
templ Market(m types.Market, p types.MarketP) {
templ Market(m types.Market, q0 types.MarketQuote, q1 types.MarketQuote) {
<html>
@components.Head()
<body class="container">
<body
x-data="{ outcome: undefined }"
class="container"
hx-preserve>
@components.Nav()
<div id="content" class="flex flex-col">
<small>
@ -23,13 +24,38 @@ templ Market(m types.Market, p types.MarketP) {
<div class="text-center text-muted my-1">{humanize.Time(m.EndDate)}</div>
<div class="text-center text-muted my-1"></div>
<blockquote cite="ekzyis" class="p-4 mb-4 border-s-4 border-muted">
{ m.Description }
if m.Description != "" {
m.Description
} else {
&lt;empty&gt;
}
<div class="text-muted text-right pt-4">― {m.User.Name}, { humanize.Time(m.CreatedAt) }</div>
</blockquote>
<div class="flex justify-center my-1">
<button class="neon success mx-1">BET YES</button>
<button class="neon error mx-1">BET NO</button>
<div class="flex flex-col justify-center my-1">
<div class="flex flex-row justify-center">
<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)
</div>
<div class="mx-auto my-5" x-show="outcome === 0">
@components.MarketForm(m, 0, q0)
</div>
</div>
</div>
@components.Modal(nil)
@components.Footer()
@ -37,11 +63,3 @@ templ Market(m types.Market, p types.MarketP) {
</html>
}
func formatPrice(p float64) string {
return fmt.Sprintf("%v msats", strconv.FormatInt(pToPrice(p), 10))
}
func pToPrice(p float64) int64 {
// 0.513 means 513 msats
return int64(p * 1e3)
}

View File

@ -17,6 +17,7 @@ func Init(e *echo.Echo, sc Context) {
e.GET("/create", handler.HandleIndex(sc))
e.POST("/create", handler.HandleCreate(sc), middleware.SessionGuard(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("/login", handler.HandleAuth(sc, "login"))

View File

@ -38,14 +38,15 @@ type Market struct {
EndDate time.Time
}
type LSMR struct {
type LMSR struct {
B float64
Q1 int
Q2 int
}
type MarketP struct {
// probability of outcomes
Pyes float64
Pno float64
type MarketQuote struct {
Outcome int
AvgPrice float64
TotalPrice float64
Reward float64
}