Skip to content

Commit 25b90f0

Browse files
committed
add UT for new proj flow
1 parent 9600b27 commit 25b90f0

File tree

5 files changed

+127
-41
lines changed

5 files changed

+127
-41
lines changed

absbox/local/component.py

Lines changed: 9 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1023,6 +1023,8 @@ def mkOrder(x):
10231023
return mkTag("ByStartDate")
10241024
case ("byName", *names):
10251025
return mkTag(("ByCustomNames", vList(names, str)))
1026+
case ("reverse", order):
1027+
return mkTag(("ReverseSeq", mkOrder(order)))
10261028

10271029

10281030
def mkAction(x:list):
@@ -1337,7 +1339,7 @@ def mkTriggerEffect(x):
13371339
return mkTag(("AddTrigger", mkTrigger(trg)))
13381340
case ["新储备目标", accName, newReserve] | ["newReserveBalance", accName, newReserve] | ("newReserveBalance", accName, newReserve):
13391341
return mkTag(("ChangeReserveBalance", [vStr(accName), mkAccType(newReserve)]))
1340-
case ["结果", *efs] | ["Effects", *efs] | ("Effects", *efs):
1342+
case ["结果", *efs] | ["Effects", *efs] | ("Effects", *efs) |("effects", *efs) :
13411343
return mkTag(("TriggerEffects", [mkTriggerEffect(e) for e in efs]))
13421344
case ("changeBondRate", bndName, bondRateType, newRate):
13431345
return mkTag(("ChangeBondRate", [vStr(bndName), mkBondRate(bondRateType), vNum(newRate)]))
@@ -1660,13 +1662,9 @@ def mkAsset(x):
16601662
,mkAssetStatus(status)]))
16611663
case ["Invoice", {"start":sd,"originBalance":ob,"originAdvance":oa,"dueDate":dd},{"status":status}] :
16621664
obligorInfo = getValWithKs(x[1],["obligor","借款人"], mapping=mkObligor)
1663-
return mkTag(("Invoice",[{"startDate":vDate(sd),"originBalance":vNum(ob),"originAdvance":vNum(oa),"dueDate":vDate(dd),"feeType":None,"obligor":obligorInfo} | mkTag("ReceivableInfo")
1664-
,mkAssetStatus(status)]))
1665-
case ["ProjectedFlowFix", cf, dp]:
1666-
return mkTag(("ProjectedFlowFixed",[mkCashFlowFrame(cf) ,mkDatePattern(dp)]))
1667-
case ['ProjectedFlowMix', cf, dp, fixPct, floatPcts]:
1668-
return mkTag(("ProjectedFlowMixFloater",[mkCashFlowFrame(cf) ,mkDatePattern(dp)
1669-
, fixPct, floatPcts]))
1665+
return mkTag(("Invoice",[{"startDate":vDate(sd),"originBalance":vNum(ob),"originAdvance":vNum(oa),"dueDate":vDate(dd),"feeType":None,"obligor":obligorInfo} | mkTag("ReceivableInfo") ,mkAssetStatus(status)]))
1666+
case ["ProjectedByFactor", cfs, dp, fixPct, floatPcts] :
1667+
return mkTag(("ProjectedByFactor" ,[cfs, mkDatePattern(dp), fixPct, floatPcts]))
16701668
case _:
16711669
raise RuntimeError(f"Failed to match {x}:mkAsset")
16721670

@@ -1692,7 +1690,7 @@ def id_by_pool_assets(z):
16921690
return "FDeal"
16931691
case {"assets": [{'tag': 'Invoice'}, *rest]}:
16941692
return "VDeal"
1695-
case {"assets": [{'tag': 'ProjectedFlowMix'}, *rest]} | {"assets": [{'tag': 'ProjectedFlowMixFloater'}, *rest]}:
1693+
case {"assets": [{'tag': 'ProjectedByFactor'}, *rest]} :
16961694
return "PDeal"
16971695
case {"assets": [{'tag': 'IL'}, *rest]} | {"assets": [{'tag': 'MO'}, *rest]} | \
16981696
{"assets": [{'tag': 'LO'}, *rest]} | {"assets": [{'tag': 'LS'}, *rest]} | \
@@ -2156,7 +2154,8 @@ def mkCashFlowFrame(x):
21562154
begBal = x.get("beginBalance",0)
21572155
begDate = x.get("beginDate","1900-01-01")
21582156
accInt = x.get("accruedInterest",None)
2159-
return mkTag(("CashFlowFrame", [[begBal,begDate,accInt], [ mkTag(("MortgageFlow", f+[0.0]*5+[None,None,None] )) for f in flows] ]))
2157+
return mkTag(("CashFlowFrame", [[begBal,begDate,accInt]
2158+
, [ mkTag(("MortgageFlow", f+[0.0]*5+[None,None,None] )) for f in flows] ]))
21602159

