fix: forecast: Generate forecast transactions at journal finalisation,
rather than as a postprocessing step. (#1638) This allows us to have a uniform procedure for balancing transactions, whether they are normal transactions or forecast transactions, including dealing with balance assignments, balance assertions, and auto postings.
This commit is contained in:
parent
c404800fbf
commit
918c243fa9
@ -20,6 +20,7 @@ module Hledger.Query (
|
||||
-- * parsing
|
||||
parseQuery,
|
||||
parseQueryList,
|
||||
parseQueryTerm,
|
||||
simplifyQuery,
|
||||
filterQuery,
|
||||
-- * accessors
|
||||
|
||||
@ -49,6 +49,8 @@ module Hledger.Read.Common (
|
||||
journalCheckAccountsDeclared,
|
||||
journalCheckCommoditiesDeclared,
|
||||
journalCheckPayeesDeclared,
|
||||
journalAddForecast,
|
||||
journalAddAutoPostings,
|
||||
setYear,
|
||||
getYear,
|
||||
setDefaultCommodityAndStyle,
|
||||
@ -135,7 +137,9 @@ import Data.Bifunctor (bimap, second)
|
||||
import Data.Char (digitToInt, isDigit, isSpace)
|
||||
import Data.Decimal (DecimalRaw (Decimal), Decimal)
|
||||
import Data.Default (Default(..))
|
||||
import Data.Either (lefts, rights)
|
||||
import Data.Function ((&))
|
||||
import Data.Functor ((<&>))
|
||||
import Data.Functor.Identity (Identity)
|
||||
import "base-compat-batteries" Data.List.Compat
|
||||
import Data.List.NonEmpty (NonEmpty(..))
|
||||
@ -144,7 +148,7 @@ import qualified Data.Map as M
|
||||
import qualified Data.Semigroup as Sem
|
||||
import Data.Text (Text)
|
||||
import qualified Data.Text as T
|
||||
import Data.Time.Calendar (Day, fromGregorianValid, toGregorian)
|
||||
import Data.Time.Calendar (Day, addDays, fromGregorianValid, toGregorian)
|
||||
import Data.Time.LocalTime (LocalTime(..), TimeOfDay(..))
|
||||
import Data.Word (Word8)
|
||||
import System.Time (getClockTime)
|
||||
@ -156,6 +160,8 @@ import Text.Megaparsec.Custom
|
||||
finalErrorBundlePretty, parseErrorAt, parseErrorAtRegion)
|
||||
|
||||
import Hledger.Data
|
||||
import Hledger.Query (Query(..), filterQuery, parseQueryTerm, queryEndDate, queryIsDate, simplifyQuery)
|
||||
import Hledger.Reports.ReportOptions (ReportOpts(..), queryFromFlags, rawOptsToReportOpts)
|
||||
import Hledger.Utils
|
||||
import Text.Printf (printf)
|
||||
|
||||
@ -228,6 +234,8 @@ definputopts = InputOpts
|
||||
, strict_ = False
|
||||
}
|
||||
|
||||
-- | Parse an InputOpts from a RawOpts and the current date.
|
||||
-- This will fail with a usage error if the forecast period expression cannot be parsed.
|
||||
rawOptsToInputOpts :: RawOpts -> IO InputOpts
|
||||
rawOptsToInputOpts rawopts = do
|
||||
d <- getCurrentDay
|
||||
@ -250,16 +258,25 @@ rawOptsToInputOpts rawopts = do
|
||||
}
|
||||
where noinferprice = boolopt "strict" rawopts || stringopt "args" rawopts == "balancednoautoconversion"
|
||||
|
||||
-- | get period expression from --forecast option
|
||||
-- | Get period expression from --forecast option.
|
||||
-- This will fail with a usage error if the forecast period expression cannot be parsed.
|
||||
forecastPeriodFromRawOpts :: Day -> RawOpts -> Maybe DateSpan
|
||||
forecastPeriodFromRawOpts d opts =
|
||||
case maybestringopt "forecast" opts
|
||||
of
|
||||
forecastPeriodFromRawOpts d rawopts = case maybestringopt "forecast" rawopts of
|
||||
Nothing -> Nothing
|
||||
Just "" -> Just nulldatespan
|
||||
Just str ->
|
||||
either (\e -> usageError $ "could not parse forecast period : "++customErrorBundlePretty e) (Just . snd) $
|
||||
parsePeriodExpr d $ stripquotes $ T.pack str
|
||||
Just "" -> Just forecastspanDefault
|
||||
Just str -> either (\e -> usageError $ "could not parse forecast period : "++customErrorBundlePretty e)
|
||||
(\(_,requestedspan) -> Just $ requestedspan `spanDefaultsFrom` forecastspanDefault) $
|
||||
parsePeriodExpr d $ stripquotes $ T.pack str
|
||||
where
|
||||
-- "They end on or before the specified report end date, or 180 days from today if unspecified."
|
||||
mspecifiedend = dbg2 "specifieddates" $ queryEndDate False datequery
|
||||
forecastendDefault = dbg2 "forecastendDefault" $ addDays 180 d
|
||||
forecastspanDefault = DateSpan Nothing $ mspecifiedend <|> Just forecastendDefault
|
||||
-- Do we really need to do all this work just to get the requested end date? This is duplicating
|
||||
-- much of reportOptsToSpec.
|
||||
ropts = rawOptsToReportOpts d rawopts
|
||||
argsquery = lefts . rights . map (parseQueryTerm d) $ querystring_ ropts
|
||||
datequery = simplifyQuery . filterQuery queryIsDate . And $ queryFromFlags ropts : argsquery
|
||||
|
||||
--- ** parsing utilities
|
||||
|
||||
@ -338,6 +355,8 @@ parseAndFinaliseJournal' parser iopts f txt = do
|
||||
--
|
||||
-- - save misc info and reverse transactions into their original parse order,
|
||||
--
|
||||
-- - add forecast transactions,
|
||||
--
|
||||
-- - evaluate balance assignments and balance each transaction,
|
||||
--
|
||||
-- - apply transaction modifiers (auto postings) if enabled,
|
||||
@ -347,41 +366,64 @@ parseAndFinaliseJournal' parser iopts f txt = do
|
||||
-- - infer transaction-implied market prices from transaction prices
|
||||
--
|
||||
journalFinalise :: InputOpts -> FilePath -> Text -> ParsedJournal -> ExceptT String IO Journal
|
||||
journalFinalise InputOpts{auto_,balancingopts_,strict_} f txt pj' = do
|
||||
t <- liftIO getClockTime
|
||||
d <- liftIO getCurrentDay
|
||||
liftEither $ do
|
||||
journalFinalise InputOpts{forecast_,auto_,balancingopts_,strict_} f txt pj = do
|
||||
t <- liftIO getClockTime
|
||||
d <- liftIO getCurrentDay
|
||||
-- Infer and apply canonical styles for each commodity (or throw an error).
|
||||
-- This affects transaction balancing/assertions/assignments, so needs to be done early.
|
||||
pj <- journalApplyCommodityStyles $
|
||||
pj'{jglobalcommoditystyles=fromMaybe M.empty $ commodity_styles_ balancingopts_} -- save any global commodity styles
|
||||
& journalAddFile (f, txt) -- save the main file's info
|
||||
& journalSetLastReadTime t -- save the last read time
|
||||
& journalReverse -- convert all lists to the order they were parsed
|
||||
liftEither $ checkAddAndBalance d <=< journalApplyCommodityStyles $
|
||||
pj{jglobalcommoditystyles=fromMaybe mempty $ commodity_styles_ balancingopts_} -- save any global commodity styles
|
||||
& journalAddFile (f, txt) -- save the main file's info
|
||||
& journalSetLastReadTime t -- save the last read time
|
||||
& journalReverse -- convert all lists to the order they were parsed
|
||||
where
|
||||
checkAddAndBalance d j = do
|
||||
when strict_ $ do
|
||||
-- If in strict mode, check all postings are to declared accounts
|
||||
journalCheckAccountsDeclared j
|
||||
-- and using declared commodities
|
||||
journalCheckCommoditiesDeclared j
|
||||
|
||||
when strict_ $ do
|
||||
-- If in strict mode, check all postings are to declared accounts
|
||||
journalCheckAccountsDeclared pj
|
||||
-- and using declared commodities
|
||||
journalCheckCommoditiesDeclared pj
|
||||
|
||||
-- infer market prices from commodity-exchanging transactions
|
||||
journalInferMarketPricesFromTransactions <$>
|
||||
if not auto_ || null (jtxnmodifiers pj)
|
||||
then
|
||||
-- Auto postings are not active.
|
||||
-- Balance all transactions and maybe check balance assertions.
|
||||
journalBalanceTransactions balancingopts_ pj
|
||||
else
|
||||
-- Auto postings are active.
|
||||
-- Balance all transactions without checking balance assertions,
|
||||
journalBalanceTransactions balancingopts_{ignore_assertions_=True} pj
|
||||
-- then add the auto postings
|
||||
-- (Note adding auto postings after balancing means #893b fails;
|
||||
-- adding them before balancing probably means #893a, #928, #938 fail.)
|
||||
>>= journalModifyTransactions d
|
||||
-- then check balance assertions.
|
||||
-- Add forecast transactions if enabled
|
||||
journalAddForecast d forecast_ j
|
||||
-- Add auto postings if enabled
|
||||
& (if auto_ && not (null $ jtxnmodifiers j) then journalAddAutoPostings d balancingopts_ else pure)
|
||||
-- Balance all transactions and maybe check balance assertions.
|
||||
>>= journalBalanceTransactions balancingopts_
|
||||
-- infer market prices from commodity-exchanging transactions
|
||||
<&> journalInferMarketPricesFromTransactions
|
||||
|
||||
journalAddAutoPostings :: Day -> BalancingOpts -> Journal -> Either String Journal
|
||||
journalAddAutoPostings d bopts =
|
||||
-- Balance all transactions without checking balance assertions,
|
||||
journalBalanceTransactions bopts{ignore_assertions_=True}
|
||||
-- then add the auto postings
|
||||
-- (Note adding auto postings after balancing means #893b fails;
|
||||
-- adding them before balancing probably means #893a, #928, #938 fail.)
|
||||
>=> journalModifyTransactions d
|
||||
|
||||
-- | Generate periodic transactions from all periodic transaction rules in the journal.
|
||||
-- These transactions are added to the in-memory Journal (but not the on-disk file).
|
||||
--
|
||||
-- The start & end date for generated periodic transactions are determined in
|
||||
-- a somewhat complicated way; see the hledger manual -> Periodic transactions.
|
||||
journalAddForecast :: Day -> Maybe DateSpan -> Journal -> Journal
|
||||
journalAddForecast _ Nothing j = j
|
||||
journalAddForecast d (Just requestedspan) j = j{jtxns = jtxns j ++ forecasttxns}
|
||||
where
|
||||
forecasttxns =
|
||||
map (txnTieKnot . transactionTransformPostings (postingApplyCommodityStyles $ journalCommodityStyles j))
|
||||
. filter (spanContainsDate forecastspan . tdate)
|
||||
. concatMap (`runPeriodicTransaction` forecastspan)
|
||||
$ jperiodictxns j
|
||||
|
||||
-- "They can start no earlier than: the day following the latest normal transaction in the journal (or today if there are none)."
|
||||
mjournalend = dbg2 "journalEndDate" $ journalEndDate False j -- ignore secondary dates
|
||||
forecastbeginDefault = dbg2 "forecastbeginDefault" $ mjournalend <|> Just d
|
||||
|
||||
-- "They end on or before the specified report end date, or 180 days from today if unspecified."
|
||||
forecastspan = dbg2 "forecastspan" $ dbg2 "forecastspan flag" requestedspan
|
||||
`spanDefaultsFrom` DateSpan forecastbeginDefault (Just $ addDays 180 d)
|
||||
|
||||
-- | Check that all the journal's transactions have payees declared with
|
||||
-- payee directives, returning an error message otherwise.
|
||||
|
||||
@ -195,20 +195,24 @@ defreportopts = ReportOpts
|
||||
, transpose_ = False
|
||||
}
|
||||
|
||||
rawOptsToReportOpts :: RawOpts -> IO ReportOpts
|
||||
rawOptsToReportOpts rawopts = do
|
||||
d <- getCurrentDay
|
||||
-- | Generate a ReportOpts from raw command-line input, given a day.
|
||||
-- This will fail with a usage error if it is passed
|
||||
-- - an invalid --format argument,
|
||||
-- - an invalid --value argument,
|
||||
-- - if --valuechange is called with a valuation type other than -V/--value=end.
|
||||
rawOptsToReportOpts :: Day -> RawOpts -> ReportOpts
|
||||
rawOptsToReportOpts d rawopts =
|
||||
|
||||
let formatstring = T.pack <$> maybestringopt "format" rawopts
|
||||
querystring = map T.pack $ listofstringopt "args" rawopts -- doesn't handle an arg like "" right
|
||||
(costing, valuation) = valuationTypeFromRawOpts rawopts
|
||||
|
||||
format <- case parseStringFormat <$> formatstring of
|
||||
Nothing -> return defaultBalanceLineFormat
|
||||
Just (Right x) -> return x
|
||||
Just (Left err) -> fail $ "could not parse format option: " ++ err
|
||||
format = case parseStringFormat <$> formatstring of
|
||||
Nothing -> defaultBalanceLineFormat
|
||||
Just (Right x) -> x
|
||||
Just (Left err) -> usageError $ "could not parse format option: " ++ err
|
||||
|
||||
return defreportopts
|
||||
in defreportopts
|
||||
{period_ = periodFromRawOpts d rawopts
|
||||
,interval_ = intervalFromRawOpts rawopts
|
||||
,statuses_ = statusesFromRawOpts rawopts
|
||||
@ -291,7 +295,7 @@ updateReportSpecWith = overWithReport reportOpts
|
||||
rawOptsToReportSpec :: RawOpts -> IO ReportSpec
|
||||
rawOptsToReportSpec rawopts = do
|
||||
d <- getCurrentDay
|
||||
ropts <- rawOptsToReportOpts rawopts
|
||||
let ropts = rawOptsToReportOpts d rawopts
|
||||
either fail return $ reportOptsToSpec d ropts
|
||||
|
||||
accountlistmodeopt :: RawOpts -> AccountListMode
|
||||
@ -443,6 +447,9 @@ reportOptsToggleStatus s ropts@ReportOpts{statuses_=ss}
|
||||
-- specified by -B/--cost, -V, -X/--exchange, or --value flags. It is
|
||||
-- allowed to combine -B/--cost with any other valuation type. If
|
||||
-- there's more than one valuation type, the rightmost flag wins.
|
||||
-- This will fail with a usage error if an invalid argument is passed
|
||||
-- to --value, or if --valuechange is called with a valuation type
|
||||
-- other than -V/--value=end.
|
||||
valuationTypeFromRawOpts :: RawOpts -> (Costing, Maybe ValuationType)
|
||||
valuationTypeFromRawOpts rawopts = (costing, valuation)
|
||||
where
|
||||
|
||||
@ -4,6 +4,7 @@ module Hledger.Web.Test (
|
||||
hledgerWebTest
|
||||
) where
|
||||
|
||||
import Control.Monad.Except (runExceptT)
|
||||
import qualified Data.Text as T
|
||||
import Test.Hspec (hspec)
|
||||
import Yesod.Default.Config
|
||||
@ -80,13 +81,17 @@ hledgerWebTest = do
|
||||
-- yit "can add transactions" $ do
|
||||
|
||||
let
|
||||
copts = defcliopts{reportspec_=defreportspec, file_=[""]} -- non-empty, see file_ note above
|
||||
-- Have forecasting on for testing
|
||||
iopts = definputopts{forecast_=Just nulldatespan}
|
||||
copts = defcliopts{inputopts_=iopts, file_=[""]} -- non-empty, see file_ note above
|
||||
wopts = defwebopts{cliopts_=copts}
|
||||
j <- fmap (either error id . journalTransform copts) $ readJournal' (T.pack $ unlines -- PARTIAL: readJournal' should not fail
|
||||
pj <- readJournal' (T.pack $ unlines -- PARTIAL: readJournal' should not fail
|
||||
["~ monthly"
|
||||
," assets 10"
|
||||
," income"
|
||||
])
|
||||
-- Have to give a non-null filename "fake" so forecast transactions get index 0
|
||||
j <- fmap (either error id) . runExceptT $ journalFinalise iopts "fake" "" pj -- PARTIAL: journalFinalise should not fail
|
||||
runHspecTestsWith conf wopts j $ do
|
||||
ydescribe "hledger-web --forecast" $ do
|
||||
|
||||
@ -95,4 +100,3 @@ hledgerWebTest = do
|
||||
statusIs 200
|
||||
bodyContains "id=\"transaction-0-1\"" -- 0 indicates a fileless (forecasted) txn
|
||||
bodyContains "id=\"transaction-0-2\"" -- etc.
|
||||
|
||||
|
||||
@ -15,7 +15,6 @@ module Hledger.Cli.Utils
|
||||
writeOutput,
|
||||
writeOutputLazyText,
|
||||
journalTransform,
|
||||
journalAddForecast,
|
||||
journalReload,
|
||||
journalReloadIfChanged,
|
||||
journalFileIsNewer,
|
||||
@ -38,7 +37,7 @@ import qualified Data.Text as T
|
||||
import qualified Data.Text.IO as T
|
||||
import qualified Data.Text.Lazy as TL
|
||||
import qualified Data.Text.Lazy.IO as TL
|
||||
import Data.Time (UTCTime, Day, addDays)
|
||||
import Data.Time (UTCTime, Day)
|
||||
import Safe (readMay, headMay)
|
||||
import System.Console.CmdArgs
|
||||
import System.Directory (getModificationTime, getDirectoryContents, copyFile, doesFileExist)
|
||||
@ -75,7 +74,7 @@ withJournalDo opts cmd = do
|
||||
-- to let the add command work.
|
||||
journalpaths <- journalFilePathFromOpts opts
|
||||
files <- readJournalFiles (inputopts_ opts) journalpaths
|
||||
let transformed = journalTransform opts =<< files
|
||||
let transformed = journalTransform opts <$> files
|
||||
either error' cmd transformed -- PARTIAL:
|
||||
|
||||
-- | Apply some extra post-parse transformations to the journal, if
|
||||
@ -88,13 +87,12 @@ withJournalDo opts cmd = do
|
||||
--
|
||||
-- This will return an error message if the query in any auto posting rule fails
|
||||
-- to parse, or the generated transactions are not balanced.
|
||||
journalTransform :: CliOpts -> Journal -> Either String Journal
|
||||
journalTransform :: CliOpts -> Journal -> Journal
|
||||
journalTransform opts =
|
||||
fmap (anonymiseByOpts opts)
|
||||
anonymiseByOpts opts
|
||||
-- - converting amounts to market value (--value)
|
||||
-- . journalApplyValue ropts
|
||||
. fmap (pivotByOpts opts)
|
||||
. journalAddForecast opts
|
||||
. pivotByOpts opts
|
||||
|
||||
-- | Apply the pivot transformation on a journal, if option is present.
|
||||
pivotByOpts :: CliOpts -> Journal -> Journal
|
||||
@ -110,48 +108,6 @@ anonymiseByOpts opts =
|
||||
then anon
|
||||
else id
|
||||
|
||||
-- | Generate periodic transactions from all periodic transaction rules in the journal.
|
||||
-- These transactions are added to the in-memory Journal (but not the on-disk file).
|
||||
--
|
||||
-- When --auto is active, auto posting rules will be applied to the
|
||||
-- generated transactions. If the query in any auto posting rule fails
|
||||
-- to parse, or the generated transactions are not balanced, this function will
|
||||
-- return an error message.
|
||||
--
|
||||
-- The start & end date for generated periodic transactions are determined in
|
||||
-- a somewhat complicated way; see the hledger manual -> Periodic transactions.
|
||||
--
|
||||
journalAddForecast :: CliOpts -> Journal -> Either String Journal
|
||||
journalAddForecast CliOpts{inputopts_=iopts, reportspec_=rspec} j =
|
||||
case forecast_ iopts of
|
||||
Nothing -> return j
|
||||
Just _ -> do
|
||||
forecasttxns <- addAutoTxns =<< mapM (balanceTransaction (balancingopts_ iopts))
|
||||
[ txnTieKnot $ transactionTransformPostings (postingApplyCommodityStyles styles) t
|
||||
| pt <- jperiodictxns j
|
||||
, t <- runPeriodicTransaction pt forecastspan
|
||||
, spanContainsDate forecastspan (tdate t)
|
||||
]
|
||||
journalBalanceTransactions (balancingopts_ iopts) j{ jtxns = concat [jtxns j, forecasttxns] }
|
||||
where
|
||||
today = _rsDay rspec
|
||||
styles = journalCommodityStyles j
|
||||
|
||||
-- "They can start no earlier than: the day following the latest normal transaction in the journal (or today if there are none)."
|
||||
mjournalend = dbg2 "journalEndDate" $ journalEndDate False j -- ignore secondary dates
|
||||
forecastbeginDefault = dbg2 "forecastbeginDefault" $ fromMaybe today mjournalend
|
||||
|
||||
-- "They end on or before the specified report end date, or 180 days from today if unspecified."
|
||||
mspecifiedend = dbg2 "specifieddates" $ addDays 1 <$> reportPeriodLastDay rspec
|
||||
forecastendDefault = dbg2 "forecastendDefault" $ fromMaybe (addDays 180 today) mspecifiedend
|
||||
|
||||
forecastspan = dbg2 "forecastspan" $
|
||||
spanDefaultsFrom
|
||||
(fromMaybe nulldatespan $ dbg2 "forecastspan flag" $ forecast_ iopts)
|
||||
(DateSpan (Just forecastbeginDefault) (Just forecastendDefault))
|
||||
|
||||
addAutoTxns = if auto_ iopts then modifyTransactions styles today (jtxnmodifiers j) else return
|
||||
|
||||
-- | Write some output to stdout or to a file selected by --output-file.
|
||||
-- If the file exists it will be overwritten.
|
||||
writeOutput :: CliOpts -> String -> IO ()
|
||||
@ -199,7 +155,7 @@ journalReload :: CliOpts -> IO (Either String Journal)
|
||||
journalReload opts = do
|
||||
journalpaths <- dbg6 "reloading files" <$> journalFilePathFromOpts opts
|
||||
files <- readJournalFiles (inputopts_ opts) journalpaths
|
||||
return $ journalTransform opts =<< files
|
||||
return $ journalTransform opts <$> files
|
||||
|
||||
-- | Has the specified file changed since the journal was last read ?
|
||||
-- Typically this is one of the journal's journalFilePaths. These are
|
||||
|
||||
@ -235,3 +235,36 @@ hledger -f - reg -b 2021-01-01 -e 2021-01-05 --forecast
|
||||
2021-01-04 (a) 1 1003
|
||||
>>>2
|
||||
>>>=0
|
||||
|
||||
# 12. Forecast transactions work with balance assignments
|
||||
hledger -f - print -x --forecast -e 2021-11
|
||||
<<<
|
||||
2021-09-01 Normal Balance Assertion Works
|
||||
Checking = -60
|
||||
Costs
|
||||
|
||||
~ 2021-10-01 explicit forecasted assertion
|
||||
Checking = -100
|
||||
Costs 40
|
||||
|
||||
~ 2021-10-02 auto-deduced forecasted assertion
|
||||
Checking = -120
|
||||
Costs
|
||||
|
||||
>>>
|
||||
2021-09-01 Normal Balance Assertion Works
|
||||
Checking -60 = -60
|
||||
Costs 60
|
||||
|
||||
2021-10-01 explicit forecasted assertion
|
||||
; generated-transaction: ~ 2021-10-01
|
||||
Checking -40 = -100
|
||||
Costs 40
|
||||
|
||||
2021-10-02 auto-deduced forecasted assertion
|
||||
; generated-transaction: ~ 2021-10-02
|
||||
Checking -20 = -120
|
||||
Costs 20
|
||||
|
||||
>>>2
|
||||
>>>=0
|
||||
|
||||
Loading…
Reference in New Issue
Block a user