ENH - stream live VaultX market movement

Run smaller server-authoritative market ticks every few seconds, update portfolio and chart values from batched prices, and mirror the live feed in the browser test server.
This commit is contained in:
smx.pusha
2026-08-18 12:23:56 +02:00
parent d4e5526d16
commit 3b66b13e43
8 changed files with 174 additions and 23 deletions
+44
View File
@@ -105,6 +105,50 @@ describe('crypto store', () => {
expect(crypto.pendingQuote).toBeNull()
})
it('applies a browser preview tick without replacing account state', async () => {
const crypto = useCryptoStore()
crypto.data = {
...bootstrap,
cashBalance: '100.00',
holdings: [
{
assetId: 'aurora',
averagePrice: '100.00',
quantity: '2.000000',
value: '200.00',
},
],
markets: [
{
changePercent: 0,
color: '#25d9ad',
enabled: true,
high24h: '100.00',
id: 'aurora',
issuedSupply: '1000000.000000',
logo: '◈',
low24h: '100.00',
name: 'Aurora',
price: '100.00',
sparkline: [0, 1],
symbol: 'AUR',
treasuryAvailable: '850000.000000',
},
],
portfolioValue: '300.00',
}
mockNuiCall.mockResolvedValueOnce({
data: [{ ...crypto.data.markets[0], price: '101.25' }],
success: true,
})
expect(await crypto.previewMarketTick()).toBe(true)
expect(mockNuiCall).toHaveBeenCalledWith('crypto:market-tick', {})
expect(crypto.data.holdings[0].value).toBe('202.50')
expect(crypto.data.portfolioValue).toBe('302.50')
expect(crypto.data.cashBalance).toBe('100.00')
})
it('sends only market, side and quantity when requesting a quote', async () => {
mockNuiCall.mockResolvedValueOnce({ data: quote, success: true })
const crypto = useCryptoStore()
+6
View File
@@ -54,6 +54,12 @@ export const useCryptoStore = defineStore('crypto', {
this.pendingQuote = null
}
},
async previewMarketTick(): Promise<boolean> {
const response = await nuiCall<CryptoMarket[]>('crypto:market-tick', {})
if (!response.success || !response.data) return false
this.applyMarketUpdate(response.data)
return true
},
async call<T>(endpoint: string, payload: Record<string, unknown> = {}) {
this.isLoading = true
this.error = ''
@@ -230,6 +230,7 @@ describe('VaultX crypto app contracts', () => {
it('streams server-driven market movement into live portfolio values', () => {
expect(config).toContain('PriceTickMinimumSeconds')
expect(config).toContain('MarketsPerTickMaximum')
expect(config).toContain('TickMovementDivisor')
expect(config).toContain('MarketShockChanceBasisPoints')
expect(server).toContain('local market_dynamics = {}')
expect(server).toContain('global_market_trend')
+24 -4
View File
@@ -24,7 +24,7 @@ import {
WalletCards,
X,
} from 'lucide-vue-next'
import { computed, onMounted, ref, watch } from 'vue'
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import cryptoHeaderLogo from '@/assets/img/app-icons/crypto-header-logo.png'
import CryptoLogo from '@/components/crypto/CryptoLogo.vue'
import { useCryptoStore } from '@/stores/crypto'
@@ -86,6 +86,7 @@ const CHART_PERIOD_CONFIG: Record<
const crypto = useCryptoStore()
const easyShare = useEasyShareStore()
const phone = usePhoneStore()
let marketPreviewTimer: number | undefined
const tab = ref<Tab>('portfolio')
const authMode = ref<'login' | 'register'>('login')
const activityFilter = ref<'all' | 'trades' | 'wallet'>('all')
@@ -348,11 +349,14 @@ function t(key: string) {
return phone.t(`Apps.crypto.${key}`)
}
function money(value: string | number) {
const numericValue = Number(value) || 0
const absolute = Math.abs(numericValue)
return new Intl.NumberFormat(locale.value, {
currency: 'USD',
maximumFractionDigits: 2,
maximumFractionDigits: absolute > 0 && absolute < 1 ? 4 : 2,
minimumFractionDigits: 2,
style: 'currency',
}).format(Number(value) || 0)
}).format(numericValue)
}
function signedMoney(value: number) {
const formatted = money(Math.abs(value))
@@ -666,7 +670,23 @@ watch(markets, (value) => {
value.find((market) => market.id === selectedMarket.value?.id) ?? null
}
})
onMounted(() => void crypto.load())
function scheduleDevelopmentMarketTick() {
if (!import.meta.env.DEV) return
marketPreviewTimer = window.setTimeout(
async () => {
await crypto.previewMarketTick()
scheduleDevelopmentMarketTick()
},
1600 + Math.random() * 1800,
)
}
onMounted(async () => {
await crypto.load()
scheduleDevelopmentMarketTick()
})
onUnmounted(() => {
if (marketPreviewTimer !== undefined) window.clearTimeout(marketPreviewTimer)
})
</script>
<template>
+79 -15
View File
@@ -496,6 +496,55 @@ const cryptoMarkets = [
supply: 1200000000,
}),
]
function advanceCryptoMarkets() {
const updatedAt = Date.now()
for (const market of cryptoMarkets) {
const currentPrice = Number(market.price)
const fractionDigits = currentPrice < 1 ? 4 : 2
const minimumStep = 10 ** -fractionDigits
const volatility =
currentPrice >= 100_000
? 0.00018
: currentPrice >= 100
? 0.00055
: currentPrice >= 1
? 0.0012
: 0.004
const direction = Math.random() < 0.49 ? -1 : 1
const movement = direction * volatility * (0.25 + Math.random() * 0.75)
let nextPrice = Number(
Math.max(minimumStep, currentPrice * (1 + movement)).toFixed(
fractionDigits,
),
)
if (nextPrice === currentPrice) {
nextPrice = Math.max(minimumStep, currentPrice + direction * minimumStep)
}
const formattedPrice = nextPrice.toFixed(fractionDigits)
const priceHistory = [...(market.priceHistory ?? []), formattedPrice].slice(
-48,
)
const numericHistory = priceHistory.map(Number)
const minimum = Math.min(...numericHistory)
const maximum = Math.max(...numericHistory)
const span = Math.max(minimumStep, maximum - minimum)
market.changePercent =
((nextPrice - numericHistory[0]) / numericHistory[0]) * 100
market.high24h = Math.max(Number(market.high24h), nextPrice).toFixed(
fractionDigits,
)
market.low24h = Math.min(Number(market.low24h), nextPrice).toFixed(
fractionDigits,
)
market.price = formattedPrice
market.priceHistory = priceHistory
market.sparkline = numericHistory.map((price) => (price - minimum) / span)
market.updatedAt = updatedAt
}
return cryptoMarkets
}
let cryptoProfile = {
createdAt: Date.now() - 42 * 86400000,
handle: 'skyline',
@@ -6809,22 +6858,33 @@ app.post('/api/:endpoint', (request, response) => {
playerName: 'Alex Morgan',
transactions: mockBankTransactions,
})
const cryptoOverview = () => ({
activity: cryptoActivity,
authenticated: cryptoAuthenticated,
cashBalance: String(cryptoCashBalance),
holdings: cryptoHoldings,
markets: cryptoMarkets,
portfolioValue: String(
cryptoCashBalance +
cryptoHoldings.reduce(
(total, holding) => total + Number(holding.value),
0,
const cryptoOverview = () => {
const currentHoldings = cryptoHoldings.map((holding) => {
const market = cryptoMarkets.find((item) => item.id === holding.assetId)
return {
...holding,
value: (Number(holding.quantity) * Number(market?.price ?? 0)).toFixed(
2,
),
),
profile: cryptoAuthenticated ? cryptoProfile : null,
registered: true,
})
}
})
return {
activity: cryptoActivity,
authenticated: cryptoAuthenticated,
cashBalance: String(cryptoCashBalance),
holdings: currentHoldings,
markets: cryptoMarkets,
portfolioValue: String(
cryptoCashBalance +
currentHoldings.reduce(
(total, holding) => total + Number(holding.value),
0,
),
),
profile: cryptoAuthenticated ? cryptoProfile : null,
registered: true,
}
}
const billingInvoice = (invoice) => ({
...invoice,
canDispute: invoice.direction === 'inbox' && invoice.status === 'open',
@@ -8300,6 +8360,10 @@ app.post('/api/:endpoint', (request, response) => {
response.json({ success: true, data: cryptoOverview() })
return
}
if (endpoint === 'crypto:market-tick') {
response.json({ success: true, data: advanceCryptoMarkets() })
return
}
if (endpoint === 'crypto:login') {
if (request.body.password !== cryptoPassword) {
response.json({ success: false, error: 'invalid_credentials' })
+10
View File
@@ -8,6 +8,7 @@ const browserDataRequests = [
['account:devices', {}],
['banking:overview', {}],
['crypto:bootstrap', {}],
['crypto:market-tick', {}],
['crypto:recipient', { walletKey: 'VX-DEAD-BEEF-C0DE-2026' }],
['crypto:quote', { marketId: 'aurora', quantity: '1', side: 'buy' }],
['health:overview', {}],
@@ -136,6 +137,15 @@ function verifyBrowserTestData(dataByEndpoint) {
assert(
Math.min(...crypto.markets.map((market) => Number(market.price))) <= 0.01,
)
const cryptoTick = dataByEndpoint.get('crypto:market-tick')
expectItems(cryptoTick, 'live crypto market tick', 24)
assert(
cryptoTick.every(
(market) =>
typeof market.updatedAt === 'number' &&
market.priceHistory.at(-1) === market.price,
),
)
expectItems(
dataByEndpoint.get('billing:list').invoices,
'billing invoices',
+5 -4
View File
@@ -655,10 +655,11 @@ Config.Crypto = {
AssetScale = 1000000,
PriceScale = 100,
QuoteLifetimeSeconds = 8,
PriceTickMinimumSeconds = 18,
PriceTickMaximumSeconds = 42,
MarketsPerTickMinimum = 4,
MarketsPerTickMaximum = 8,
PriceTickMinimumSeconds = 4,
PriceTickMaximumSeconds = 8,
MarketsPerTickMinimum = 6,
MarketsPerTickMaximum = 10,
TickMovementDivisor = 8,
MomentumDecayBasisPoints = 6500,
MomentumImpulseBasisPoints = 3500,
MeanReversionBasisPoints = 80,
+5
View File
@@ -1482,6 +1482,11 @@ CreateThread(function()
* Config.Crypto.MaximumMovementMultiplier
local movement = impulse + dynamics.momentum + global_market_trend + reversion + shock
movement = math.max(-maximum_movement, math.min(maximum_movement, movement))
if movement > 0 then
movement = math.floor(movement / Config.Crypto.TickMovementDivisor)
elseif movement < 0 then
movement = math.ceil(movement / Config.Crypto.TickMovementDivisor)
end
local next_price = math.floor(price * (10000 + movement) / 10000)
if next_price == price and movement ~= 0 then
next_price = price + (movement > 0 and 1 or -1)