21612160

21622161
def mkPid(x):

absbox/local/util.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -290,7 +290,7 @@ def inferPoolTypeFromAst(x:dict) -> str:
290290
return "FPool"
291291
case {"assets":[["Invoice",*fields],*ast]} | {'清单': [['应收账款', *fields], *ast]}:
292292
return "VPool"
293-
case {"assets":[["ProjectedFlowMix",*fields],*ast]} | {"assets":[["ProjectedFlowFix",*fields],*ast]} :
293+
case {"assets":[["ProjectedByFactor",*fields],*ast]} :
294294
return "PPool"
295295
case _:
296296
raise RuntimeError(f"Failed to find pool type from assets:{x}")

absbox/tests/regression/deals.py

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -696,9 +696,7 @@
696696
{
697697
"default": [
698698
["accrueAndPayInt", "acc01", ["A1", "A2"]],
699-
["payPrinBySeq", "acc01", ["A1", "A2"]]
700-
# ,['inspect',"isPaidoff",("isPaidOff","A2")]
701-
,
699+
["payPrinBySeq", "acc01", ["A1", "A2"]] ,
702700
["payPrin", "acc01", ["A1"]],
703701
["payIntResidual", "acc01", "B"],
704702
],
@@ -766,7 +764,6 @@
766764
,{"default":[
767765
["accrueAndPayIntByGroup","acc01","A", "byName"]
768766
,["payPrinByGroup","acc01","A", "byName"]
769-
#,["payPrin","acc01",["A1"]]
770767
,["payIntResidual","acc01","B"]
771768
],
772769
}
@@ -984,3 +981,18 @@
984981
None,
985982
"Amortizing",
986983
)
984+
985+
import datetime
986+
from dateutil.relativedelta import relativedelta
987+
988+
989+
cf = [[ (datetime.datetime.strptime("2021-03-01", "%Y-%m-%d") + relativedelta(months=_)).strftime("%Y-%m-%d"), (29-_)*75]
990+
for _ in range(30) ]
991+
992+
fixPct = (1.00,0.07)
993+
# floatPcts = [(0.50, 0.05, 0.02, "LIBOR1M")]
994+
floatPcts = []
995+
projCf = ["ProjectedByFactor", cf, "MonthEnd", fixPct, floatPcts]
996+
997+
test06 = test01 & lens.name.set("TEST06 - ProjectByFactor")\
998+
& lens.pool['assets'].set([projCf])

absbox/tests/regression/test_main.py

Lines changed: 76 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
import re, math, json
66
from pathlib import Path
77
from collections import Counter
8+
from itertools import dropwhile
89

910
from .deals import *
1011
from .assets import *
@@ -43,6 +44,14 @@ def eqDataFrameByLens(a, b, l, fn=None, msg=""):
4344
eqDataFrame(fn(da), fn(db), msg=msg)
4445

4546

47+
def seniorTest(x, y):
48+
xx = pd.concat([x.rename("Left"),y.rename("Right")],axis=1).fillna(0)
49+
xflags = xx["Left"]- xx["Right"] == xx["Left"]
50+
yflags = xx["Right"] - xx["Left"] == xx["Right"]
51+
rflags = xflags | yflags
52+
f = dropwhile( lambda y: not y, dropwhile(lambda x:x ,xflags))
53+
return len(list(f)) == 0 & Counter(rflags).get("False",0) <= 1
54+
4655

4756
@pytest.fixture
4857
def setup_api():
@@ -330,6 +339,69 @@ def test_bondGrp(setup_api):
330339
assert grpFlow.A1.to_list()[:8] == [1000.0, 1000.0, 1000.0, 1000.0, 1000.0, 1000.0, 1000.0, 993.93]
331340
assert grpFlow.A2.to_list()[:8] == [510.6, 436.68, 364.06, 290.75, 217.26, 143.1, 68.74, 0.0,]
332341

