ENH - simulate live crypto markets

Drive market prices from randomized server intervals with per-market momentum, global trend, mean reversion, bounded shocks, and retained history. Stream authoritative market updates into live holdings, portfolio valuations, and real detail charts while invalidating stale quotes.
This commit is contained in:
smx.pusha
2026-08-18 11:41:56 +02:00
parent 3a5391c10d
commit 604d2b4084
11 changed files with 345 additions and 118 deletions
+7
View File
@@ -33,6 +33,7 @@ import { useClockStore } from '@/stores/clock'
import { useGamesStore } from '@/features/games/store'
import { useCallsStore } from '@/stores/calls'
import { useBankingStore } from '@/stores/banking'
import { useCryptoStore } from '@/stores/crypto'
import { useBillingStore } from '@/stores/billing'
import { useCompaniesStore } from '@/stores/companies'
import { useAccountStore } from '@/stores/account'
@@ -69,6 +70,7 @@ import type {
} from '@/types/companies'
import type { PhoneCall } from '@/types/phone'
import type { EasyShareEvent } from '@/types/easyshare'
import type { CryptoMarketChangedData } from '@/types/crypto'
import { nuiCall } from '@/utils/nui'
import { formatTimer } from '@/utils/clock'
import { parsePhonePreferences } from '@/utils/preferences'
@@ -92,6 +94,7 @@ type AppMessage = {
| PicstagramNotificationData
| FeatherNotificationData
| BankingChangedData
| CryptoMarketChangedData
| BillingNotificationData
| EasyShareEvent
| PhoneCall
@@ -274,6 +277,7 @@ const clock = useClockStore()
const games = useGamesStore()
const calls = useCallsStore()
const banking = useBankingStore()
const crypto = useCryptoStore()
const billing = useBillingStore()
const companies = useCompaniesStore()
const mail = useMailStore()
@@ -1024,6 +1028,9 @@ function onMessage(event: MessageEvent<AppMessage>): void {
title: phone.t('Apps.banking.notifications.receivedTitle'),
})
}
} else if (event.data?.type === 'crypto:changed' && event.data.data) {
const data = event.data.data as CryptoMarketChangedData
crypto.applyMarketUpdate(data.markets)
} else if (event.data?.type === 'billing:changed') {
void billing.loadOverview()
} else if (event.data?.type === 'billing:new' && event.data.data) {
+50
View File
@@ -54,6 +54,56 @@ describe('crypto store', () => {
expect(mockNuiCall).toHaveBeenCalledWith('crypto:bootstrap', {})
})
it('applies live server prices to holdings and portfolio profit or loss', () => {
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',
}
crypto.pendingQuote = quote
crypto.applyMarketUpdate([
{
...crypto.data.markets[0],
changePercent: 25,
high24h: '125.00',
price: '125.00',
priceHistory: ['100.00', '125.00'],
},
])
expect(crypto.data.holdings[0].value).toBe('250.00')
expect(crypto.data.portfolioValue).toBe('350.00')
expect(crypto.data.markets[0].priceHistory).toEqual(['100.00', '125.00'])
expect(crypto.pendingQuote).toBeNull()
})
it('sends only market, side and quantity when requesting a quote', async () => {
mockNuiCall.mockResolvedValueOnce({ data: quote, success: true })
const crypto = useCryptoStore()
+38 -1
View File
@@ -1,6 +1,11 @@
import { defineStore } from 'pinia'
import type { CryptoBootstrap, CryptoQuote, CryptoSide } from '@/types/crypto'
import type {
CryptoBootstrap,
CryptoMarket,
CryptoQuote,
CryptoSide,
} from '@/types/crypto'
import { nuiCall, type NuiResponse } from '@/utils/nui'
function requestKey(prefix: string): string {
@@ -16,6 +21,38 @@ export const useCryptoStore = defineStore('crypto', {
pendingQuote: null as CryptoQuote | null,
}),
actions: {
applyMarketUpdate(markets: CryptoMarket[]): void {
if (!this.data || markets.length === 0) return
const changed = new Map(markets.map((market) => [market.id, market]))
const nextMarkets = this.data.markets.map(
(market) => changed.get(market.id) ?? market,
)
const prices = new Map(
nextMarkets.map((market) => [market.id, Number(market.price)]),
)
const holdings = this.data.holdings.map((holding) => ({
...holding,
value: (
Number(holding.quantity) * (prices.get(holding.assetId) ?? 0)
).toFixed(2),
}))
const portfolioValue = holdings
.reduce(
(total, holding) => total + Number(holding.value),
Number(this.data.cashBalance),
)
.toFixed(2)
this.data = {
...this.data,
holdings,
markets: nextMarkets,
portfolioValue,
}
if (this.pendingQuote && changed.has(this.pendingQuote.marketId)) {
this.pendingQuote = null
}
},
async call<T>(endpoint: string, payload: Record<string, unknown> = {}) {
this.isLoading = true
this.error = ''
+7
View File
@@ -11,9 +11,16 @@ export type CryptoMarket = {
low24h: string
name: string
price: string
priceHistory?: string[]
sparkline: number[]
symbol: string
treasuryAvailable: string
updatedAt?: number
}
export type CryptoMarketChangedData = {
markets: CryptoMarket[]
updatedAt: number
}
export type CryptoHolding = {
@@ -51,7 +51,7 @@ describe('VaultX crypto app contracts', () => {
})
it('includes advanced market detail and persistent profile controls', () => {
expect(source).toContain('selected.sparkline')
expect(source).toContain('selected.priceHistory')
expect(source).toContain('const detailChart = computed')
expect(source).toContain('CHART_PERIOD_CONFIG[period.value]')
expect(source).toContain('class="detail-chart__marker"')
@@ -187,6 +187,18 @@ describe('VaultX crypto app contracts', () => {
expect(server).toContain('settlement_ledger_queries')
})
it('streams server-driven market movement into live portfolio values', () => {
expect(config).toContain('PriceTickMinimumSeconds')
expect(config).toContain('MarketsPerTickMaximum')
expect(config).toContain('MarketShockChanceBasisPoints')
expect(server).toContain('local market_dynamics = {}')
expect(server).toContain('global_market_trend')
expect(server).toContain('Config.Crypto.MeanReversionBasisPoints')
expect(server).toContain('TriggerClientEvent("sky_phone:crypto:changed"')
expect(server).toContain('priceHistory = price_history')
expect(source).toContain('selected.priceHistory')
})
it('stores cash in price-scale minor units throughout the ledger', () => {
expect(server).toContain(
'local ledger_amount = amount * Config.Crypto.PriceScale',
+24 -46
View File
@@ -50,32 +50,24 @@ type ChartPeriod = '1D' | '1W' | '1M' | '6M' | '1Y'
const CHART_PERIODS: ChartPeriod[] = ['1D', '1W', '1M', '6M', '1Y']
const CHART_PERIOD_CONFIG: Record<
ChartPeriod,
{ duration: number; multiplier: number; noise: number; samples: number }
{ duration: number; samples: number }
> = {
'1D': { duration: 86_400_000, multiplier: 1, noise: 0.025, samples: 16 },
'1D': { duration: 86_400_000, samples: 16 },
'1W': {
duration: 7 * 86_400_000,
multiplier: 1.4,
noise: 0.05,
samples: 18,
samples: 24,
},
'1M': {
duration: 30 * 86_400_000,
multiplier: 2.2,
noise: 0.09,
samples: 20,
samples: 32,
},
'6M': {
duration: 183 * 86_400_000,
multiplier: 3.5,
noise: 0.16,
samples: 22,
samples: 40,
},
'1Y': {
duration: 365 * 86_400_000,
multiplier: 5,
noise: 0.24,
samples: 24,
samples: 48,
},
}
const crypto = useCryptoStore()
@@ -264,38 +256,14 @@ const detailChart = computed(() => {
}
const currentPrice = Number(selected.price)
const seed = [...selected.id].reduce(
(total, character) => total + character.charCodeAt(0),
0,
)
const periodBias = ((seed % 13) - 6) * (config.multiplier - 1) * 0.24
const periodReturn = Math.max(
-72,
Math.min(180, selected.changePercent * config.multiplier + periodBias),
)
const startPrice = currentPrice / (1 + periodReturn / 100)
const sparkline = selected.sparkline.length ? selected.sparkline : [0.5, 0.5]
const values = Array.from({ length: config.samples }, (_, index) => {
const ratio = index / (config.samples - 1)
const sourcePosition = ratio * (sparkline.length - 1)
const sourceIndex = Math.floor(sourcePosition)
const sourceRatio = sourcePosition - sourceIndex
const sourceValue =
(sparkline[sourceIndex] ?? 0.5) * (1 - sourceRatio) +
(sparkline[Math.min(sourceIndex + 1, sparkline.length - 1)] ?? 0.5) *
sourceRatio
const trend = startPrice + (currentPrice - startPrice) * ratio
const wave =
(sourceValue - 0.5) * 0.72 +
Math.sin((ratio * (1.4 + config.multiplier * 0.28) + seed) * Math.PI) *
0.2
return Math.max(
currentPrice * 0.01,
trend + currentPrice * config.noise * wave * Math.sin(Math.PI * ratio),
)
})
values[0] = startPrice
const history = (selected.priceHistory ?? [])
.map(Number)
.filter((value) => Number.isFinite(value) && value > 0)
const values = (
history.length ? history : [currentPrice, currentPrice]
).slice(-config.samples)
if (values.length === 1) values.unshift(values[0])
const startPrice = values[0] ?? currentPrice
values[values.length - 1] = currentPrice
const minimum = Math.min(...values)
@@ -551,6 +519,16 @@ watch(amount, () => {
formError.value = ''
}
})
watch(markets, (value) => {
if (detail.value) {
detail.value =
value.find((market) => market.id === detail.value?.id) ?? null
}
if (selectedMarket.value) {
selectedMarket.value =
value.find((market) => market.id === selectedMarket.value?.id) ?? null
}
})
onMounted(() => void crypto.load())
</script>
+14 -1
View File
@@ -221,6 +221,18 @@ function createCryptoMarket({
const minimum = Math.min(...rawSparkline)
const maximum = Math.max(...rawSparkline)
const span = Math.max(0.01, maximum - minimum)
const normalizedSparkline = rawSparkline.map(
(value) => (value - minimum) / span,
)
const startPrice = numericPrice / (1 + changePercent / 100)
const priceHistory = normalizedSparkline.map((value, index) => {
const progress = index / (normalizedSparkline.length - 1)
const trend = startPrice + (numericPrice - startPrice) * progress
const fluctuation =
numericPrice * 0.018 * (value - 0.5) * Math.sin(Math.PI * progress)
return Math.max(0.01, trend + fluctuation).toFixed(2)
})
priceHistory[priceHistory.length - 1] = numericPrice.toFixed(2)
return {
changePercent,
color,
@@ -236,7 +248,8 @@ function createCryptoMarket({
).toFixed(2),
name,
price,
sparkline: rawSparkline.map((value) => (value - minimum) / span),
priceHistory,
sparkline: normalizedSparkline,
symbol,
treasuryAvailable: String(Math.floor(supply * 0.82)),
}
+8
View File
@@ -119,6 +119,14 @@ function verifyBrowserTestData(dataByEndpoint) {
assert.equal(typeof crypto.profile.priceAlerts, 'boolean')
assert.equal(typeof crypto.markets[0].issuedSupply, 'string')
assert(crypto.markets.every((market) => typeof market.logo === 'string'))
assert(
crypto.markets.every(
(market) =>
Array.isArray(market.priceHistory) &&
market.priceHistory.length >= 2 &&
market.priceHistory.at(-1) === Number(market.price).toFixed(2),
),
)
assert(
Math.max(...crypto.markets.map((market) => Number(market.price))) >=
1000000,
+14 -1
View File
@@ -655,7 +655,20 @@ Config.Crypto = {
AssetScale = 1000000,
PriceScale = 100,
QuoteLifetimeSeconds = 8,
PriceTickSeconds = 60,
PriceTickMinimumSeconds = 18,
PriceTickMaximumSeconds = 42,
MarketsPerTickMinimum = 4,
MarketsPerTickMaximum = 8,
MomentumDecayBasisPoints = 6500,
MomentumImpulseBasisPoints = 3500,
MeanReversionBasisPoints = 80,
GlobalTrendMaximumBasisPoints = 28,
MarketShockChanceBasisPoints = 300,
MarketShockMinimumMultiplier = 2,
MarketShockMaximumMultiplier = 4,
MaximumMovementMultiplier = 5,
HistoryRetentionTicks = 4096,
SparklinePoints = 48,
SessionSeconds = 30 * 60,
RecentAuthenticationSeconds = 5 * 60,
PasswordMinLength = 8,
+8
View File
@@ -927,6 +927,14 @@ RegisterNetEvent("sky_phone:banking:changed", function(data)
SendNUIMessage({ type = "banking:changed", data = data })
end)
RegisterNetEvent("sky_phone:crypto:changed", function(data)
if type(data) ~= "table" or type(data.markets) ~= "table" then
Bridge.Debug("error", "[sky_phone] Rejected invalid crypto market data.")
return
end
SendNUIMessage({ type = "crypto:changed", data = data })
end)
RegisterNetEvent("sky_phone:billing:changed", function()
SendNUIMessage({ type = "billing:changed" })
end)
+162 -68
View File
@@ -5,6 +5,9 @@ local profile_locks = {}
local exchange_lock = false
local markets = {}
local market_order = {}
local market_dynamics = {}
local market_cursor = 1
local global_market_trend = 0
local function ensure_schema()
local statements = {
@@ -359,47 +362,71 @@ local function market_rows()
return indexed
end
local function market_dtos()
local function market_dtos(selected_market_ids)
local current = market_rows()
local selected = nil
if selected_market_ids then
selected = {}
for _, market_id in ipairs(selected_market_ids) do
selected[market_id] = true
end
end
local daily_rows = Bridge.Database.Query([[
SELECT `market_id`, MIN(`price`) AS `low`, MAX(`price`) AS `high`
FROM `sky_phone_crypto_market_ticks`
WHERE `created_at` >= DATE_SUB(CURRENT_TIMESTAMP, INTERVAL 24 HOUR)
GROUP BY `market_id`
]], {})
local daily = {}
for _, daily_row in ipairs(daily_rows) do
daily[daily_row.market_id] = daily_row
end
local result = {}
for _, market_id in ipairs(market_order) do
local config = markets[market_id]
local row = current[market_id]
local ticks = Bridge.Database.Query([[
SELECT `price` FROM `sky_phone_crypto_market_ticks`
WHERE `market_id` = ? ORDER BY `id` DESC LIMIT 12
]], { market_id })
local prices = {}
for index = #ticks, 1, -1 do
prices[#prices + 1] = tonumber(ticks[index].price) or tonumber(row.price)
if not selected or selected[market_id] then
local config = markets[market_id]
local row = current[market_id]
local ticks = Bridge.Database.Query([[
SELECT `price` FROM `sky_phone_crypto_market_ticks`
WHERE `market_id` = ? ORDER BY `id` DESC LIMIT ?
]], { market_id, Config.Crypto.SparklinePoints })
local prices = {}
for index = #ticks, 1, -1 do
prices[#prices + 1] = tonumber(ticks[index].price) or tonumber(row.price)
end
if #prices == 0 then
prices[1] = tonumber(row.price)
end
local minimum = math.min(table.unpack(prices))
local maximum = math.max(table.unpack(prices))
local span = math.max(1, maximum - minimum)
local sparkline = {}
local price_history = {}
for index, historical_price in ipairs(prices) do
sparkline[index] = (historical_price - minimum) / span
price_history[index] = decimal_string(historical_price, Config.Crypto.PriceScale)
end
local first = prices[1]
local price = tonumber(row.price) or config.InitialPrice
local daily_range = daily[market_id]
result[#result + 1] = {
id = market_id,
symbol = config.Symbol,
name = config.Name,
color = config.Color,
logo = config.Logo,
price = decimal_string(price, Config.Crypto.PriceScale),
changePercent = first > 0 and ((price - first) / first) * 100 or 0,
enabled = row.status == "active",
high24h = decimal_string(daily_range and daily_range.high or maximum, Config.Crypto.PriceScale),
low24h = decimal_string(daily_range and daily_range.low or minimum, Config.Crypto.PriceScale),
issuedSupply = decimal_string(config.IssuedSupply * Config.Crypto.AssetScale, Config.Crypto.AssetScale),
treasuryAvailable = decimal_string(balance("treasury", market_id), Config.Crypto.AssetScale),
priceHistory = price_history,
sparkline = sparkline,
updatedAt = (tonumber(row.updated_at) or os.time()) * 1000,
}
end
if #prices == 0 then
prices[1] = tonumber(row.price)
end
local minimum = math.min(table.unpack(prices))
local maximum = math.max(table.unpack(prices))
local span = math.max(1, maximum - minimum)
local sparkline = {}
for index, price in ipairs(prices) do
sparkline[index] = (price - minimum) / span
end
local first = prices[1]
local price = tonumber(row.price) or config.InitialPrice
result[#result + 1] = {
id = market_id,
symbol = config.Symbol,
name = config.Name,
color = config.Color,
logo = config.Logo,
price = decimal_string(price, Config.Crypto.PriceScale),
changePercent = first > 0 and ((price - first) / first) * 100 or 0,
enabled = row.status == "active",
high24h = decimal_string(math.max(table.unpack(prices)), Config.Crypto.PriceScale),
low24h = decimal_string(math.min(table.unpack(prices)), Config.Crypto.PriceScale),
issuedSupply = decimal_string(config.IssuedSupply * Config.Crypto.AssetScale, Config.Crypto.AssetScale),
treasuryAvailable = decimal_string(balance("treasury", market_id), Config.Crypto.AssetScale),
sparkline = sparkline,
}
end
return result
end
@@ -1128,41 +1155,108 @@ end)
CreateThread(function()
while true do
Wait(Config.Crypto.PriceTickSeconds * 1000)
local tick_seconds = exports[GetCurrentResourceName()]:CryptoRandomInt(
Config.Crypto.PriceTickMinimumSeconds,
Config.Crypto.PriceTickMaximumSeconds + 1
)
if type(tick_seconds) ~= "number" then
error("[sky_phone] Crypto entropy provider did not return a market tick interval.")
end
Wait(tick_seconds * 1000)
with_exchange_lock(function()
for _, market_id in ipairs(market_order) do
local config = markets[market_id]
local row = Bridge.Database.Query(
"SELECT `price`,`version`,`status` FROM `sky_phone_crypto_markets` WHERE `id` = ? LIMIT 1",
{ market_id }
)[1]
if row and row.status == "active" then
local price = tonumber(row.price) or config.InitialPrice
local movement = exports[GetCurrentResourceName()]:CryptoRandomInt(
-config.VolatilityBasisPoints,
config.VolatilityBasisPoints + 1
)
if type(movement) ~= "number" then
error("[sky_phone] Crypto entropy provider did not return a market movement.")
end
local next_price = math.floor(price * (10000 + movement) / 10000)
next_price = math.max(config.MinimumPrice, math.min(config.MaximumPrice, next_price))
local next_version = (tonumber(row.version) or 0) + 1
if Bridge.Database.Transaction({
{ query = [[UPDATE `sky_phone_crypto_markets` SET `price` = ?, `version` = ? WHERE `id` = ? AND `version` = ?]], params = { next_price, next_version, market_id, row.version } },
{ query = [[INSERT INTO `sky_phone_crypto_market_ticks` (`market_id`,`version`,`price`) VALUES (?, ?, ?)]], params = { market_id, next_version, next_price } },
}) then
Bridge.Database.Query([[
DELETE FROM `sky_phone_crypto_market_ticks`
WHERE `market_id` = ? AND `id` NOT IN (
SELECT `id` FROM (
SELECT `id` FROM `sky_phone_crypto_market_ticks`
WHERE `market_id` = ? ORDER BY `id` DESC LIMIT 1440
) retained
local market_count = exports[GetCurrentResourceName()]:CryptoRandomInt(
Config.Crypto.MarketsPerTickMinimum,
Config.Crypto.MarketsPerTickMaximum + 1
)
local trend_impulse = exports[GetCurrentResourceName()]:CryptoRandomInt(
-Config.Crypto.GlobalTrendMaximumBasisPoints,
Config.Crypto.GlobalTrendMaximumBasisPoints + 1
)
if type(market_count) ~= "number" or type(trend_impulse) ~= "number" then
error("[sky_phone] Crypto entropy provider did not return valid market dynamics.")
end
global_market_trend = math.floor((global_market_trend * 7800 + trend_impulse * 2200) / 10000)
local changed_markets = {}
market_count = math.min(market_count, #market_order)
for offset = 0, market_count - 1 do
local order_index = ((market_cursor + offset - 1) % #market_order) + 1
local market_id = market_order[order_index]
local config = markets[market_id]
local row = Bridge.Database.Query(
"SELECT `price`,`version`,`status` FROM `sky_phone_crypto_markets` WHERE `id` = ? LIMIT 1",
{ market_id }
)[1]
if row and row.status == "active" then
local price = tonumber(row.price) or config.InitialPrice
local impulse = exports[GetCurrentResourceName()]:CryptoRandomInt(
-config.VolatilityBasisPoints,
config.VolatilityBasisPoints + 1
)
local shock_roll = exports[GetCurrentResourceName()]:CryptoRandomInt(0, 10000)
if type(impulse) ~= "number" or type(shock_roll) ~= "number" then
error("[sky_phone] Crypto entropy provider did not return a market movement.")
end
local dynamics = market_dynamics[market_id] or { momentum = 0 }
dynamics.momentum = math.floor((
dynamics.momentum * Config.Crypto.MomentumDecayBasisPoints
+ impulse * Config.Crypto.MomentumImpulseBasisPoints
) / 10000)
market_dynamics[market_id] = dynamics
local deviation = math.floor(
(config.InitialPrice - price) * 10000 / config.InitialPrice
)
local reversion = math.floor(
deviation * Config.Crypto.MeanReversionBasisPoints / 10000
)
local shock = 0
if shock_roll < Config.Crypto.MarketShockChanceBasisPoints then
local multiplier = exports[GetCurrentResourceName()]:CryptoRandomInt(
Config.Crypto.MarketShockMinimumMultiplier,
Config.Crypto.MarketShockMaximumMultiplier + 1
)
]], { market_id, market_id })
local direction_roll = exports[GetCurrentResourceName()]:CryptoRandomInt(0, 2)
if type(multiplier) ~= "number" or type(direction_roll) ~= "number" then
error("[sky_phone] Crypto entropy provider did not return valid shock dynamics.")
end
local direction = direction_roll == 0 and -1 or 1
shock = direction * config.VolatilityBasisPoints * multiplier
end
local maximum_movement = config.VolatilityBasisPoints
* Config.Crypto.MaximumMovementMultiplier
local movement = impulse + dynamics.momentum + global_market_trend + reversion + shock
movement = math.max(-maximum_movement, math.min(maximum_movement, movement))
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)
end
next_price = math.max(config.MinimumPrice, math.min(config.MaximumPrice, next_price))
local next_version = (tonumber(row.version) or 0) + 1
if Bridge.Database.Transaction({
{ query = [[UPDATE `sky_phone_crypto_markets` SET `price` = ?, `version` = ? WHERE `id` = ? AND `version` = ?]], params = { next_price, next_version, market_id, row.version } },
{ query = [[INSERT INTO `sky_phone_crypto_market_ticks` (`market_id`,`version`,`price`) VALUES (?, ?, ?)]], params = { market_id, next_version, next_price } },
}) then
changed_markets[#changed_markets + 1] = market_id
Bridge.Database.Query([[
DELETE FROM `sky_phone_crypto_market_ticks`
WHERE `market_id` = ? AND `id` NOT IN (
SELECT `id` FROM (
SELECT `id` FROM `sky_phone_crypto_market_ticks`
WHERE `market_id` = ? ORDER BY `id` DESC LIMIT ?
) retained
)
]], { market_id, market_id, Config.Crypto.HistoryRetentionTicks })
end
end
end
market_cursor = ((market_cursor + market_count - 1) % #market_order) + 1
if #changed_markets > 0 then
TriggerClientEvent("sky_phone:crypto:changed", -1, {
markets = market_dtos(changed_markets),
updatedAt = os.time() * 1000,
})
end
end)
end