Skip to content

Commit 6b84f3d

Browse files
committed
feat: implemented holdrules
1 parent dbd70f1 commit 6b84f3d

File tree

17 files changed

+472
-176
lines changed

17 files changed

+472
-176
lines changed

README.md

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -90,8 +90,7 @@ Once a feature's PR is merged, the pipeline will run checks and publish.
9090
## Missing features
9191

9292
1. delay 1st mortgage payment
93-
2. implement hold rules
94-
3. apartments (passive investor)
93+
2. apartments (passive investor)
9594

9695
## Future
9796

@@ -107,6 +106,7 @@ import {
107106
ISimulateOptions,
108107
LedgerCollection,
109108
LoanSettings,
109+
HoldRuleTypes,
110110
PropertyType,
111111
RentalGenerator,
112112
RentalSingleFamily,
@@ -121,6 +121,13 @@ const options: ISimulateOptions = {
121121
amountInSavings: 100000,
122122
monthlyIncomeAmountGoal: 10000,
123123
monthlySavedAmount: 10000,
124+
holdRules: [
125+
{
126+
value: 5,
127+
type: HoldRuleTypes.minSellIfHighEquityPercent,
128+
propertyType: PropertyType.SingleFamily,
129+
},
130+
],
124131
purchaseRules: [
125132
{ value: 30000, type: PurchaseRuleTypes.maxEstimatedOutOfPocket },
126133
{ value: 7000, type: PurchaseRuleTypes.minEstimatedCapitalGains },

src/account/user.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { PurchaseRuleTypes } from '../rules/purchase-rule-types';
88
import { IRuleEvaluation } from '../rules/rule-evaluation';
99
import cloneDeep from 'lodash.clonedeep';
1010
import { ILedgerSummary } from '../ledger/i-ledger-summary';
11+
import { HoldRuleTypes } from '../rules/hold-rule-types';
1112

1213
/**
1314
* It's the user... as an interface!
@@ -53,6 +54,11 @@ export interface IUser extends IUserInvestorCheck {
5354
*/
5455
getSummariesAnnual(year: number): ILedgerSummary[];
5556

57+
/**
58+
* a system to determine how to hold onto the properties the longest. This scenario says as long as it meets 1 rule
59+
*/
60+
holdRules: IRuleEvaluation<HoldRuleTypes>[];
61+
5662
/**
5763
* a system to weed out the properties you don't want. This scenario says as long as it meets 1 rule
5864
*/
@@ -94,6 +100,11 @@ export class User implements IUser {
94100
this.ledgerCollection = ledgerCollection;
95101
}
96102

103+
/**
104+
* a system to determine how to hold onto the properties the longest. This scenario says as long as it meets 1 rule
105+
*/
106+
holdRules: IRuleEvaluation<HoldRuleTypes>[];
107+
97108
/**
98109
* a system to weed out the properties you don't want. This scenario says as long as it meets 1 rule
99110
*/

src/calculations/can-invest-by-user.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,7 @@ export function canInvestByUser(
6969
}
7070

7171
// 1. need to map to rule to property, eg: PurchaseRuleTypes.minEstimatedCashFlowPerMonth > this.monthlyCashFlow;
72-
const reasons = getInvestmentReasons<IRentalPropertyEntity>(rental).filter(
72+
const reasons = getInvestmentReasons<IRentalPropertyEntity, PurchaseRuleTypes>(rental).filter(
7373
(r) => r.ruleType !== PurchaseRuleTypes.none
7474
);
7575

src/investments/investment-reasons-decorator.ts

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,13 @@ import 'reflect-metadata';
33
import { IRentalPropertyEntity } from '../properties/i-rental-property-entity';
44
import { InvestmentReasons } from './investment-reasons';
55
import { PurchaseRuleTypes } from '../rules/purchase-rule-types';
6+
import { HoldRuleTypes } from '../rules/hold-rule-types';
67

78
export type PropertyDecoratorType<T extends IRentalPropertyEntity> = (target: T, propertyKey: keyof T & string) => any;
89

9-
export interface IReasonToRule<T extends IRentalPropertyEntity> {
10+
export interface IReasonToRule<T extends IRentalPropertyEntity, TR extends PurchaseRuleTypes | HoldRuleTypes> {
1011
investmentReason: InvestmentReasons;
11-
ruleType: PurchaseRuleTypes;
12+
ruleType: TR;
1213
propertyKey: keyof T & string;
1314
value: T[keyof T] & number;
1415
}
@@ -17,14 +18,14 @@ export interface IReasonToRule<T extends IRentalPropertyEntity> {
1718
* used to set up properties to help map results to InvestmentReasons
1819
* @constructor
1920
* @param investmentReason
20-
* @param [ruleType=PurchaseRuleTypes.none]
21+
* @param ruleType
2122
*/
2223
export function InvestmentReason(
2324
investmentReason: InvestmentReasons,
24-
ruleType: PurchaseRuleTypes = PurchaseRuleTypes.none
25+
ruleType: PurchaseRuleTypes | HoldRuleTypes
2526
): PropertyDecoratorType<IRentalPropertyEntity> {
2627
return (target: IRentalPropertyEntity, propertyKey: keyof IRentalPropertyEntity & string): any => {
27-
const saveItem: IReasonToRule<IRentalPropertyEntity> = {
28+
const saveItem: IReasonToRule<IRentalPropertyEntity, PurchaseRuleTypes | HoldRuleTypes> = {
2829
investmentReason,
2930
ruleType,
3031
propertyKey,
@@ -56,7 +57,10 @@ export function InvestmentReason(
5657
* @param target
5758
* @constructor
5859
*/
59-
export function getInvestmentReasons<T extends IRentalPropertyEntity & object>(target: T): IReasonToRule<T>[] {
60+
export function getInvestmentReasons<
61+
T extends IRentalPropertyEntity & object,
62+
TR extends PurchaseRuleTypes | HoldRuleTypes
63+
>(target: T): IReasonToRule<T, TR>[] {
6064
const keys = Reflect.getMetadata('design:type', target) as string[];
6165

6266
return keys.map((k) => {

src/properties/property-sort.ts

Lines changed: 10 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,24 +2,25 @@ import { IRentalPropertyEntity } from './i-rental-property-entity';
22
import { PurchaseRuleTypes } from '../rules/purchase-rule-types';
33
import { getInvestmentReasons, InvestmentReasons } from '../investments';
44
import { IRuleEvaluation } from '../rules/rule-evaluation';
5+
import { HoldRuleTypes } from '../rules/hold-rule-types';
56

67
/**
78
* property sort based on rules order and property value
89
* @param propertyA
910
* @param propertyB
10-
* @param purchaseRules
11+
* @param rule
1112
*/
12-
export default function propertySort(
13+
export default function propertySort<T extends PurchaseRuleTypes | HoldRuleTypes>(
1314
propertyA: IRentalPropertyEntity,
1415
propertyB: IRentalPropertyEntity,
15-
purchaseRules: IRuleEvaluation<PurchaseRuleTypes>[]
16+
rule: IRuleEvaluation<T>[]
1617
): number {
17-
if (!purchaseRules || purchaseRules.length === 0) {
18+
if (!rule || rule.length === 0) {
1819
return -1;
1920
}
2021

21-
const reasonsA = getInvestmentReasons<IRentalPropertyEntity>(propertyA);
22-
const reasonsB = getInvestmentReasons<IRentalPropertyEntity>(propertyB);
22+
const reasonsA = getInvestmentReasons<IRentalPropertyEntity, T>(propertyA);
23+
const reasonsB = getInvestmentReasons<IRentalPropertyEntity, T>(propertyB);
2324

2425
if (reasonsA.length > 0 && reasonsB.length === 0) {
2526
return -1;
@@ -29,8 +30,8 @@ export default function propertySort(
2930
return 1;
3031
}
3132

32-
for (let i = 0; i < purchaseRules.length; i++) {
33-
const r = purchaseRules[i];
33+
for (let i = 0; i < rule.length; i++) {
34+
const r = rule[i];
3435
const reasonsItemA = reasonsA.find((s) => s.ruleType === r.type) || {
3536
ruleType: PurchaseRuleTypes.none,
3637
descriptor: { value: 0 },
@@ -42,6 +43,7 @@ export default function propertySort(
4243
ruleType: PurchaseRuleTypes.none,
4344
descriptor: { value: 0 },
4445
propertyKey: '',
46+
value: -1,
4547
investmentReason: InvestmentReasons.Unknown,
4648
};
4749

src/properties/rental-single-family.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import { getCashDown, getSellPriceEstimate } from '../calculations/get-monthly-m
1313
import { cloneDateUtc } from '../utils/data-clone-date';
1414
import areSameDate from '../utils/data-are-same-date';
1515
import compareDates from '../utils/data-compare-date';
16+
import { HoldRuleTypes } from '../rules/hold-rule-types';
1617

1718
export class RentalSingleFamily implements IEntityExistence, IRentalSavings, IRentalPropertyEntity {
1819
/**
@@ -227,5 +228,6 @@ export class RentalSingleFamily implements IEntityExistence, IRentalSavings, IRe
227228
}
228229

229230
@InvestmentReason(InvestmentReasons.DoesNotMeetUserRuleCashOnCash, PurchaseRuleTypes.minEstimatedCashFlowPerMonth)
231+
@InvestmentReason(InvestmentReasons.DoesNotMeetUserRuleCashOnCash, HoldRuleTypes.minSellIfLowCashFlowPercent)
230232
monthlyCashFlow: number;
231233
}

src/rules/hold-rule-types.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ export enum HoldRuleTypes {
44
*/
55
none = 'none',
66
/**
7-
* this rule is states that if you have a low cash flow %, then you'd like to sell it
7+
* this rule states that if you have a low cash flow %, then you'd like to sell it
88
*/
99
minSellIfLowCashFlowPercent = 'minSellIfLowCashFlowPercent',
1010

src/time/movement.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import { ILoanSetting } from '../account/i-loan-settings';
1313
import { PropertyType } from '../account/property-type';
1414
import { IRuleEvaluation } from '../rules/rule-evaluation';
1515
import { PurchaseRuleTypes } from '../rules/purchase-rule-types';
16+
import { HoldRuleTypes } from '../rules/hold-rule-types';
1617

1718
export interface ILoopOptions {
1819
/**
@@ -62,6 +63,10 @@ export function loop(options: ILoopOptions, user: IUser): ITimeline {
6263
predicate: (item) => item.propertyType === PropertyType.SingleFamily,
6364
message: 'no single family purchase rules for user: purchaseRules',
6465
});
66+
ensureArray<IRuleEvaluation<HoldRuleTypes>>(user.holdRules, {
67+
predicate: (item) => item.propertyType === PropertyType.SingleFamily,
68+
message: 'no single family purchase rules for user: holdRules',
69+
});
6570

6671
let today = cloneDateUtc(options.startDate);
6772

@@ -120,6 +125,7 @@ export function loop(options: ILoopOptions, user: IUser): ITimeline {
120125
//step 3: sell properties
121126
result.rentals
122127
.filter((r) => r.property.canSell(today))
128+
.sort((a, b) => propertySort<HoldRuleTypes>(a.property, b.property, user.holdRules))
123129
.forEach((pr) => {
124130
pr.property.soldDate = cloneDateUtc(today);
125131

@@ -161,7 +167,7 @@ export function loop(options: ILoopOptions, user: IUser): ITimeline {
161167
})
162168
.filter((r) => r.validator.canInvest)
163169
.map((r) => r.historical)
164-
.sort((a, b) => propertySort(a.property, b.property, user.purchaseRules))
170+
.sort((a, b) => propertySort<PurchaseRuleTypes>(a.property, b.property, user.purchaseRules))
165171
.forEach((pr) => {
166172
// check cash
167173
if (user.hasMoneyToInvest(today)) {

src/time/simulate.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import { LedgerCollection, LedgerItem, LedgerItemType } from '../ledger';
1313
import { ValueCache } from '../caching/value-cache';
1414
import { loop } from './movement';
1515
import { cloneDateUtc } from '../utils/data-clone-date';
16+
import { HoldRuleTypes } from '../rules/hold-rule-types';
1617

1718
export interface ISimulateOptions extends IPropertyEntityOptions {
1819
/**
@@ -35,6 +36,11 @@ export interface ISimulateOptions extends IPropertyEntityOptions {
3536
*/
3637
loanSettings: ILoanSetting[];
3738

39+
/**
40+
* a system to determine how to hold onto the properties the longest. This scenario says as long as it meets 1 rule
41+
*/
42+
holdRules: IRule<HoldRuleTypes>[];
43+
3844
/**
3945
* This is how to prioritize the properties that come up. Use one, or use all rules! The order you put them in here, is the order it evaluates them as. {@link PurchaseRuleTypes} for possible rules
4046
*/
@@ -88,6 +94,7 @@ export function simulate(options: ISimulateOptions): ITimeline {
8894
user.monthlySavedAmount = options.monthlySavedAmount;
8995
user.monthlyIncomeAmountGoal = options.monthlyIncomeAmountGoal;
9096
user.loanSettings = options.loanSettings;
97+
user.holdRules = (options.holdRules || []).map((r) => new RuleEvaluation(r.value, r.type, r.propertyType));
9198
user.purchaseRules = (options.purchaseRules || []).map((r) => new RuleEvaluation(r.value, r.type, r.propertyType));
9299

93100
return loop(

tests/calculations/can-invest-by-user.spec.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,7 @@ describe('and canInvestByUser', () => {
6969
expect(canInvestByUser(instance, user, null, null)).toMatchObject(expected);
7070
});
7171
});
72+
7273
describe('and ledger.hasMinimumSavings is true', () => {
7374
test('and no rules', () => {
7475
const user: IUserInvestorCheck = {

0 commit comments

Comments
 (0)