334 lines
8.5 KiB
TypeScript
334 lines
8.5 KiB
TypeScript
import React, { useMemo, useState } from 'react'
|
|
import { DatumValue } from '@nivo/core'
|
|
import { ResponsiveLine } from '@nivo/line'
|
|
|
|
import { Entry, makeEntries } from '../lib/simulator/entries'
|
|
import { NavBar } from '../components/nav/nav-bar'
|
|
import { Col } from '../components/layout/col'
|
|
|
|
function TableBody(props: { entries: Entry[] }) {
|
|
return (
|
|
<tbody>
|
|
{props.entries.map((entry, i) => (
|
|
<tr key={i}>
|
|
<th>{props.entries.length - i}</th>
|
|
<TableRowStart entry={entry} />
|
|
<TableRowEnd entry={entry} />
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
)
|
|
}
|
|
|
|
function TableRowStart(props: { entry: Entry }) {
|
|
const { entry } = props
|
|
if (entry.yesBid && entry.noBid) {
|
|
return (
|
|
<>
|
|
<td>
|
|
<div className="badge">ANTE</div>
|
|
</td>
|
|
<td>
|
|
${entry.yesBid} / ${entry.noBid}
|
|
</td>
|
|
</>
|
|
)
|
|
} else if (entry.yesBid) {
|
|
return (
|
|
<>
|
|
<td>
|
|
<div className="badge badge-success">YES</div>
|
|
</td>
|
|
<td>${entry.yesBid}</td>
|
|
</>
|
|
)
|
|
} else {
|
|
return (
|
|
<>
|
|
<td>
|
|
<div className="badge badge-error">NO</div>
|
|
</td>
|
|
<td>${entry.noBid}</td>
|
|
</>
|
|
)
|
|
}
|
|
}
|
|
|
|
function TableRowEnd(props: { entry: Entry | null; isNew?: boolean }) {
|
|
const { entry } = props
|
|
if (!entry) {
|
|
return (
|
|
<>
|
|
<td>0</td>
|
|
<td>0</td>
|
|
{!props.isNew && (
|
|
<>
|
|
<td>N/A</td>
|
|
<td>N/A</td>
|
|
</>
|
|
)}
|
|
</>
|
|
)
|
|
} else if (entry.yesBid && entry.noBid) {
|
|
return (
|
|
<>
|
|
<td>{(entry.prob * 100).toFixed(1)}%</td>
|
|
<td>N/A</td>
|
|
{!props.isNew && (
|
|
<>
|
|
<td>N/A</td>
|
|
<td>N/A</td>
|
|
</>
|
|
)}
|
|
</>
|
|
)
|
|
} else if (entry.yesBid) {
|
|
return (
|
|
<>
|
|
<td>{(entry.prob * 100).toFixed(1)}%</td>
|
|
<td>${entry.yesWeight.toFixed(0)}</td>
|
|
{!props.isNew && (
|
|
<>
|
|
<td>${entry.yesPayout.toFixed(0)}</td>
|
|
<td>{(entry.yesReturn * 100).toFixed(0)}%</td>
|
|
</>
|
|
)}
|
|
</>
|
|
)
|
|
} else {
|
|
return (
|
|
<>
|
|
<td>{(entry.prob * 100).toFixed(1)}%</td>
|
|
<td>${entry.noWeight.toFixed(0)}</td>
|
|
{!props.isNew && (
|
|
<>
|
|
<td>${entry.noPayout.toFixed(0)}</td>
|
|
<td>{(entry.noReturn * 100).toFixed(0)}%</td>
|
|
</>
|
|
)}
|
|
</>
|
|
)
|
|
}
|
|
}
|
|
|
|
function NewBidTable(props: {
|
|
steps: number
|
|
bids: any[]
|
|
setSteps: (steps: number) => void
|
|
setBids: (bids: any[]) => void
|
|
}) {
|
|
const { steps, bids, setSteps, setBids } = props
|
|
// Prepare for new bids
|
|
const [newBid, setNewBid] = useState(0)
|
|
const [newBidType, setNewBidType] = useState('YES')
|
|
|
|
function makeBid(type: string, bid: number) {
|
|
return {
|
|
yesBid: type == 'YES' ? bid : 0,
|
|
noBid: type == 'YES' ? 0 : bid,
|
|
}
|
|
}
|
|
|
|
function submitBid() {
|
|
if (newBid <= 0) return
|
|
const bid = makeBid(newBidType, newBid)
|
|
bids.splice(steps, 0, bid)
|
|
setBids(bids)
|
|
setSteps(steps + 1)
|
|
setNewBid(0)
|
|
}
|
|
|
|
function toggleBidType() {
|
|
setNewBidType(newBidType === 'YES' ? 'NO' : 'YES')
|
|
}
|
|
|
|
const nextBid = makeBid(newBidType, newBid)
|
|
const fakeBids = [...bids.slice(0, steps), nextBid]
|
|
const entries = makeEntries(fakeBids)
|
|
const nextEntry = entries[entries.length - 1]
|
|
|
|
function randomBid() {
|
|
const bidType = Math.random() < 0.5 ? 'YES' : 'NO'
|
|
// const p = bidType === 'YES' ? nextEntry.prob : 1 - nextEntry.prob
|
|
|
|
const amount = Math.floor(Math.random() * 300) + 1
|
|
const bid = makeBid(bidType, amount)
|
|
|
|
bids.splice(steps, 0, bid)
|
|
setBids(bids)
|
|
setSteps(steps + 1)
|
|
setNewBid(0)
|
|
}
|
|
|
|
return (
|
|
<>
|
|
<table className="table-compact my-8 table w-full text-center">
|
|
<thead>
|
|
<tr>
|
|
<th>Order #</th>
|
|
<th>Type</th>
|
|
<th>Bet</th>
|
|
<th>Prob</th>
|
|
<th>Est Payout</th>
|
|
<th></th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<tr>
|
|
<th>{steps + 1}</th>
|
|
<td>
|
|
<div
|
|
className={
|
|
`badge hover:cursor-pointer ` +
|
|
(newBidType == 'YES' ? 'badge-success' : 'badge-ghost')
|
|
}
|
|
onClick={toggleBidType}
|
|
>
|
|
YES
|
|
</div>
|
|
<br />
|
|
<div
|
|
className={
|
|
`badge hover:cursor-pointer ` +
|
|
(newBidType == 'NO' ? 'badge-error' : 'badge-ghost')
|
|
}
|
|
onClick={toggleBidType}
|
|
>
|
|
NO
|
|
</div>
|
|
</td>
|
|
<td>
|
|
{/* Note: Would love to make this input smaller... */}
|
|
<input
|
|
type="number"
|
|
placeholder="0"
|
|
className="input input-bordered"
|
|
style={{ maxWidth: 100 }}
|
|
value={newBid.toString()}
|
|
onChange={(e) => setNewBid(parseInt(e.target.value) || 0)}
|
|
onKeyUp={(e) => {
|
|
if (e.key === 'Enter') {
|
|
submitBid()
|
|
}
|
|
}}
|
|
onFocus={(e) => e.target.select()}
|
|
/>
|
|
</td>
|
|
|
|
<TableRowEnd entry={nextEntry} isNew />
|
|
|
|
<button
|
|
className="btn btn-primary mt-2"
|
|
onClick={() => submitBid()}
|
|
disabled={newBid <= 0}
|
|
>
|
|
Submit
|
|
</button>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
|
|
<button className="btn btn-secondary mb-4" onClick={randomBid}>
|
|
Random bet!
|
|
</button>
|
|
</>
|
|
)
|
|
}
|
|
|
|
// Show a hello world React page
|
|
export default function Simulator() {
|
|
const [steps, setSteps] = useState(1)
|
|
const [bids, setBids] = useState([{ yesBid: 100, noBid: 100 }])
|
|
|
|
const entries = useMemo(
|
|
() => makeEntries(bids.slice(0, steps)),
|
|
[bids, steps]
|
|
)
|
|
|
|
const reversedEntries = [...entries].reverse()
|
|
|
|
const probs = entries.map((entry) => entry.prob)
|
|
const points = probs.map((prob, i) => ({ x: i + 1, y: prob * 100 }))
|
|
const data = [{ id: 'Yes', data: points, color: '#11b981' }]
|
|
const tickValues = [0, 25, 50, 75, 100]
|
|
|
|
return (
|
|
<Col>
|
|
<NavBar />
|
|
<div className="mx-auto mt-8 grid w-full grid-cols-1 gap-4 p-2 text-center xl:grid-cols-2">
|
|
{/* Left column */}
|
|
<div>
|
|
<h1 className="mb-8 text-2xl font-bold">
|
|
Dynamic Parimutuel Market Simulator
|
|
</h1>
|
|
|
|
<NewBidTable {...{ steps, bids, setSteps, setBids }} />
|
|
|
|
{/* History of bids */}
|
|
<div className="overflow-x-auto">
|
|
<table className="table w-full text-center">
|
|
<thead>
|
|
<tr>
|
|
<th>Order #</th>
|
|
<th>Type</th>
|
|
<th>Bet</th>
|
|
<th>Prob</th>
|
|
<th>Est Payout</th>
|
|
<th>Payout</th>
|
|
<th>Return</th>
|
|
</tr>
|
|
</thead>
|
|
|
|
<TableBody entries={reversedEntries} />
|
|
</table>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Right column */}
|
|
<Col>
|
|
<h1 className="mb-8 text-2xl font-bold">
|
|
Probability of
|
|
<div className="badge badge-success w-18 ml-3 h-8 text-2xl">
|
|
YES
|
|
</div>
|
|
</h1>
|
|
<div className="mb-10 w-full" style={{ height: 500 }}>
|
|
<ResponsiveLine
|
|
data={data}
|
|
yScale={{ min: 0, max: 100, type: 'linear' }}
|
|
yFormat={formatPercent}
|
|
gridYValues={tickValues}
|
|
axisLeft={{
|
|
tickValues,
|
|
format: formatPercent,
|
|
}}
|
|
enableGridX={false}
|
|
colors={{ datum: 'color' }}
|
|
pointSize={8}
|
|
pointBorderWidth={1}
|
|
pointBorderColor="#fff"
|
|
enableSlices="x"
|
|
enableArea
|
|
margin={{ top: 20, right: 10, bottom: 20, left: 40 }}
|
|
/>
|
|
</div>
|
|
{/* Range slider that sets the current step */}
|
|
<label>Orders # 1 - {steps}</label>
|
|
<input
|
|
type="range"
|
|
className="range"
|
|
min="1"
|
|
max={bids.length}
|
|
value={steps}
|
|
onChange={(e) => setSteps(parseInt(e.target.value))}
|
|
/>
|
|
</Col>
|
|
</div>
|
|
</Col>
|
|
)
|
|
}
|
|
|
|
function formatPercent(y: DatumValue) {
|
|
return `${Math.round(+y.toString())}%`
|
|
}
|