342+
assert seniorTest(r['bonds']['A']['A2'].principal,r['bonds']['A']['A1'].principal), "A2 should be senior to A1"
343+
344+
r = setup_api.run(bondGrp & lens.waterfall['default'][1][3].set("byName")
345+
, read=True , runAssump = [])
346+
assert seniorTest(r['bonds']['A']['A1'].principal,r['bonds']['A']['A2'].principal), "byName: A1 should be senior to A2"
347+
348+
r = setup_api.run(bondGrp & lens.waterfall['default'][1][3].set(('reverse',"byName")))
349+
assert seniorTest(r['bonds']['A']['A2'].principal,r['bonds']['A']['A1'].principal), "byName: A2 should be senior to A1"
350+
351+
r = setup_api.run(bondGrp & lens.waterfall['default'][1][3].set("byProrata")
352+
, read=True , runAssump = [])
353+
allEqTo08 = (r['bonds']['A']['A2'].principal / r['bonds']['A']['A1'].principal).round(2) == 0.8
354+
assert all(allEqTo08), "byProrata: A2 should be 0.8 of A1"
355+
356+
r = setup_api.run(bondGrp & lens.waterfall['default'][1][3].set("byCurRate")
357+
& lens.bonds[0][1][1]["A1"]['rate'].set(0.06)
358+
, read=True , runAssump = [])
359+
assert seniorTest(r['bonds']['A']['A2'].principal,r['bonds']['A']['A1'].principal), "A2 should be senior to A1"
360+
361+
r = setup_api.run(bondGrp & lens.waterfall['default'][1][3].set(("reverse", "byCurRate"))
362+
& lens.bonds[0][1][1]["A1"]['rate'].set(0.06)
363+
, read=True , runAssump = [])
364+
assert seniorTest(r['bonds']['A']['A1'].principal,r['bonds']['A']['A2'].principal), "A1 should be senior to A2"
365+
366+
#test by maturity date
367+
d = bondGrp & lens.bonds[0][1][1]["A1"].modify(lambda x: tz.assoc(x,"maturityDate","2026-01-01"))\
368+
& lens.bonds[0][1][1]["A2"].modify(lambda x: tz.assoc(x,"maturityDate","2025-01-01"))\
369+
& lens.waterfall['default'][1][3].set("byMaturity")
370+
371+
r = setup_api.run(d, read=True , runAssump = [])
372+
assert seniorTest(r['bonds']['A']['A2'].principal,r['bonds']['A']['A1'].principal), "A2 should be senior to A1"
373+
374+
d = bondGrp & lens.bonds[0][1][1]["A2"].modify(lambda x: tz.assoc(x,"startDate","2020-01-02"))\
375+
& lens.waterfall['default'][1][3].set("byStartDate")
376+
377+
r = setup_api.run(d, read=True , runAssump = [])
378+
assert seniorTest(r['bonds']['A']['A2'].principal,r['bonds']['A']['A1'].principal), "A2 should be senior to A1"
379+
380+
@pytest.mark.trigger
381+
def test_trigger_chgBondRate(setup_api):
382+
withTrigger = test01 & lens.trigger.set({
383+
"BeforeDistribution":{
384+
"changeBndRt":{"condition":[">=","2021-08-20"]
385+
,"effects":("changeBondRate", "A1", {"floater":[0.05, "SOFR1Y",-0.02,"MonthEnd"]}, 0.12)
386+
,"status":False
387+
,"curable":False}
388+
}
389+
})
390+
r = setup_api.run(withTrigger , read=True ,runAssump = [("interest",("SOFR1Y",0.04))])
391+
assert r['bonds']['A1'].rate.to_list() == [0.07, 0.12, 0.02, 0.02, 0.02, 0.02, 0.02, 0.02, 0.02, 0.02]
392+
393+
@pytest.mark.pool
394+
def test_pool_lease_end(setup_api):
395+
""" Lease end date """
396+
myPool = {'assets':[l1],'cutoffDate':"2022-01-01"}
397+
r = setup_api.runPool(myPool
398+
,poolAssump=("Pool",("Lease", None, ('days', 20) , ('byAnnualRate', 0.0), ("byExtTimes", 2))
399+
,None
400+
,None
401+
)
402+
,read=True)
403+
assert r['PoolConsol']['flow'].index[-1] == '2024-12-15'
404+
333405
@pytest.mark.trigger
334406
def test_trigger_chgBondRate(setup_api):
335407
withTrigger = test01 & lens.trigger.set({
@@ -425,9 +497,6 @@ def test_collect_pool_loanlevel_cashflow(setup_api):
425497
eqDataFrame(rAssetLevel['pool']['flow']['PoolConsol'].drop(columns=["WAC"])
426498
,combined2
427499
,msg="breakdown should be same with aggregation cashflow(with performance)")
428-
# combined = pd.concat([rWithOsPoolFlow['pool']['flow']['PoolConsol']
429-
# ,rWithOsPoolFlow['pool_outstanding']['flow']['PoolConsol']])
430-
# eqDataFrame(complete['pool']['flow']['PoolConsol'], combined)
431500

432501
@pytest.mark.report
433502
def test_reports(setup_api):
@@ -466,7 +535,10 @@ def test_revolving_01(setup_api):
466535
totalPrins = r['pool']['breakdown']['PoolConsol'] & lens.Each().Principal.collect() & lens.Each().modify(lambda x: x.sum())
467536
assert r['pool']['flow']['PoolConsol'].Principal.sum().round(2) == sum(totalPrins).round(2), "Breakdown cashflow should tieout with aggregated pool cashflow"
468537

469-
538+
@pytest.mark.asset
539+
def test_06(setup_api):
540+
r = setup_api.run(test06 , read=True , runAssump = [])
541+
assert r['pool']['flow']['PoolConsol'].Principal.sum() == 2175, "Total principal should be 2175.0"
470542
# @pytest.mark.analytics
471543
# def test_rootfinder_by_formula(setup_api):
472544
# poolPerf = ("Pool",("Mortgage",{"CDR":0.002},{"CPR":0.001},{"Rate":0.1,"Lag":18},None)

docs/source/modeling.rst

Lines changed: 25 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1675,42 +1675,43 @@ syntax
16751675
Projected Cashflow
16761676
^^^^^^^^^^^^^^^^^^^^^^^
16771677
1678-
It's possible that to build a deal model with ``Projected Cashflow`` which means the there is no way to get loan level data of `Assets` but only cashflow derived from these assets.
1678+
.. versionchanged:: 0.51.0
16791679
1680-
There are two type of ``Projected Cashflow``
1680+
It's possible that to build a asset model with ``Projected Cashflow`` which means the there is no way to get loan level data of `Assets` but only projected cashflow derived from these assets.
16811681
1682-
* Fixed Projected Flow -> the cashflow is projected via assets with fix rate.
1683-
* Mixed Projected Flow -> the cashflow is projected via a pool of assets with %x of fixed rate asset and %y1 %y2 ...
1684-
1685-
Fix Rate Flow
1682+
ProjectedByFactor
16861683
""""""""""""""""""""
16871684
1688-
syntax
1689-
``["ProjectedFlowFix" ,{"flows":[["2024-09-01",<Balance>,<Principal>,<Interest>]....] ,"beginDate":"2024-06-01","beginBalance":1000},"MonthEnd"]``
1690-
1691-
* ``MonthEnd``
1692-
1693-
Means if there is default/recovery lag how the cashflow periods extended
1694-
1685+
syntax
1686+
.. code-block:: python
1687+
1688+
["ProjectedByFactor"
1689+
, [["2024-09-01",100]
1690+
,["2024-10-01",50]
1691+
,["2024-11-01",30]
1692+
,["2024-12-01",0]
1693+
]
1694+
,"MonthEnd"
1695+
,(0.5, 0.08)
1696+
,[(0.5, 0.02, "LIBOR1M")]
1697+
]
1698+
* ``[[<Date>, <Balance>]...]``
16951699
1696-
Mix Rate Flow
1697-
""""""""""""""""""""
1700+
Expected balance and dates
16981701
1699-
syntax
1700-
``["ProjectedFlowMix" ,{"flows":[["2024-09-01",100,10,10]] ,"beginDate":"2024-06-01" ,"beginBalance":110} ,"MonthEnd" ,(.5,0.08) ,[(1.0,0.02,"LIBOR1M")]]``
17011702
17021703
* ``(0.5,0.08)``
17031704
17041705
Means, 50% of projected cashflow are attributed to fix rate assets with fix rate of 8%
1705-
* ``[(1.0,0.02,"LIBOR1M")]``
1706+
* ``[(0.5,0.02,"LIBOR1M")]``
17061707
1707-
Means 100% of rest cashflow are generated from floater asset with spread of 2% and index of `LIBOR1M`
1708+
Means 50% of rest cashflow are generated from floater asset with spread of 2% and index of `LIBOR1M`
17081709
1710+
.. warning::
1711+
1712+
Only `Mortgage` assumptions are supported
17091713
1710-
Assumption
1711-
""""""""""""""""""
17121714
1713-
Only `Mortgage` assumptions are supported
17141715
17151716
17161717
Collection Rules
@@ -3404,6 +3405,8 @@ examples:
34043405
* ``"byStartDate"`` -> pay bonds by start date, the earlier start date bond will be paid first
34053406
.. versionadded:: 0.43.1
34063407
* ``("byName", name1, name2...)`` -> pay bonds by order of name1, name2...
3408+
.. versionadded:: 0.50.3
3409+
* ``("reverse", <ordering>)`` -> reverse the order of <ordering>
34073410
34083411
34093412
Trigger

0 commit comments

Comments
 (0)