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