Hedging setup

This commit is contained in:
2026-07-16 20:37:17 +02:00
parent 4c85f80412
commit aba25d62dc
6 changed files with 248 additions and 2 deletions

View File

@@ -12,22 +12,42 @@ class PurchaseConfiguration(metaclass=PoolMeta):
allow_modification_after_validation = fields.Boolean( allow_modification_after_validation = fields.Boolean(
"Autorise modification after validation") "Autorise modification after validation")
auto_hedging = fields.Boolean("Auto hedge")
auto_hedging_over = fields.Boolean("Over hedge")
@classmethod @classmethod
def default_allow_modification_after_validation(cls): def default_allow_modification_after_validation(cls):
return False return False
@classmethod
def default_auto_hedging(cls):
return False
@classmethod
def default_auto_hedging_over(cls):
return False
class SaleConfiguration(metaclass=PoolMeta): class SaleConfiguration(metaclass=PoolMeta):
__name__ = 'sale.configuration' __name__ = 'sale.configuration'
allow_modification_after_validation = fields.Boolean( allow_modification_after_validation = fields.Boolean(
"Autorise modification after validation") "Autorise modification after validation")
auto_hedging = fields.Boolean("Auto hedge")
auto_hedging_over = fields.Boolean("Over hedge")
@classmethod @classmethod
def default_allow_modification_after_validation(cls): def default_allow_modification_after_validation(cls):
return False return False
@classmethod
def default_auto_hedging(cls):
return False
@classmethod
def default_auto_hedging_over(cls):
return False
class AccountConfiguration(metaclass=PoolMeta): class AccountConfiguration(metaclass=PoolMeta):
__name__ = 'account.configuration' __name__ = 'account.configuration'

View File

@@ -9,7 +9,7 @@ from trytond.pyson import Bool, Eval, Id, If, PYSONEncoder
from trytond.model import (ModelSQL, ModelView) from trytond.model import (ModelSQL, ModelView)
from trytond.tools import (cursor_dict, is_full_text, lstrip_wildcard) from trytond.tools import (cursor_dict, is_full_text, lstrip_wildcard)
from trytond.transaction import Transaction, inactive_records from trytond.transaction import Transaction, inactive_records
from decimal import getcontext, Decimal, ROUND_HALF_UP from decimal import getcontext, Decimal, ROUND_CEILING, ROUND_FLOOR, ROUND_HALF_UP
from sql.aggregate import Count, Max, Min, Sum, Avg, BoolOr from sql.aggregate import Count, Max, Min, Sum, Avg, BoolOr
from sql.conditionals import Case, Coalesce from sql.conditionals import Case, Coalesce
from sql import Column, Literal from sql import Column, Literal
@@ -2903,6 +2903,60 @@ class Line(metaclass=PoolMeta):
lot.lot_unit = packing_unit lot.lot_unit = packing_unit
Lot.save([lot]) Lot.save([lot])
@classmethod
def _auto_hedge_configuration(cls):
Configuration = Pool().get('purchase.configuration')
configurations = Configuration.search([], limit=1)
if configurations:
return configurations[0]
@classmethod
def _auto_hedge_contract_count(cls, line, over_hedge=False):
price_index = getattr(line, 'coffee_market_reference', None)
if not price_index or not getattr(line, 'unit', None):
return 0
quantity = Decimal(str(
getattr(line, 'quantity_theorical', None)
or getattr(line, 'quantity', None)
or 0))
if quantity <= 0:
return 0
contract_quantity = Decimal(str(price_index.get_qt(1, line.unit) or 0))
if contract_quantity <= 0:
return 0
rounding = ROUND_CEILING if over_hedge else ROUND_FLOOR
return int((quantity / contract_quantity).to_integral_value(
rounding=rounding))
@classmethod
def _ensure_auto_hedge_derivative(cls, line):
config = cls._auto_hedge_configuration()
if not config or not getattr(config, 'auto_hedging', False):
return
if getattr(line, 'derivatives', None):
return
price_index = getattr(line, 'coffee_market_reference', None)
nb_ct = cls._auto_hedge_contract_count(
line, over_hedge=getattr(config, 'auto_hedging_over', False))
if not price_index or nb_ct <= 0:
return
Derivative = Pool().get('derivative.derivative')
Date = Pool().get('ir.date')
quantity = price_index.get_qt(nb_ct, line.unit)
Derivative.create([{
'purchase': line.purchase.id if line.purchase else None,
'line': line.id,
'product': line.product.id if line.product else None,
'party': line.purchase.party.id
if line.purchase and line.purchase.party else None,
'price_index': price_index.id,
'nb_ct': nb_ct,
'price': getattr(line, 'coffee_market_price', None),
'direction': 'short',
'trade_date': Date.today(),
'open_qty': quantity,
}])
@classmethod @classmethod
def write(cls, *args): def write(cls, *args):
actions = iter(args) actions = iter(args)
@@ -3135,6 +3189,7 @@ class Line(metaclass=PoolMeta):
fl.line = line.id fl.line = line.id
FeeLots.save([fl]) FeeLots.save([fl])
cls._sync_virtual_lot_packing(line) cls._sync_virtual_lot_packing(line)
cls._ensure_auto_hedge_derivative(line)
if line.fee_: if line.fee_:
if not line.fee_.purchase: if not line.fee_.purchase:

View File

@@ -7,7 +7,7 @@ from trytond.model import (ModelSQL, ModelView)
from trytond.i18n import gettext from trytond.i18n import gettext
from trytond.wizard import Button, StateTransition, StateView, Wizard, StateAction from trytond.wizard import Button, StateTransition, StateView, Wizard, StateAction
from trytond.transaction import Transaction, inactive_records from trytond.transaction import Transaction, inactive_records
from decimal import getcontext, Decimal, ROUND_HALF_UP from decimal import getcontext, Decimal, ROUND_CEILING, ROUND_FLOOR, ROUND_HALF_UP
from sql.aggregate import Count, Max, Min, Sum, Avg, BoolOr from sql.aggregate import Count, Max, Min, Sum, Avg, BoolOr
from sql.conditionals import Case from sql.conditionals import Case
from sql import Column, Literal from sql import Column, Literal
@@ -3023,6 +3023,60 @@ class SaleLine(metaclass=PoolMeta):
lot.lot_qt = packing_count lot.lot_qt = packing_count
lot.lot_unit = packing_unit lot.lot_unit = packing_unit
Lot.save([lot]) Lot.save([lot])
@classmethod
def _auto_hedge_configuration(cls):
Configuration = Pool().get('sale.configuration')
configurations = Configuration.search([], limit=1)
if configurations:
return configurations[0]
@classmethod
def _auto_hedge_contract_count(cls, line, over_hedge=False):
price_index = getattr(line, 'coffee_market_reference', None)
if not price_index or not getattr(line, 'unit', None):
return 0
quantity = Decimal(str(
getattr(line, 'quantity_theorical', None)
or getattr(line, 'quantity', None)
or 0))
if quantity <= 0:
return 0
contract_quantity = Decimal(str(price_index.get_qt(1, line.unit) or 0))
if contract_quantity <= 0:
return 0
rounding = ROUND_CEILING if over_hedge else ROUND_FLOOR
return int((quantity / contract_quantity).to_integral_value(
rounding=rounding))
@classmethod
def _ensure_auto_hedge_derivative(cls, line):
config = cls._auto_hedge_configuration()
if not config or not getattr(config, 'auto_hedging', False):
return
if getattr(line, 'derivatives', None):
return
price_index = getattr(line, 'coffee_market_reference', None)
nb_ct = cls._auto_hedge_contract_count(
line, over_hedge=getattr(config, 'auto_hedging_over', False))
if not price_index or nb_ct <= 0:
return
Derivative = Pool().get('derivative.derivative')
Date = Pool().get('ir.date')
quantity = price_index.get_qt(nb_ct, line.unit)
Derivative.create([{
'sale': line.sale.id if line.sale else None,
'sale_line': line.id,
'product': line.product.id if line.product else None,
'party': line.sale.party.id
if line.sale and line.sale.party else None,
'price_index': price_index.id,
'nb_ct': nb_ct,
'price': getattr(line, 'coffee_market_price', None),
'direction': 'long',
'trade_date': Date.today(),
'open_qty': quantity,
}])
@classmethod @classmethod
def validate(cls, salelines): def validate(cls, salelines):
@@ -3083,6 +3137,7 @@ class SaleLine(metaclass=PoolMeta):
fl.sale_line = line.id fl.sale_line = line.id
FeeLots.save([fl]) FeeLots.save([fl])
cls._sync_virtual_lot_packing(line) cls._sync_virtual_lot_packing(line)
cls._ensure_auto_hedge_derivative(line)
#generate valuation for purchase and sale #generate valuation for purchase and sale
LotQt = Pool().get('lot.qt') LotQt = Pool().get('lot.qt')

View File

