ENH - add slow VaultX market cycles

Drive server-authoritative prices through alternating global and per-market trend phases with eased transitions, reduced noise, momentum, mean reversion, and rare bounded shocks. Mirror the cycle model in browser mocks and slow development preview ticks so charts show readable highs and downs.
This commit is contained in:
smx.pusha
2026-08-18 12:49:35 +02:00
parent c319cc57be
commit f657d1f63b
5 changed files with 223 additions and 45 deletions
@@ -22,6 +22,10 @@ const config = readFileSync(
new URL('../../../../sky_phone/config/config.lua', import.meta.url),
'utf8',
)
const testServer = readFileSync(
new URL('../../../testserver/index.cjs', import.meta.url),
'utf8',
)
describe('VaultX crypto app contracts', () => {
it('uses Sky UI without introducing Konsta components', () => {
@@ -247,13 +251,24 @@ describe('VaultX crypto app contracts', () => {
expect(config).toContain('PriceTickMinimumSeconds')
expect(config).toContain('MarketsPerTickMaximum')
expect(config).toContain('TickMovementDivisor')
expect(config).toContain('CycleDurationMinimumTicks')
expect(config).toContain('CycleStrengthMaximumBasisPoints')
expect(config).toContain('GlobalCycleMinimumTicks')
expect(config).toContain('MarketShockChanceBasisPoints')
expect(server).toContain('local market_dynamics = {}')
expect(server).toContain('global_market_trend')
expect(server).toContain('local function advance_market_cycle')
expect(server).toContain(
'dynamics.cycle_direction = -dynamics.cycle_direction',
)
expect(server).toContain('local function advance_global_market_cycle')
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')
expect(source).toContain('4500 + Math.random() * 2500')
expect(testServer).toContain('const cryptoMarketDynamics = new Map()')
expect(testServer).toContain('function advanceCryptoCycle(')
})
it('stores cash in price-scale minor units throughout the ledger', () => {
+1 -1
View File
@@ -677,7 +677,7 @@ function scheduleDevelopmentMarketTick() {
await crypto.previewMarketTick()
scheduleDevelopmentMarketTick()
},
1600 + Math.random() * 1800,
4500 + Math.random() * 2500,
)
}
onMounted(async () => {
+60 -2
View File
@@ -496,8 +496,43 @@ const cryptoMarkets = [
supply: 1200000000,
}),
]
const cryptoMarketDynamics = new Map()
const cryptoGlobalCycle = {
bias: 0,
direction: 0,
remainingTicks: 0,
target: 0,
}
function advanceCryptoCycle(
cycle,
minimumTicks,
maximumTicks,
minimumStrength,
maximumStrength,
) {
if (cycle.remainingTicks <= 0) {
cycle.direction =
cycle.direction === 0 ? (Math.random() < 0.5 ? -1 : 1) : -cycle.direction
cycle.remainingTicks = Math.floor(
minimumTicks + Math.random() * (maximumTicks - minimumTicks + 1),
)
cycle.target =
cycle.direction *
(minimumStrength + Math.random() * (maximumStrength - minimumStrength))
}
cycle.bias += (cycle.target - cycle.bias) * 0.18
cycle.remainingTicks -= 1
return cycle.bias
}
function advanceCryptoMarkets() {
const updatedAt = Date.now()
const globalBias = advanceCryptoCycle(
cryptoGlobalCycle,
24,
54,
0.00008,
0.00024,
)
for (const market of cryptoMarkets) {
const currentPrice = Number(market.price)
const fractionDigits = currentPrice < 1 ? 4 : 2
@@ -510,8 +545,31 @@ function advanceCryptoMarkets() {
: currentPrice >= 1
? 0.0012
: 0.004
const direction = Math.random() < 0.49 ? -1 : 1
const movement = direction * volatility * (0.25 + Math.random() * 0.75)
const dynamics = cryptoMarketDynamics.get(market.id) ?? {
bias: 0,
direction: 0,
momentum: 0,
remainingTicks: 0,
target: 0,
}
const cycleBias = advanceCryptoCycle(
dynamics,
14,
30,
volatility * 0.28,
volatility * 0.52,
)
const impulse = (Math.random() * 2 - 1) * volatility * 0.2
dynamics.momentum = dynamics.momentum * 0.8 + impulse * 0.2
cryptoMarketDynamics.set(market.id, dynamics)
const movement = Math.max(
-volatility * 0.9,
Math.min(
volatility * 0.9,
cycleBias + globalBias + dynamics.momentum + impulse,
),
)
const direction = movement < 0 ? -1 : 1
let nextPrice = Number(
Math.max(minimumStep, currentPrice * (1 + movement)).toFixed(
fractionDigits,
+21 -12
View File
@@ -655,19 +655,28 @@ Config.Crypto = {
AssetScale = 1000000,
PriceScale = 100,
QuoteLifetimeSeconds = 8,
PriceTickMinimumSeconds = 4,
PriceTickMaximumSeconds = 8,
MarketsPerTickMinimum = 6,
MarketsPerTickMaximum = 10,
TickMovementDivisor = 8,
MomentumDecayBasisPoints = 6500,
MomentumImpulseBasisPoints = 3500,
MeanReversionBasisPoints = 80,
GlobalTrendMaximumBasisPoints = 28,
MarketShockChanceBasisPoints = 300,
PriceTickMinimumSeconds = 7,
PriceTickMaximumSeconds = 12,
MarketsPerTickMinimum = 8,
MarketsPerTickMaximum = 12,
TickMovementDivisor = 10,
RandomImpulseDivisor = 3,
MomentumDecayBasisPoints = 8000,
MomentumImpulseBasisPoints = 2000,
MeanReversionBasisPoints = 55,
CycleDurationMinimumTicks = 14,
CycleDurationMaximumTicks = 30,
CycleStrengthMinimumBasisPoints = 2800,
CycleStrengthMaximumBasisPoints = 5200,
CycleTransitionBasisPoints = 1800,
GlobalCycleMinimumTicks = 24,
GlobalCycleMaximumTicks = 54,
GlobalTrendMinimumBasisPoints = 8,
GlobalTrendMaximumBasisPoints = 24,
MarketShockChanceBasisPoints = 100,
MarketShockMinimumMultiplier = 2,
MarketShockMaximumMultiplier = 4,
MaximumMovementMultiplier = 5,
MarketShockMaximumMultiplier = 3,
MaximumMovementMultiplier = 3,
HistoryRetentionTicks = 4096,
SparklinePoints = 48,
SessionSeconds = 30 * 60,
+126 -30
View File
@@ -8,6 +8,11 @@ local market_order = {}
local market_dynamics = {}
local market_cursor = 1
local global_market_trend = 0
local global_market_cycle = {
direction = 0,
remaining_ticks = 0,
target = 0,
}
local function ensure_schema()
local statements = {
@@ -1400,6 +1405,88 @@ end
reconcile_settlements(true)
local function crypto_random_int(minimum, maximum, failure_message)
local value = exports[GetCurrentResourceName()]:CryptoRandomInt(minimum, maximum)
if type(value) ~= "number" then
error(failure_message)
end
return value
end
local function truncate_integer(value)
if value > 0 then
return math.floor(value)
elseif value < 0 then
return math.ceil(value)
end
return 0
end
local function advance_global_market_cycle()
if global_market_cycle.remaining_ticks <= 0 then
if global_market_cycle.direction == 0 then
global_market_cycle.direction = crypto_random_int(
0,
2,
"[sky_phone] Crypto entropy provider did not return a global market direction."
) == 0 and -1 or 1
else
global_market_cycle.direction = -global_market_cycle.direction
end
global_market_cycle.remaining_ticks = crypto_random_int(
Config.Crypto.GlobalCycleMinimumTicks,
Config.Crypto.GlobalCycleMaximumTicks + 1,
"[sky_phone] Crypto entropy provider did not return a global market cycle duration."
)
global_market_cycle.target = global_market_cycle.direction * crypto_random_int(
Config.Crypto.GlobalTrendMinimumBasisPoints,
Config.Crypto.GlobalTrendMaximumBasisPoints + 1,
"[sky_phone] Crypto entropy provider did not return a global market trend."
)
end
global_market_trend = truncate_integer((
global_market_trend * (10000 - Config.Crypto.CycleTransitionBasisPoints)
+ global_market_cycle.target * Config.Crypto.CycleTransitionBasisPoints
) / 10000)
global_market_cycle.remaining_ticks = global_market_cycle.remaining_ticks - 1
end
local function advance_market_cycle(config, dynamics)
if dynamics.cycle_remaining_ticks <= 0 then
if dynamics.cycle_direction == 0 then
dynamics.cycle_direction = crypto_random_int(
0,
2,
"[sky_phone] Crypto entropy provider did not return a market cycle direction."
) == 0 and -1 or 1
else
dynamics.cycle_direction = -dynamics.cycle_direction
end
dynamics.cycle_remaining_ticks = crypto_random_int(
Config.Crypto.CycleDurationMinimumTicks,
Config.Crypto.CycleDurationMaximumTicks + 1,
"[sky_phone] Crypto entropy provider did not return a market cycle duration."
)
local strength = crypto_random_int(
Config.Crypto.CycleStrengthMinimumBasisPoints,
Config.Crypto.CycleStrengthMaximumBasisPoints + 1,
"[sky_phone] Crypto entropy provider did not return a market cycle strength."
)
dynamics.cycle_target = dynamics.cycle_direction * math.max(
1,
math.floor(config.VolatilityBasisPoints * strength / 10000)
)
end
dynamics.cycle_bias = truncate_integer((
dynamics.cycle_bias * (10000 - Config.Crypto.CycleTransitionBasisPoints)
+ dynamics.cycle_target * Config.Crypto.CycleTransitionBasisPoints
) / 10000)
dynamics.cycle_remaining_ticks = dynamics.cycle_remaining_ticks - 1
return dynamics.cycle_bias
end
CreateThread(function()
while true do
Wait(5 * 60 * 1000)
@@ -1409,27 +1496,19 @@ end)
CreateThread(function()
while true do
local tick_seconds = exports[GetCurrentResourceName()]:CryptoRandomInt(
local tick_seconds = crypto_random_int(
Config.Crypto.PriceTickMinimumSeconds,
Config.Crypto.PriceTickMaximumSeconds + 1
Config.Crypto.PriceTickMaximumSeconds + 1,
"[sky_phone] Crypto entropy provider did not return a market tick interval."
)
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()
local market_count = exports[GetCurrentResourceName()]:CryptoRandomInt(
local market_count = crypto_random_int(
Config.Crypto.MarketsPerTickMinimum,
Config.Crypto.MarketsPerTickMaximum + 1
Config.Crypto.MarketsPerTickMaximum + 1,
"[sky_phone] Crypto entropy provider did not return a market count."
)
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)
advance_global_market_cycle()
local changed_markets = {}
market_count = math.min(market_count, #market_order)
@@ -1443,44 +1522,61 @@ CreateThread(function()
)[1]
if row and row.status == "active" then
local price = tonumber(row.price) or config.InitialPrice
local impulse = exports[GetCurrentResourceName()]:CryptoRandomInt(
local impulse = crypto_random_int(
-config.VolatilityBasisPoints,
config.VolatilityBasisPoints + 1
config.VolatilityBasisPoints + 1,
"[sky_phone] Crypto entropy provider did not return a market movement."
)
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.")
if impulse > 0 then
impulse = math.floor(impulse / Config.Crypto.RandomImpulseDivisor)
elseif impulse < 0 then
impulse = math.ceil(impulse / Config.Crypto.RandomImpulseDivisor)
end
local dynamics = market_dynamics[market_id] or { momentum = 0 }
dynamics.momentum = math.floor((
local shock_roll = crypto_random_int(
0,
10000,
"[sky_phone] Crypto entropy provider did not return a market shock roll."
)
local dynamics = market_dynamics[market_id] or {
momentum = 0,
cycle_bias = 0,
cycle_direction = 0,
cycle_remaining_ticks = 0,
cycle_target = 0,
}
dynamics.momentum = truncate_integer((
dynamics.momentum * Config.Crypto.MomentumDecayBasisPoints
+ impulse * Config.Crypto.MomentumImpulseBasisPoints
) / 10000)
local cycle_bias = advance_market_cycle(config, dynamics)
market_dynamics[market_id] = dynamics
local deviation = math.floor(
(config.InitialPrice - price) * 10000 / config.InitialPrice
)
local reversion = math.floor(
local reversion = truncate_integer(
deviation * Config.Crypto.MeanReversionBasisPoints / 10000
)
local shock = 0
if shock_roll < Config.Crypto.MarketShockChanceBasisPoints then
local multiplier = exports[GetCurrentResourceName()]:CryptoRandomInt(
local multiplier = crypto_random_int(
Config.Crypto.MarketShockMinimumMultiplier,
Config.Crypto.MarketShockMaximumMultiplier + 1
Config.Crypto.MarketShockMaximumMultiplier + 1,
"[sky_phone] Crypto entropy provider did not return a market shock multiplier."
)
local direction_roll = crypto_random_int(
0,
2,
"[sky_phone] Crypto entropy provider did not return a market shock direction."
)
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
local movement = impulse + dynamics.momentum + cycle_bias
+ 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)