mirror of
https://github.com/sky-systems/sky_phone.git
synced 2026-08-29 01:08:59 +00:00
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:
@@ -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()
|
||||
|
||||
@@ -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,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>
|
||||
|
||||
@@ -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' })
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user