@@ -266,6 +266,112 @@ class PurchaseTradeTestCase(ModuleTestCase):
lot_model.save.assert_not_called() lot_model.save.assert_not_called()
def test_purchase_auto_hedge_under_uses_floor_contract_count(self):
'purchase auto hedge under creates a short floor contract quantity'
unit = Mock()
price_index = Mock(id=7)
price_index.get_qt.side_effect = [Decimal('17'), Decimal('17')]
line = Mock(
id=12,
derivatives=[],
coffee_market_reference=price_index,
coffee_market_price=Decimal('300'),
quantity_theorical=Decimal('26.4'),
quantity=None,
unit=unit,
product=Mock(id=3),
purchase=Mock(id=4, party=Mock(id=5)))
config_model = Mock()
config_model.search.return_value = [
Mock(auto_hedging=True, auto_hedging_over=False)]
derivative_model = Mock()
date_model = Mock()
date_model.today.return_value = datetime.date(2026, 7, 16)
pool = Mock()
pool.get.side_effect = lambda name: {
'purchase.configuration': config_model,
'derivative.derivative': derivative_model,
'ir.date': date_model,
}[name]
with patch.object(purchase_module, 'Pool', return_value=pool):
purchase_module.Line._ensure_auto_hedge_derivative(line)
derivative_model.create.assert_called_once_with([{
'purchase': 4,
'line': 12,
'product': 3,
'party': 5,
'price_index': 7,
'nb_ct': 1,
'price': Decimal('300'),
'direction': 'short',
'trade_date': datetime.date(2026, 7, 16),
'open_qty': Decimal('17'),
}])
def test_sale_auto_hedge_over_uses_ceiling_contract_count(self):
'sale auto hedge over creates a long ceiling contract quantity'
unit = Mock()
price_index = Mock(id=8)
price_index.get_qt.side_effect = [Decimal('17'), Decimal('34')]
line = Mock(
id=22,
derivatives=[],
coffee_market_reference=price_index,
coffee_market_price=Decimal('301'),
quantity_theorical=Decimal('26.4'),
quantity=None,
unit=unit,
product=Mock(id=13),
sale=Mock(id=14, party=Mock(id=15)))
config_model = Mock()
config_model.search.return_value = [
Mock(auto_hedging=True, auto_hedging_over=True)]
derivative_model = Mock()
date_model = Mock()
date_model.today.return_value = datetime.date(2026, 7, 16)
pool = Mock()
pool.get.side_effect = lambda name: {
'sale.configuration': config_model,
'derivative.derivative': derivative_model,
'ir.date': date_model,
}[name]
with patch.object(sale_module, 'Pool', return_value=pool):
sale_module.SaleLine._ensure_auto_hedge_derivative(line)
derivative_model.create.assert_called_once_with([{
'sale': 14,
'sale_line': 22,
'product': 13,
'party': 15,
'price_index': 8,
'nb_ct': 2,
'price': Decimal('301'),
'direction': 'long',
'trade_date': datetime.date(2026, 7, 16),
'open_qty': Decimal('34'),
}])
def test_auto_hedge_keeps_existing_manual_derivative(self):
'auto hedge does not create a duplicate when a derivative exists'
line = Mock(derivatives=[Mock()])
config_model = Mock()
config_model.search.return_value = [
Mock(auto_hedging=True, auto_hedging_over=True)]
derivative_model = Mock()
pool = Mock()
pool.get.side_effect = lambda name: {
'purchase.configuration': config_model,
'derivative.derivative': derivative_model,
}[name]
with patch.object(purchase_module, 'Pool', return_value=pool):
purchase_module.Line._ensure_auto_hedge_derivative(line)
derivative_model.create.assert_not_called()
def test_itsa_book_year_suffix_uses_april_fiscal_start(self): def test_itsa_book_year_suffix_uses_april_fiscal_start(self):
'ITSA book year changes on April 1st' 'ITSA book year changes on April 1st'
self.assertEqual( self.assertEqual(

View File

@@ -4,5 +4,10 @@
<label name="allow_modification_after_validation"/> <label name="allow_modification_after_validation"/>
<field name="allow_modification_after_validation"/> <field name="allow_modification_after_validation"/>
<newline/> <newline/>
<label name="auto_hedging"/>
<field name="auto_hedging"/>
<label name="auto_hedging_over"/>
<field name="auto_hedging_over"/>
<newline/>
</xpath> </xpath>
</data> </data>

View File

@@ -4,5 +4,10 @@
<label name="allow_modification_after_validation"/> <label name="allow_modification_after_validation"/>
<field name="allow_modification_after_validation"/> <field name="allow_modification_after_validation"/>
<newline/> <newline/>
<label name="auto_hedging"/>
<field name="auto_hedging"/>
<label name="auto_hedging_over"/>
<field name="auto_hedging_over"/>
<newline/>
</xpath> </xpath>
</data> </data>