Update market form on input change
This commit is contained in:
parent
bd7b2715bd
commit
df7726e313
|
@ -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);
|
||||
}
|
||||
|
|
File diff suppressed because one or more lines are too long
|
@ -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 "/"
|
||||
}
|
||||
|
|
|
@ -5,6 +5,7 @@ import (
|
|||
"fmt"
|
||||
"math"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"git.ekzyis.com/ekzyis/delphi.market/lib/lmsr"
|
||||
|
@ -101,12 +102,23 @@ func HandleMarket(sc context.Context) echo.HandlerFunc {
|
|||
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.LSMR{}
|
||||
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
|
||||
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{}
|
||||
u = types.User{}
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
}
|
||||
|
|
|
@ -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))
|
||||
}
|
|
@ -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 {
|
||||
<empty>
|
||||
}
|
||||
<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)
|
||||
}
|
|
@ -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"))
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
Loading…
Reference in New Issue