diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 00039f7..5d08fcf 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -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): 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) { diff --git a/frontend/src/stores/crypto.test.ts b/frontend/src/stores/crypto.test.ts index 6e38581..555cbd3 100644 --- a/frontend/src/stores/crypto.test.ts +++ b/frontend/src/stores/crypto.test.ts @@ -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() diff --git a/frontend/src/stores/crypto.ts b/frontend/src/stores/crypto.ts index 58a2488..f9e21e8 100644 --- a/frontend/src/stores/crypto.ts +++ b/frontend/src/stores/crypto.ts @@ -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(endpoint: string, payload: Record = {}) { this.isLoading = true this.error = '' diff --git a/frontend/src/types/crypto.ts b/frontend/src/types/crypto.ts index c919ea7..7dbf9e5 100644 --- a/frontend/src/types/crypto.ts +++ b/frontend/src/types/crypto.ts @@ -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 = { diff --git a/frontend/src/views/apps/CryptoApp.contract.test.ts b/frontend/src/views/apps/CryptoApp.contract.test.ts index 11f59c1..a4fd7cd 100644 --- a/frontend/src/views/apps/CryptoApp.contract.test.ts +++ b/frontend/src/views/apps/CryptoApp.contract.test.ts @@ -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', diff --git a/frontend/src/views/apps/CryptoApp.vue b/frontend/src/views/apps/CryptoApp.vue index 2457dc9..8c48105 100644 --- a/frontend/src/views/apps/CryptoApp.vue +++ b/frontend/src/views/apps/CryptoApp.vue @@ -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()) diff --git a/frontend/testserver/index.cjs b/frontend/testserver/index.cjs index 0f4fc27..6209e40 100644 --- a/frontend/testserver/index.cjs +++ b/frontend/testserver/index.cjs @@ -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)), } diff --git a/frontend/testserver/smoke.cjs b/frontend/testserver/smoke.cjs index 97ade85..ce45394 100644 --- a/frontend/testserver/smoke.cjs +++ b/frontend/testserver/smoke.cjs @@ -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, diff --git a/sky_phone/config/config.lua b/sky_phone/config/config.lua index 6734171..f488e35 100644 --- a/sky_phone/config/config.lua +++ b/sky_phone/config/config.lua @@ -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, diff --git a/sky_phone/source/client/main.lua b/sky_phone/source/client/main.lua index b08816c..d5198f0 100644 --- a/sky_phone/source/client/main.lua +++ b/sky_phone/source/client/main.lua @@ -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) diff --git a/sky_phone/source/server/crypto.lua b/sky_phone/source/server/crypto.lua index 0dfe797..53121ee 100644 --- a/sky_phone/source/server/crypto.lua +++ b/sky_phone/source/server/crypto.lua @@ -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