Marketplace Price Management Bot Development (Repricing)
Repricing—automatic price changes on marketplaces in response to competitor actions or market conditions. Goal: maintain top search position and Buy Box without manual monitoring. Incorrectly configured repricing leads to price wars down to zero or sales below cost. Correctly configured—stable conversion growth while maintaining margins.
Repricing Models
| Strategy | Description | When to use |
|---|---|---|
| Min Price | Keep price at best competitor level | Competitive market without unique offer |
| Buy Box | Optimize for Buy Box win on Ozon/WB | Multi-seller positions |
| Margin Floor | Don't drop below set margin | Always, as limiter |
| Rule-Based | Conditions (if competitor < our price—lower by X%) | Flexible scenarios |
| Demand-Based | Raise price with high demand/stock | Products with variable demand |
Data Schema
CREATE TABLE repricing_rules (
id BIGSERIAL PRIMARY KEY,
name VARCHAR(255) NOT NULL,
marketplace VARCHAR(50) NOT NULL, -- 'ozon', 'wildberries', 'yandex_market'
scope_type VARCHAR(20) NOT NULL, -- 'global', 'category', 'product'
scope_id BIGINT,
strategy VARCHAR(30) NOT NULL, -- 'min_price', 'buy_box', 'rule_based'
min_price_mode VARCHAR(20) DEFAULT 'margin_floor', -- 'fixed' | 'margin_floor'
min_price_value NUMERIC(12,2), -- fixed minimum price
min_margin_pct NUMERIC(5,2) DEFAULT 10, -- minimum margin in %
max_price NUMERIC(12,2), -- price ceiling
step_pct NUMERIC(5,2) DEFAULT 1.0, -- change step in %
step_abs NUMERIC(10,2), -- or step in rubles
cooldown_minutes INT DEFAULT 60, -- minimum interval between changes
is_active BOOLEAN DEFAULT TRUE
);
CREATE TABLE repricing_log (
id BIGSERIAL PRIMARY KEY,
product_id BIGINT REFERENCES products(id),
marketplace VARCHAR(50),
old_price NUMERIC(12,2),
new_price NUMERIC(12,2),
reason TEXT,
rule_id BIGINT REFERENCES repricing_rules(id),
triggered_at TIMESTAMP DEFAULT NOW()
);
Getting Competitor Prices
Ozon API—competitor prices in Buy Box:
class OzonCompetitorPriceClient
{
public function getCompetitorPrices(string $offerId): array
{
$response = Http::withHeaders([
'Client-Id' => $this->clientId,
'Api-Key' => $this->apiKey,
])->post('https://api-seller.ozon.ru/v1/product/info/competitor-price', [
'offer_id' => $offerId,
]);
return $response->json('result', []);
}
}
Wildberries—analysis via product card:
class WildberriesPriceClient
{
public function getSellerPrices(int $nmId): array
{
// WB API v2 to get competitor price list for product
$response = Http::get("https://card.wb.ru/cards/detail", [
'nm' => $nmId,
'spp' => 27,
'curr' => 'rub',
]);
$products = $response->json('data.products', []);
$product = collect($products)->firstWhere('id', $nmId);
return $product ? $product['sizes'] ?? [] : [];
}
}
Repricing Engine
class RepricingEngine
{
public function calculateNewPrice(
Product $product,
string $marketplace,
RepricingRule $rule,
): ?PriceDecision {
$competitorData = $this->getCompetitorData($product, $marketplace);
$costPrice = $product->cost_price ?? 0;
$currentPrice = $this->getCurrentMarketplacePrice($product, $marketplace);
$decision = match ($rule->strategy) {
'min_price' => $this->strategyMinPrice($currentPrice, $competitorData, $rule, $costPrice),
'buy_box' => $this->strategyBuyBox($currentPrice, $competitorData, $rule, $costPrice),
'rule_based' => $this->strategyRuleBased($currentPrice, $competitorData, $rule, $costPrice),
default => null,
};
if (!$decision) return null;
// Check cooldown—don't change price too often
$lastChange = RepricingLog::where('product_id', $product->id)
->where('marketplace', $marketplace)
->where('triggered_at', '>=', now()->subMinutes($rule->cooldown_minutes))
->exists();
if ($lastChange) return null;
return $decision;
}
private function strategyMinPrice(
float $current, array $competitors, RepricingRule $rule, float $costPrice
): ?PriceDecision {
$competitorMin = collect($competitors)->min('price');
if (!$competitorMin) return null;
$floor = $this->calculateFloor($rule, $costPrice);
// Competitor cheaper—lower to their price (not below floor)
if ($competitorMin < $current) {
$newPrice = max($competitorMin, $floor);
if ($newPrice >= $current) return null; // no point
return new PriceDecision(
newPrice: $newPrice,
reason: "Competitor lowered to {$competitorMin}",
);
}
// Competitor more expensive—can raise (not above max_price)
if ($competitorMin > $current && $rule->max_price && $current < $rule->max_price) {
$newPrice = min($competitorMin - 1, $rule->max_price);
return new PriceDecision(
newPrice: $newPrice,
reason: "Competitor raised to {$competitorMin}",
);
}
return null;
}
private function calculateFloor(RepricingRule $rule, float $costPrice): float
{
if ($rule->min_price_mode === 'fixed' && $rule->min_price_value) {
return $rule->min_price_value;
}
if ($rule->min_margin_pct && $costPrice > 0) {
return $costPrice * (1 + $rule->min_margin_pct / 100);
}
return 0;
}
}
Publishing Price via API
class OzonPricePublisher
{
public function setPrice(string $offerId, float $newPrice): bool
{
$response = Http::withHeaders([
'Client-Id' => $this->clientId,
'Api-Key' => $this->apiKey,
])->post('https://api-seller.ozon.ru/v1/product/import/prices', [
'prices' => [[
'offer_id' => $offerId,
'price' => (string) $newPrice,
'old_price' => '0',
'premium_price' => '0',
'price_strategy_enabled' => false,
]],
]);
return $response->successful()
&& collect($response->json('result', []))->first()['updated'] === true;
}
}
Price War Protection
Price war—situation where two competitors endlessly lower prices. Protection mechanisms:
class PriceWarDetector
{
public function isWarring(int $productId, string $marketplace): bool
{
// If price changed more than 5 times in 24 hours—sign of war
$changes = RepricingLog::where('product_id', $productId)
->where('marketplace', $marketplace)
->where('triggered_at', '>=', now()->subDay())
->count();
if ($changes >= 5) {
// Stop repricing for 6 hours and alert
Cache::put("repricing.paused.{$productId}.{$marketplace}", true, now()->addHours(6));
Notification::send($this->admins, new PriceWarAlert($productId, $marketplace));
return true;
}
return false;
}
}
Monitoring and Reports
-- Price change statistics for the day
SELECT
p.name,
rl.marketplace,
COUNT(*) AS changes_count,
MIN(rl.new_price) AS min_price_today,
MAX(rl.new_price) AS max_price_today,
ROUND(AVG(rl.new_price), 2) AS avg_price_today
FROM repricing_log rl
JOIN products p ON p.id = rl.product_id
WHERE rl.triggered_at >= NOW() - INTERVAL '24 hours'
GROUP BY p.name, rl.marketplace
ORDER BY changes_count DESC;
Schedule
// Run repricing every 30 minutes
$schedule->job(new RunRepricingJob)->everyThirtyMinutes();
// Night—reset counters and recalculate strategies
$schedule->job(new ResetRepricingCountersJob)->dailyAt('03:00');
Timeline
- Data schema + engine for basic strategies: 2 days
- Ozon API integration (competitor prices + publishing): 1–2 days
- Wildberries API: +1 day
- PriceWarDetector + cooldown + alerts: 1 day
- Rule management interface + change log: 1–2 days
Total: 6–8 working days.







