mirror of
https://github.com/sky-systems/sky_phone.git
synced 2026-08-28 17:01:18 +00:00
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:
@@ -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', () => {
|
||||
|
||||
@@ -677,7 +677,7 @@ function scheduleDevelopmentMarketTick() {
|
||||
await crypto.previewMarketTick()
|
||||
scheduleDevelopmentMarketTick()
|
||||
},
|
||||
1600 + Math.random() * 1800,
|
||||
4500 + Math.random() * 2500,
|
||||
)
|
||||
}
|
||||
onMounted(async () => {
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user