wbportfolio 1.52.5__py2.py3-none-any.whl → 1.53.0__py2.py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of wbportfolio might be problematic. Click here for more details.
- wbportfolio/factories/portfolios.py +1 -0
- wbportfolio/migrations/0078_trade_drift_factor.py +26 -0
- wbportfolio/models/asset.py +3 -2
- wbportfolio/models/portfolio.py +17 -2
- wbportfolio/models/transactions/trade_proposals.py +80 -71
- wbportfolio/models/transactions/trades.py +66 -32
- wbportfolio/pms/trading/handler.py +104 -55
- wbportfolio/pms/typing.py +63 -20
- wbportfolio/rebalancing/models/model_portfolio.py +11 -14
- wbportfolio/serializers/transactions/trade_proposals.py +18 -1
- wbportfolio/serializers/transactions/trades.py +33 -5
- wbportfolio/tests/models/test_portfolios.py +10 -9
- wbportfolio/tests/models/transactions/test_trade_proposals.py +230 -13
- wbportfolio/tests/rebalancing/test_models.py +10 -16
- wbportfolio/viewsets/configs/buttons/trade_proposals.py +9 -1
- wbportfolio/viewsets/configs/display/trade_proposals.py +5 -4
- wbportfolio/viewsets/configs/display/trades.py +36 -0
- wbportfolio/viewsets/transactions/trade_proposals.py +3 -1
- wbportfolio/viewsets/transactions/trades.py +53 -45
- {wbportfolio-1.52.5.dist-info → wbportfolio-1.53.0.dist-info}/METADATA +1 -1
- {wbportfolio-1.52.5.dist-info → wbportfolio-1.53.0.dist-info}/RECORD +23 -22
- {wbportfolio-1.52.5.dist-info → wbportfolio-1.53.0.dist-info}/WHEEL +0 -0
- {wbportfolio-1.52.5.dist-info → wbportfolio-1.53.0.dist-info}/licenses/LICENSE +0 -0
|
@@ -66,11 +66,11 @@ class TestTradeProposal:
|
|
|
66
66
|
validated_trading_service = trade_proposal.validated_trading_service
|
|
67
67
|
|
|
68
68
|
# Assert effective and target portfolios are as expected
|
|
69
|
-
assert validated_trading_service.
|
|
69
|
+
assert validated_trading_service._effective_portfolio.to_dict() == {
|
|
70
70
|
a1.underlying_quote.id: a1.weighting,
|
|
71
71
|
a2.underlying_quote.id: a2.weighting,
|
|
72
72
|
}
|
|
73
|
-
assert validated_trading_service.
|
|
73
|
+
assert validated_trading_service._target_portfolio.to_dict() == {
|
|
74
74
|
a1.underlying_quote.id: a1.weighting + t1.weighting,
|
|
75
75
|
a2.underlying_quote.id: a2.weighting + t2.weighting,
|
|
76
76
|
}
|
|
@@ -267,15 +267,25 @@ class TestTradeProposal:
|
|
|
267
267
|
asset_position_factory.create(
|
|
268
268
|
portfolio=trade_proposal.portfolio, date=effective_date, underlying_instrument=i2, weighting=Decimal("0.3")
|
|
269
269
|
)
|
|
270
|
-
instrument_price_factory.create(instrument=i1, date=effective_date)
|
|
271
|
-
instrument_price_factory.create(instrument=i2, date=effective_date)
|
|
272
|
-
instrument_price_factory.create(instrument=i3, date=effective_date)
|
|
270
|
+
p1 = instrument_price_factory.create(instrument=i1, date=effective_date)
|
|
271
|
+
p2 = instrument_price_factory.create(instrument=i2, date=effective_date)
|
|
272
|
+
p3 = instrument_price_factory.create(instrument=i3, date=effective_date)
|
|
273
273
|
|
|
274
274
|
# build the target portfolio
|
|
275
275
|
target_portfolio = PortfolioDTO(
|
|
276
276
|
positions=(
|
|
277
|
-
Position(
|
|
278
|
-
|
|
277
|
+
Position(
|
|
278
|
+
underlying_instrument=i2.id,
|
|
279
|
+
date=trade_proposal.trade_date,
|
|
280
|
+
weighting=Decimal("0.4"),
|
|
281
|
+
price=float(p2.net_value),
|
|
282
|
+
),
|
|
283
|
+
Position(
|
|
284
|
+
underlying_instrument=i3.id,
|
|
285
|
+
date=trade_proposal.trade_date,
|
|
286
|
+
weighting=Decimal("0.6"),
|
|
287
|
+
price=float(p3.net_value),
|
|
288
|
+
),
|
|
279
289
|
)
|
|
280
290
|
)
|
|
281
291
|
|
|
@@ -295,9 +305,24 @@ class TestTradeProposal:
|
|
|
295
305
|
# build the target portfolio
|
|
296
306
|
new_target_portfolio = PortfolioDTO(
|
|
297
307
|
positions=(
|
|
298
|
-
Position(
|
|
299
|
-
|
|
300
|
-
|
|
308
|
+
Position(
|
|
309
|
+
underlying_instrument=i1.id,
|
|
310
|
+
date=trade_proposal.trade_date,
|
|
311
|
+
weighting=Decimal("0.2"),
|
|
312
|
+
price=float(p1.net_value),
|
|
313
|
+
),
|
|
314
|
+
Position(
|
|
315
|
+
underlying_instrument=i2.id,
|
|
316
|
+
date=trade_proposal.trade_date,
|
|
317
|
+
weighting=Decimal("0.3"),
|
|
318
|
+
price=float(p2.net_value),
|
|
319
|
+
),
|
|
320
|
+
Position(
|
|
321
|
+
underlying_instrument=i3.id,
|
|
322
|
+
date=trade_proposal.trade_date,
|
|
323
|
+
weighting=Decimal("0.5"),
|
|
324
|
+
price=float(p3.net_value),
|
|
325
|
+
),
|
|
301
326
|
)
|
|
302
327
|
)
|
|
303
328
|
|
|
@@ -361,6 +386,8 @@ class TestTradeProposal:
|
|
|
361
386
|
"""
|
|
362
387
|
portfolio = trade_proposal.portfolio
|
|
363
388
|
instrument = instrument_factory.create(currency=portfolio.currency)
|
|
389
|
+
underlying_quote_price = instrument_price_factory.create(instrument=instrument, date=trade_proposal.trade_date)
|
|
390
|
+
mock_fct.return_value = Decimal(1_000_000) # 1 million cash
|
|
364
391
|
trade = trade_factory.create(
|
|
365
392
|
trade_proposal=trade_proposal,
|
|
366
393
|
transaction_date=trade_proposal.trade_date,
|
|
@@ -368,17 +395,19 @@ class TestTradeProposal:
|
|
|
368
395
|
underlying_instrument=instrument,
|
|
369
396
|
)
|
|
370
397
|
trade.refresh_from_db()
|
|
371
|
-
underlying_quote_price = instrument_price_factory.create(instrument=instrument, date=trade.transaction_date)
|
|
372
|
-
mock_fct.return_value = Decimal(1_000_000) # 1 million cash
|
|
373
398
|
|
|
374
399
|
# Assert estimated shares are correctly calculated
|
|
375
400
|
assert (
|
|
376
|
-
trade_proposal.get_estimated_shares(
|
|
401
|
+
trade_proposal.get_estimated_shares(
|
|
402
|
+
trade.weighting, trade.underlying_instrument, underlying_quote_price.net_value
|
|
403
|
+
)
|
|
377
404
|
== Decimal(1_000_000) * trade.weighting / underlying_quote_price.net_value
|
|
378
405
|
)
|
|
379
406
|
|
|
380
407
|
@patch.object(Portfolio, "get_total_asset_value")
|
|
381
408
|
def test_get_estimated_target_cash(self, mock_fct, trade_proposal, trade_factory, cash_factory):
|
|
409
|
+
trade_proposal.portfolio.only_weighting = False
|
|
410
|
+
trade_proposal.portfolio.save()
|
|
382
411
|
mock_fct.return_value = Decimal(1_000_000) # 1 million cash
|
|
383
412
|
cash = cash_factory.create(currency=trade_proposal.portfolio.currency)
|
|
384
413
|
trade_factory.create( # equity trade
|
|
@@ -410,3 +439,191 @@ class TestTradeProposal:
|
|
|
410
439
|
tp2 = trade_proposal_factory.create(portfolio=portfolio, trade_date=tp.trade_date - BDay(1))
|
|
411
440
|
instrument.refresh_from_db()
|
|
412
441
|
assert instrument.inception_date == (tp2.trade_date + BDay(1)).date()
|
|
442
|
+
|
|
443
|
+
def test_get_round_lot_size(self, trade_proposal, instrument):
|
|
444
|
+
# without a round lot size, we expect no normalization of shares
|
|
445
|
+
assert trade_proposal.get_round_lot_size(Decimal("66"), instrument) == Decimal("66")
|
|
446
|
+
instrument.round_lot_size = 100
|
|
447
|
+
instrument.save()
|
|
448
|
+
|
|
449
|
+
# if instrument has a round lot size different than 1, we expect different behavior based on whether shares is positive or negative
|
|
450
|
+
assert trade_proposal.get_round_lot_size(Decimal(66.0), instrument) == Decimal("100")
|
|
451
|
+
assert trade_proposal.get_round_lot_size(Decimal(-66.0), instrument) == Decimal(-66.0)
|
|
452
|
+
assert trade_proposal.get_round_lot_size(Decimal(-120), instrument) == Decimal(-200)
|
|
453
|
+
|
|
454
|
+
# exchange can disable rounding based on the lot size
|
|
455
|
+
instrument.exchange.apply_round_lot_size = False
|
|
456
|
+
instrument.exchange.save()
|
|
457
|
+
assert trade_proposal.get_round_lot_size(Decimal("66"), instrument) == Decimal("66")
|
|
458
|
+
|
|
459
|
+
def test_submit_round_lot_size(self, trade_proposal, trade_factory, instrument):
|
|
460
|
+
trade_proposal.portfolio.only_weighting = False
|
|
461
|
+
trade_proposal.portfolio.save()
|
|
462
|
+
instrument.round_lot_size = 100
|
|
463
|
+
instrument.save()
|
|
464
|
+
trade = trade_factory.create(
|
|
465
|
+
status="DRAFT",
|
|
466
|
+
underlying_instrument=instrument,
|
|
467
|
+
shares=70,
|
|
468
|
+
trade_proposal=trade_proposal,
|
|
469
|
+
weighting=Decimal("1.0"),
|
|
470
|
+
)
|
|
471
|
+
warnings = trade_proposal.submit()
|
|
472
|
+
trade_proposal.save()
|
|
473
|
+
assert (
|
|
474
|
+
len(warnings) == 1
|
|
475
|
+
) # ensure that submit returns a warning concerning the rounded trade based on the lot size
|
|
476
|
+
trade.refresh_from_db()
|
|
477
|
+
assert trade.shares == 100 # we expect the share to be transformed from 70 to 100 (lot size of 100)
|
|
478
|
+
|
|
479
|
+
def test_submit_round_fractional_shares(self, trade_proposal, trade_factory, instrument):
|
|
480
|
+
trade_proposal.portfolio.only_weighting = False
|
|
481
|
+
trade_proposal.portfolio.save()
|
|
482
|
+
trade = trade_factory.create(
|
|
483
|
+
status="DRAFT",
|
|
484
|
+
underlying_instrument=instrument,
|
|
485
|
+
shares=5.6,
|
|
486
|
+
trade_proposal=trade_proposal,
|
|
487
|
+
weighting=Decimal("1.0"),
|
|
488
|
+
)
|
|
489
|
+
trade_proposal.submit()
|
|
490
|
+
trade_proposal.save()
|
|
491
|
+
trade.refresh_from_db()
|
|
492
|
+
assert trade.shares == 6 # we expect the fractional share to be rounded
|
|
493
|
+
|
|
494
|
+
def test_ex_post(
|
|
495
|
+
self, instrument_factory, asset_position_factory, instrument_price_factory, trade_proposal_factory, portfolio
|
|
496
|
+
):
|
|
497
|
+
"""
|
|
498
|
+
Tests the ex-post rebalancing mechanism of a portfolio with two instruments.
|
|
499
|
+
Verifies that weights are correctly recalculated after submitting and approving a trade proposal.
|
|
500
|
+
"""
|
|
501
|
+
|
|
502
|
+
# --- Create instruments ---
|
|
503
|
+
msft = instrument_factory.create(currency=portfolio.currency)
|
|
504
|
+
apple = instrument_factory.create(currency=portfolio.currency)
|
|
505
|
+
|
|
506
|
+
# --- Key dates ---
|
|
507
|
+
d1 = date(2025, 6, 24)
|
|
508
|
+
d2 = date(2025, 6, 25)
|
|
509
|
+
d3 = date(2025, 6, 26)
|
|
510
|
+
d4 = date(2025, 6, 27)
|
|
511
|
+
|
|
512
|
+
# --- Create MSFT prices ---
|
|
513
|
+
msft_p1 = instrument_price_factory.create(instrument=msft, date=d1, net_value=Decimal("10"))
|
|
514
|
+
msft_p2 = instrument_price_factory.create(instrument=msft, date=d2, net_value=Decimal("8"))
|
|
515
|
+
msft_p3 = instrument_price_factory.create(instrument=msft, date=d3, net_value=Decimal("12"))
|
|
516
|
+
msft_p4 = instrument_price_factory.create(instrument=msft, date=d4, net_value=Decimal("15")) # noqa
|
|
517
|
+
|
|
518
|
+
# Calculate MSFT returns between dates
|
|
519
|
+
msft_r2 = msft_p2.net_value / msft_p1.net_value - Decimal("1") # noqa
|
|
520
|
+
msft_r3 = msft_p3.net_value / msft_p2.net_value - Decimal("1")
|
|
521
|
+
|
|
522
|
+
# --- Create Apple prices (stable) ---
|
|
523
|
+
apple_p1 = instrument_price_factory.create(instrument=apple, date=d1, net_value=Decimal("100"))
|
|
524
|
+
apple_p2 = instrument_price_factory.create(instrument=apple, date=d2, net_value=Decimal("100"))
|
|
525
|
+
apple_p3 = instrument_price_factory.create(instrument=apple, date=d3, net_value=Decimal("100"))
|
|
526
|
+
apple_p4 = instrument_price_factory.create(instrument=apple, date=d4, net_value=Decimal("100")) # noqa
|
|
527
|
+
|
|
528
|
+
# Apple returns (always 0 since price is stable)
|
|
529
|
+
apple_r2 = apple_p2.net_value / apple_p1.net_value - Decimal("1") # noqa
|
|
530
|
+
apple_r3 = apple_p3.net_value / apple_p2.net_value - Decimal("1")
|
|
531
|
+
|
|
532
|
+
# --- Create positions on d2 ---
|
|
533
|
+
msft_a2 = asset_position_factory.create(
|
|
534
|
+
portfolio=portfolio,
|
|
535
|
+
underlying_quote=msft,
|
|
536
|
+
date=d2,
|
|
537
|
+
initial_shares=10,
|
|
538
|
+
weighting=Decimal("0.44"),
|
|
539
|
+
)
|
|
540
|
+
apple_a2 = asset_position_factory.create(
|
|
541
|
+
portfolio=portfolio,
|
|
542
|
+
underlying_quote=apple,
|
|
543
|
+
date=d2,
|
|
544
|
+
initial_shares=1,
|
|
545
|
+
weighting=Decimal("0.56"),
|
|
546
|
+
)
|
|
547
|
+
|
|
548
|
+
# Check that initial weights sum to 1
|
|
549
|
+
total_weight_d2 = msft_a2.weighting + apple_a2.weighting
|
|
550
|
+
assert total_weight_d2 == pytest.approx(Decimal("1.0"), abs=Decimal("1e-6"))
|
|
551
|
+
|
|
552
|
+
# --- Calculate total portfolio return between d2 and d3 ---
|
|
553
|
+
portfolio_r3 = msft_a2.weighting * (Decimal("1.0") + msft_r3) + apple_a2.weighting * (
|
|
554
|
+
Decimal("1.0") + apple_r3
|
|
555
|
+
)
|
|
556
|
+
|
|
557
|
+
# --- Create positions on d3 with weights adjusted for returns ---
|
|
558
|
+
msft_a3 = asset_position_factory.create(
|
|
559
|
+
portfolio=portfolio,
|
|
560
|
+
underlying_quote=msft,
|
|
561
|
+
date=d3,
|
|
562
|
+
initial_shares=10,
|
|
563
|
+
weighting=msft_a2.weighting * (Decimal("1.0") + msft_r3) / portfolio_r3,
|
|
564
|
+
)
|
|
565
|
+
apple_a3 = asset_position_factory.create(
|
|
566
|
+
portfolio=portfolio,
|
|
567
|
+
underlying_quote=apple,
|
|
568
|
+
date=d3,
|
|
569
|
+
initial_shares=1,
|
|
570
|
+
weighting=apple_a2.weighting * (Decimal("1.0") + apple_r3) / portfolio_r3,
|
|
571
|
+
)
|
|
572
|
+
|
|
573
|
+
# Check that weights on d3 sum to 1
|
|
574
|
+
total_weight_d3 = msft_a3.weighting + apple_a3.weighting
|
|
575
|
+
assert total_weight_d3 == pytest.approx(Decimal("1.0"), abs=Decimal("1e-6"))
|
|
576
|
+
|
|
577
|
+
# --- Create a trade proposal on d3 ---
|
|
578
|
+
trade_proposal = trade_proposal_factory.create(portfolio=portfolio, trade_date=d3)
|
|
579
|
+
trade_proposal.reset_trades()
|
|
580
|
+
|
|
581
|
+
# Retrieve trades for each instrument
|
|
582
|
+
trade_msft = trade_proposal.trades.get(underlying_instrument=msft)
|
|
583
|
+
trade_apple = trade_proposal.trades.get(underlying_instrument=apple)
|
|
584
|
+
|
|
585
|
+
# Check that trade weights are initially zero
|
|
586
|
+
assert trade_msft.weighting == Decimal("0")
|
|
587
|
+
assert trade_apple.weighting == Decimal("0")
|
|
588
|
+
|
|
589
|
+
# --- Adjust trade weights to target 50% each ---
|
|
590
|
+
target_weight = Decimal("0.5")
|
|
591
|
+
trade_msft.weighting = target_weight - msft_a3.weighting
|
|
592
|
+
trade_msft.save()
|
|
593
|
+
|
|
594
|
+
trade_apple.weighting = target_weight - apple_a3.weighting
|
|
595
|
+
trade_apple.save()
|
|
596
|
+
|
|
597
|
+
# --- Check drift factors and effective weights ---
|
|
598
|
+
assert trade_msft.drift_factor == pytest.approx(msft_a3.weighting / msft_a2.weighting, abs=Decimal("1e-6"))
|
|
599
|
+
assert trade_apple.drift_factor == pytest.approx(apple_a3.weighting / apple_a2.weighting, abs=Decimal("1e-6"))
|
|
600
|
+
|
|
601
|
+
assert trade_msft._effective_weight == pytest.approx(msft_a3.weighting, abs=Decimal("1e-6"))
|
|
602
|
+
assert trade_apple._effective_weight == pytest.approx(apple_a3.weighting, abs=Decimal("1e-6"))
|
|
603
|
+
|
|
604
|
+
# Check that the target weight is the sum of drifted weight and adjustment
|
|
605
|
+
assert trade_msft._target_weight == pytest.approx(
|
|
606
|
+
msft_a2.weighting * trade_msft.drift_factor + trade_msft.weighting,
|
|
607
|
+
abs=Decimal("1e-6"),
|
|
608
|
+
)
|
|
609
|
+
assert trade_msft._target_weight == pytest.approx(target_weight, abs=Decimal("1e-6"))
|
|
610
|
+
|
|
611
|
+
assert trade_apple._target_weight == pytest.approx(
|
|
612
|
+
apple_a2.weighting * trade_apple.drift_factor + trade_apple.weighting,
|
|
613
|
+
abs=Decimal("1e-6"),
|
|
614
|
+
)
|
|
615
|
+
assert trade_apple._target_weight == pytest.approx(target_weight, abs=Decimal("1e-6"))
|
|
616
|
+
|
|
617
|
+
# --- Submit and approve the trade proposal ---
|
|
618
|
+
trade_proposal.submit()
|
|
619
|
+
trade_proposal.save()
|
|
620
|
+
trade_proposal.approve()
|
|
621
|
+
trade_proposal.save()
|
|
622
|
+
|
|
623
|
+
# --- Refresh positions after ex-post rebalancing ---
|
|
624
|
+
msft_a3.refresh_from_db()
|
|
625
|
+
apple_a3.refresh_from_db()
|
|
626
|
+
|
|
627
|
+
# Final check that weights have been updated to 50%
|
|
628
|
+
assert msft_a3.weighting == pytest.approx(target_weight, abs=Decimal("1e-6"))
|
|
629
|
+
assert apple_a3.weighting == pytest.approx(target_weight, abs=Decimal("1e-6"))
|
|
@@ -38,30 +38,24 @@ class TestModelPortfolioRebalancing:
|
|
|
38
38
|
def model(self, portfolio, weekday):
|
|
39
39
|
from wbportfolio.rebalancing.models import ModelPortfolioRebalancing
|
|
40
40
|
|
|
41
|
+
PortfolioPortfolioThroughModel.objects.create(
|
|
42
|
+
portfolio=portfolio,
|
|
43
|
+
dependency_portfolio=PortfolioFactory.create(),
|
|
44
|
+
type=PortfolioPortfolioThroughModel.Type.MODEL,
|
|
45
|
+
)
|
|
41
46
|
return ModelPortfolioRebalancing(portfolio, (weekday + BDay(1)).date(), weekday)
|
|
42
47
|
|
|
43
48
|
def test_is_valid(self, portfolio, weekday, model, asset_position_factory, instrument_price_factory):
|
|
44
49
|
assert not model.is_valid()
|
|
45
50
|
asset_position_factory.create(portfolio=model.portfolio, date=model.last_effective_date)
|
|
46
51
|
assert not model.is_valid()
|
|
47
|
-
|
|
48
|
-
PortfolioPortfolioThroughModel.objects.create(
|
|
49
|
-
portfolio=model.portfolio,
|
|
50
|
-
dependency_portfolio=model_portfolio,
|
|
51
|
-
type=PortfolioPortfolioThroughModel.Type.MODEL,
|
|
52
|
-
)
|
|
52
|
+
|
|
53
53
|
a = asset_position_factory.create(portfolio=model.model_portfolio, date=model.last_effective_date)
|
|
54
54
|
assert not model.is_valid()
|
|
55
55
|
instrument_price_factory.create(instrument=a.underlying_quote, date=model.trade_date)
|
|
56
56
|
assert model.is_valid()
|
|
57
57
|
|
|
58
58
|
def test_get_target_portfolio(self, portfolio, weekday, model, asset_position_factory):
|
|
59
|
-
model_portfolio = PortfolioFactory.create()
|
|
60
|
-
PortfolioPortfolioThroughModel.objects.create(
|
|
61
|
-
portfolio=model.portfolio,
|
|
62
|
-
dependency_portfolio=model_portfolio,
|
|
63
|
-
type=PortfolioPortfolioThroughModel.Type.MODEL,
|
|
64
|
-
)
|
|
65
59
|
asset_position_factory(portfolio=portfolio, date=model.last_effective_date) # noise
|
|
66
60
|
asset_position_factory(portfolio=portfolio, date=model.last_effective_date) # noise
|
|
67
61
|
a1 = asset_position_factory(weighting=0.8, portfolio=portfolio.model_portfolio, date=model.last_effective_date)
|
|
@@ -91,7 +85,7 @@ class TestCompositeRebalancing:
|
|
|
91
85
|
transaction_date=model.last_effective_date,
|
|
92
86
|
transaction_subtype=Trade.Type.BUY,
|
|
93
87
|
trade_proposal=trade_proposal,
|
|
94
|
-
weighting=0.7,
|
|
88
|
+
weighting=Decimal(0.7),
|
|
95
89
|
status=Trade.Status.EXECUTED,
|
|
96
90
|
)
|
|
97
91
|
TradeFactory.create(
|
|
@@ -99,7 +93,7 @@ class TestCompositeRebalancing:
|
|
|
99
93
|
transaction_date=model.last_effective_date,
|
|
100
94
|
transaction_subtype=Trade.Type.BUY,
|
|
101
95
|
trade_proposal=trade_proposal,
|
|
102
|
-
weighting=0.3,
|
|
96
|
+
weighting=Decimal(0.3),
|
|
103
97
|
status=Trade.Status.EXECUTED,
|
|
104
98
|
)
|
|
105
99
|
assert not model.is_valid()
|
|
@@ -117,7 +111,7 @@ class TestCompositeRebalancing:
|
|
|
117
111
|
transaction_date=model.last_effective_date,
|
|
118
112
|
transaction_subtype=Trade.Type.BUY,
|
|
119
113
|
trade_proposal=trade_proposal,
|
|
120
|
-
weighting=0.8,
|
|
114
|
+
weighting=Decimal(0.8),
|
|
121
115
|
status=Trade.Status.EXECUTED,
|
|
122
116
|
)
|
|
123
117
|
t2 = TradeFactory.create(
|
|
@@ -125,7 +119,7 @@ class TestCompositeRebalancing:
|
|
|
125
119
|
transaction_date=model.last_effective_date,
|
|
126
120
|
transaction_subtype=Trade.Type.BUY,
|
|
127
121
|
trade_proposal=trade_proposal,
|
|
128
|
-
weighting=0.2,
|
|
122
|
+
weighting=Decimal(0.2),
|
|
129
123
|
status=Trade.Status.EXECUTED,
|
|
130
124
|
)
|
|
131
125
|
target_portfolio = model.get_target_portfolio()
|
|
@@ -1,7 +1,13 @@
|
|
|
1
|
+
from wbcore import serializers as wb_serializers
|
|
1
2
|
from wbcore.contrib.icons import WBIcon
|
|
2
3
|
from wbcore.enums import RequestType
|
|
3
4
|
from wbcore.metadata.configs import buttons as bt
|
|
4
5
|
from wbcore.metadata.configs.buttons.view_config import ButtonViewConfig
|
|
6
|
+
from wbcore.metadata.configs.display import create_simple_display
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class NormalizeSerializer(wb_serializers.Serializer):
|
|
10
|
+
total_cash_weight = wb_serializers.FloatField(default=0, precision=4, percent=True)
|
|
5
11
|
|
|
6
12
|
|
|
7
13
|
class TradeProposalButtonConfig(ButtonViewConfig):
|
|
@@ -41,10 +47,12 @@ class TradeProposalButtonConfig(ButtonViewConfig):
|
|
|
41
47
|
icon=WBIcon.EDIT.icon,
|
|
42
48
|
label="Normalize Trades",
|
|
43
49
|
description_fields="""
|
|
44
|
-
<p>Make sure all trades normalize to a total target weight of 100%</p>
|
|
50
|
+
<p>Make sure all trades normalize to a total target weight of (100 - {{total_cash_weight}})%</p>
|
|
45
51
|
""",
|
|
46
52
|
action_label="Normalize Trades",
|
|
47
53
|
title="Normalize Trades",
|
|
54
|
+
serializer=NormalizeSerializer,
|
|
55
|
+
instance_display=create_simple_display([["total_cash_weight"]]),
|
|
48
56
|
),
|
|
49
57
|
bt.ActionButton(
|
|
50
58
|
method=RequestType.PATCH,
|
|
@@ -77,11 +77,12 @@ class TradeProposalDisplayConfig(DisplayViewConfig):
|
|
|
77
77
|
layouts={
|
|
78
78
|
default(): Layout(
|
|
79
79
|
grid_template_areas=[
|
|
80
|
-
["status", "status"
|
|
81
|
-
["trade_date", "
|
|
80
|
+
["status", "status"],
|
|
81
|
+
["trade_date", "total_cash_weight"],
|
|
82
|
+
["rebalancing_model", "target_portfolio"]
|
|
82
83
|
if self.view.new_mode
|
|
83
|
-
else ["
|
|
84
|
-
["comment", "comment"
|
|
84
|
+
else ["rebalancing_model", "rebalancing_model"],
|
|
85
|
+
["comment", "comment"],
|
|
85
86
|
],
|
|
86
87
|
),
|
|
87
88
|
},
|
|
@@ -357,6 +357,42 @@ class TradeTradeProposalDisplayConfig(DisplayViewConfig):
|
|
|
357
357
|
],
|
|
358
358
|
)
|
|
359
359
|
)
|
|
360
|
+
fields.append(
|
|
361
|
+
dp.Field(
|
|
362
|
+
label="Total Value",
|
|
363
|
+
open_by_default=False,
|
|
364
|
+
key=None,
|
|
365
|
+
children=[
|
|
366
|
+
dp.Field(
|
|
367
|
+
key="effective_total_value_fx_portfolio",
|
|
368
|
+
label="Effective Total Value",
|
|
369
|
+
show="open",
|
|
370
|
+
width=Unit.PIXEL(150),
|
|
371
|
+
),
|
|
372
|
+
dp.Field(
|
|
373
|
+
key="target_total_value_fx_portfolio",
|
|
374
|
+
label="Target Total Value",
|
|
375
|
+
show="open",
|
|
376
|
+
width=Unit.PIXEL(150),
|
|
377
|
+
),
|
|
378
|
+
dp.Field(
|
|
379
|
+
key="total_value_fx_portfolio",
|
|
380
|
+
label="Total Value",
|
|
381
|
+
formatting_rules=[
|
|
382
|
+
dp.FormattingRule(
|
|
383
|
+
style={"color": WBColor.RED_DARK.value, "fontWeight": "bold"},
|
|
384
|
+
condition=("<", 0),
|
|
385
|
+
),
|
|
386
|
+
dp.FormattingRule(
|
|
387
|
+
style={"color": WBColor.GREEN_DARK.value, "fontWeight": "bold"},
|
|
388
|
+
condition=(">", 0),
|
|
389
|
+
),
|
|
390
|
+
],
|
|
391
|
+
width=Unit.PIXEL(150),
|
|
392
|
+
),
|
|
393
|
+
],
|
|
394
|
+
)
|
|
395
|
+
)
|
|
360
396
|
fields.append(
|
|
361
397
|
dp.Field(
|
|
362
398
|
label="Information",
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
from contextlib import suppress
|
|
2
2
|
from datetime import date
|
|
3
|
+
from decimal import Decimal
|
|
3
4
|
|
|
4
5
|
from django.contrib.messages import info, warning
|
|
5
6
|
from django.shortcuts import get_object_or_404
|
|
@@ -106,8 +107,9 @@ class TradeProposalModelViewSet(CloneMixin, RiskCheckViewSetMixin, InternalUserP
|
|
|
106
107
|
@action(detail=True, methods=["PATCH"])
|
|
107
108
|
def normalize(self, request, pk=None):
|
|
108
109
|
trade_proposal = get_object_or_404(TradeProposal, pk=pk)
|
|
110
|
+
total_cash_weight = Decimal(request.data.get("total_cash_weight", Decimal("0.0")))
|
|
109
111
|
if trade_proposal.status == TradeProposal.Status.DRAFT:
|
|
110
|
-
trade_proposal.normalize_trades()
|
|
112
|
+
trade_proposal.normalize_trades(total_target_weight=Decimal("1.0") - total_cash_weight)
|
|
111
113
|
return Response({"send": True})
|
|
112
114
|
return Response({"status": "Trade proposal is not Draft"}, status=status.HTTP_400_BAD_REQUEST)
|
|
113
115
|
|
|
@@ -25,7 +25,6 @@ from wbcore.permissions.permissions import InternalUserPermissionMixin
|
|
|
25
25
|
from wbcore.utils.strings import format_number
|
|
26
26
|
from wbcore.viewsets.mixins import OrderableMixin
|
|
27
27
|
from wbcrm.models import Account
|
|
28
|
-
from wbfdm.models import Cash
|
|
29
28
|
|
|
30
29
|
from wbportfolio.filters import (
|
|
31
30
|
SubscriptionRedemptionFilterSet,
|
|
@@ -395,6 +394,10 @@ class TradeTradeProposalModelViewSet(
|
|
|
395
394
|
def trade_proposal(self):
|
|
396
395
|
return get_object_or_404(TradeProposal, pk=self.kwargs["trade_proposal_id"])
|
|
397
396
|
|
|
397
|
+
@cached_property
|
|
398
|
+
def portfolio_total_asset_value(self):
|
|
399
|
+
return self.trade_proposal.portfolio_total_asset_value
|
|
400
|
+
|
|
398
401
|
def has_import_permission(self, request) -> bool: # allow import only on draft trade proposal
|
|
399
402
|
return super().has_import_permission(request) and self.trade_proposal.status == TradeProposal.Status.DRAFT
|
|
400
403
|
|
|
@@ -409,46 +412,36 @@ class TradeTradeProposalModelViewSet(
|
|
|
409
412
|
def get_aggregates(self, queryset, *args, **kwargs):
|
|
410
413
|
agg = {}
|
|
411
414
|
if queryset.exists():
|
|
412
|
-
cash_target_position = self.trade_proposal.get_estimated_target_cash(
|
|
413
|
-
self.trade_proposal.portfolio.currency
|
|
414
|
-
)
|
|
415
|
-
cash_target_cash_weight, cash_target_cash_shares = (
|
|
416
|
-
cash_target_position.weighting,
|
|
417
|
-
cash_target_position.initial_shares,
|
|
418
|
-
)
|
|
419
|
-
extra_existing_cash_components = Cash.objects.filter(
|
|
420
|
-
id__in=self.trade_proposal.trades.filter(underlying_instrument__is_cash=True).values(
|
|
421
|
-
"underlying_instrument"
|
|
422
|
-
)
|
|
423
|
-
).exclude(currency=self.trade_proposal.portfolio.currency)
|
|
424
|
-
|
|
425
|
-
for cash_component in extra_existing_cash_components:
|
|
426
|
-
extra_cash_position = self.trade_proposal.get_estimated_target_cash(cash_component.currency)
|
|
427
|
-
cash_target_cash_weight += extra_cash_position.weighting
|
|
428
|
-
cash_target_cash_shares += extra_cash_position.initial_shares
|
|
429
415
|
noncash_aggregates = queryset.filter(underlying_instrument__is_cash=False).aggregate(
|
|
430
416
|
sum_target_weight=Sum(F("target_weight")),
|
|
431
417
|
sum_effective_weight=Sum(F("effective_weight")),
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
)
|
|
435
|
-
cash_aggregates = queryset.filter(underlying_instrument__is_cash=True).aggregate(
|
|
436
|
-
sum_effective_weight=Sum(F("effective_weight")),
|
|
437
|
-
sum_effective_shares=Sum(F("effective_shares")),
|
|
418
|
+
sum_target_total_value_fx_portfolio=Sum(F("target_total_value_fx_portfolio")),
|
|
419
|
+
sum_effective_total_value_fx_portfolio=Sum(F("effective_total_value_fx_portfolio")),
|
|
438
420
|
)
|
|
421
|
+
|
|
439
422
|
# weights aggregates
|
|
440
|
-
cash_sum_effective_weight =
|
|
423
|
+
cash_sum_effective_weight = Decimal("1.0") - noncash_aggregates["sum_effective_weight"]
|
|
424
|
+
cash_sum_target_cash_weight = Decimal("1.0") - noncash_aggregates["sum_target_weight"]
|
|
441
425
|
noncash_sum_effective_weight = noncash_aggregates["sum_effective_weight"] or Decimal(0)
|
|
442
426
|
noncash_sum_target_weight = noncash_aggregates["sum_target_weight"] or Decimal(0)
|
|
443
427
|
sum_buy_weight = queryset.filter(weighting__gte=0).aggregate(s=Sum(F("weighting")))["s"] or Decimal(0)
|
|
444
428
|
sum_sell_weight = queryset.filter(weighting__lt=0).aggregate(s=Sum(F("weighting")))["s"] or Decimal(0)
|
|
445
429
|
|
|
446
430
|
# shares aggregates
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
431
|
+
cash_sum_effective_total_value_fx_portfolio = cash_sum_effective_weight * self.portfolio_total_asset_value
|
|
432
|
+
cash_sum_target_total_value_fx_portfolio = cash_sum_target_cash_weight * self.portfolio_total_asset_value
|
|
433
|
+
noncash_sum_effective_total_value_fx_portfolio = noncash_aggregates[
|
|
434
|
+
"sum_effective_total_value_fx_portfolio"
|
|
435
|
+
] or Decimal(0)
|
|
436
|
+
noncash_sum_target_total_value_fx_portfolio = noncash_aggregates[
|
|
437
|
+
"sum_target_total_value_fx_portfolio"
|
|
438
|
+
] or Decimal(0)
|
|
439
|
+
sum_buy_total_value_fx_portfolio = queryset.filter(total_value_fx_portfolio__gte=0).aggregate(
|
|
440
|
+
s=Sum(F("total_value_fx_portfolio"))
|
|
441
|
+
)["s"] or Decimal(0)
|
|
442
|
+
sum_sell_total_value_fx_portfolio = queryset.filter(total_value_fx_portfolio__lt=0).aggregate(
|
|
443
|
+
s=Sum(F("total_value_fx_portfolio"))
|
|
444
|
+
)["s"] or Decimal(0)
|
|
452
445
|
|
|
453
446
|
agg = {
|
|
454
447
|
"effective_weight": {
|
|
@@ -457,29 +450,38 @@ class TradeTradeProposalModelViewSet(
|
|
|
457
450
|
"Total": format_number(noncash_sum_effective_weight + cash_sum_effective_weight, decimal=6),
|
|
458
451
|
},
|
|
459
452
|
"target_weight": {
|
|
460
|
-
"Cash": format_number(
|
|
453
|
+
"Cash": format_number(cash_sum_target_cash_weight, decimal=6),
|
|
461
454
|
"Non-Cash": format_number(noncash_sum_target_weight, decimal=6),
|
|
462
|
-
"Total": format_number(
|
|
455
|
+
"Total": format_number(cash_sum_target_cash_weight + noncash_sum_target_weight, decimal=6),
|
|
463
456
|
},
|
|
464
|
-
"
|
|
465
|
-
"Cash": format_number(
|
|
466
|
-
"Non-Cash": format_number(
|
|
467
|
-
"Total": format_number(
|
|
457
|
+
"effective_total_value_fx_portfolio": {
|
|
458
|
+
"Cash": format_number(cash_sum_effective_total_value_fx_portfolio, decimal=6),
|
|
459
|
+
"Non-Cash": format_number(noncash_sum_effective_total_value_fx_portfolio, decimal=6),
|
|
460
|
+
"Total": format_number(
|
|
461
|
+
cash_sum_effective_total_value_fx_portfolio + noncash_sum_effective_total_value_fx_portfolio,
|
|
462
|
+
decimal=6,
|
|
463
|
+
),
|
|
468
464
|
},
|
|
469
|
-
"
|
|
470
|
-
"Cash": format_number(
|
|
471
|
-
"Non-Cash": format_number(
|
|
472
|
-
"Total": format_number(
|
|
465
|
+
"target_total_value_fx_portfolio": {
|
|
466
|
+
"Cash": format_number(cash_sum_target_total_value_fx_portfolio, decimal=6),
|
|
467
|
+
"Non-Cash": format_number(noncash_sum_target_total_value_fx_portfolio, decimal=6),
|
|
468
|
+
"Total": format_number(
|
|
469
|
+
cash_sum_target_total_value_fx_portfolio + noncash_sum_target_total_value_fx_portfolio,
|
|
470
|
+
decimal=6,
|
|
471
|
+
),
|
|
473
472
|
},
|
|
474
473
|
"weighting": {
|
|
475
|
-
"Cash Flow": format_number(
|
|
474
|
+
"Cash Flow": format_number(cash_sum_target_cash_weight - cash_sum_effective_weight, decimal=6),
|
|
476
475
|
"Buy": format_number(sum_buy_weight, decimal=6),
|
|
477
476
|
"Sell": format_number(sum_sell_weight, decimal=6),
|
|
478
477
|
},
|
|
479
|
-
"
|
|
480
|
-
"Cash Flow": format_number(
|
|
481
|
-
|
|
482
|
-
|
|
478
|
+
"total_value_fx_portfolio": {
|
|
479
|
+
"Cash Flow": format_number(
|
|
480
|
+
cash_sum_target_total_value_fx_portfolio - cash_sum_effective_total_value_fx_portfolio,
|
|
481
|
+
decimal=6,
|
|
482
|
+
),
|
|
483
|
+
"Buy": format_number(sum_buy_total_value_fx_portfolio, decimal=6),
|
|
484
|
+
"Sell": format_number(sum_sell_total_value_fx_portfolio, decimal=6),
|
|
483
485
|
},
|
|
484
486
|
}
|
|
485
487
|
|
|
@@ -509,6 +511,7 @@ class TradeTradeProposalModelViewSet(
|
|
|
509
511
|
qs = super().get_queryset().filter(trade_proposal=self.kwargs["trade_proposal_id"]).annotate_base_info()
|
|
510
512
|
else:
|
|
511
513
|
qs = TradeProposal.objects.none()
|
|
514
|
+
|
|
512
515
|
return qs.annotate(
|
|
513
516
|
underlying_instrument_isin=F("underlying_instrument__isin"),
|
|
514
517
|
underlying_instrument_ticker=F("underlying_instrument__ticker"),
|
|
@@ -520,4 +523,9 @@ class TradeTradeProposalModelViewSet(
|
|
|
520
523
|
),
|
|
521
524
|
default=F("underlying_instrument__instrument_type__short_name"),
|
|
522
525
|
),
|
|
526
|
+
effective_total_value_fx_portfolio=F("effective_weight") * Value(self.portfolio_total_asset_value),
|
|
527
|
+
target_total_value_fx_portfolio=F("target_weight") * Value(self.portfolio_total_asset_value),
|
|
528
|
+
portfolio_currency=F("portfolio__currency__symbol"),
|
|
529
|
+
security=F("underlying_instrument__parent"),
|
|
530
|
+
company=F("underlying_instrument__parent__parent"),
|
|
523
531
|
)
|