trade-journal-0.0.1: src/Journal/Model.hs
{-# LANGUAGE DataKinds #-}
{-# LANGUAGE FlexibleContexts #-}
{-# LANGUAGE LambdaCase #-}
{-# LANGUAGE RecordWildCards #-}
{-# LANGUAGE ViewPatterns #-}
module Journal.Model
( processJournal,
processJournalWithChanges,
processActionsWithChanges,
)
where
import Control.Applicative
import Control.Arrow
import Control.Exception hiding (handle)
import Control.Lens
import Control.Monad.Except
import Control.Monad.Trans.Reader
import Control.Monad.Trans.State
import Control.Monad.Trans.Writer
import Data.Foldable
import Data.List (sortOn)
import Data.List (tails)
import Journal.Amount
import Journal.Split
import Journal.Types
import Journal.Utils
import Prelude hiding (Double, Float)
-- | Ideally, this module turns a stream of lots -- expressing intentions to
-- buy and sell at given prices -- into a record of transactions with the
-- broker where all gains and losses have been calculated.
processJournal :: MonadError JournalError m => Journal -> m Journal
processJournal =
fmap (Journal . snd) . processActionsWithChanges . view actions
processJournalWithChanges ::
MonadError JournalError m =>
Journal ->
m ([Change], Journal)
processJournalWithChanges =
fmap (second Journal) . processActionsWithChanges . view actions
processActionsWithChanges ::
MonadError JournalError m =>
[Timed Action] ->
m ([Change], [Timed Action])
processActionsWithChanges xs =
fmap unzipBoth . (`evalStateT` newJournalState) $ do
forM xs $ \x -> do
let lot = x ^. item . _Lot
macct = lot ^? details . traverse . _Account
sym = lot ^. symbol
zoom (accounts . at macct . non newAccountState) $ do
zoom
( zipped3
nextId
balance
(instruments . at sym . non newInstrumentState)
)
$ first (SawAction x :) <$> processAction x
checkNetAmount ::
MonadError JournalError m =>
Timed Action ->
m (Timed Action)
checkNetAmount x
| Just itemNet <- x ^? item . _Lot . details . traverse . _Net = do
let calcNet = netAmount (x ^. item)
if itemNet == calcNet
then throwError $ NetAmountDoesNotMatch x calcNet itemNet
else pure x
| otherwise =
pure $ x & item . _Lot . details <>~ [Net (netAmount (x ^. item))]
checkBalance ::
MonadError JournalError m =>
Timed Action ->
StateT (Amount 2) m (Timed Action)
checkBalance x
| Just itemBal <- x ^? item . _Lot . details . traverse . _Balance = do
bal <- get
let newBal = netAmount (x ^. item) + bal
if itemBal == newBal
then pure x
else throwError $ BalanceDoesNotMatch x newBal itemBal
| otherwise = do
bal <- get
let newBal = netAmount (x ^. item) + bal
put newBal
pure $ x & item . _Lot . details <>~ [Balance newBal]
processAction ::
MonadError JournalError m =>
Timed Action ->
StateT (Int, Amount 2, InstrumentState) m ([Change], [Timed Action])
processAction =
fmap unzipBoth . mapM applyChange <=< readonly . impliedChanges
where
applyChange chg = case chg of
SawAction _ -> throwError $ ChangeNotFromImpliedChanges chg
Result e -> do
e' <- zoom _2 $ checkBalance e
e'' <- lift $ checkNetAmount e'
pure ([chg], [e''])
Submit lot ->
first (\xs -> chg : xs ++ [SubmitEnd])
<$> processAction (Wash <$> lot)
SubmitEnd -> pure ([chg], [])
AddEvent e -> do
ident <- use _1
_1 += 1
_3 . events . at ident ?= e
pure ([chg], [])
RemoveEvent ident -> do
_3 . events . at ident .= Nothing
pure ([chg], [])
ReplaceEvent ident e -> do
_3 . events . at ident ?= e
pure ([chg], [])
SaveWash name lot -> do
_3 . washSales . at name
%= \case Nothing -> Just [lot]; Just xs -> Just (lot : xs)
pure ([chg], [])
-- | A specialized variant of 'foldM' with some arguments shifted around.
shortFoldM ::
Monad m =>
a ->
[b] ->
(b -> Maybe a -> m (Maybe a)) ->
m (Maybe a)
shortFoldM z xs f = foldM (flip f) (Just z) xs
impliedChanges ::
MonadError JournalError m =>
Timed Action ->
ReaderT (Int, Amount 2, InstrumentState) m [Change]
impliedChanges x = do
hist <- asks (sortOn fst . toListOf (_3 . events . ifolded . withIndex))
(mx, changes) <- runWriterT $ shortFoldM x (tails hist) $
\hs -> \case
Nothing -> pure Nothing
Just x' -> handle hs x'
forM_ mx $ throwError . UnexpectedRemainder
pure changes
buyOrSell :: Traversal' (Timed Action) Lot
buyOrSell = item . failing _Buy _Sell
wash :: Traversal' (Timed Action) Lot
wash = item . _Wash
opened :: Traversal' (Timed Event) Lot
opened = item . _Opened . _2
handle ::
MonadError JournalError m =>
[(Int, Timed Event)] ->
Timed Action ->
WriterT
[Change]
(ReaderT (Int, Amount 2, InstrumentState) m)
(Maybe (Timed Action))
handle
((n, open@(view item -> Opened buyToOpen _)) : _)
close@(preview buyOrSell -> Just _)
| buyToOpen == has (item . _Sell) close =
closePosition n open close
handle
((n, open@(view item -> Opened _ o)) : _)
washing@(preview wash -> Just _)
| all
(\ann -> hasn't _Exempt ann && hasn't _Washed ann)
(o ^. details),
(open ^. time) `distance` (washing ^. time) <= 30 =
washExistingPosition n open washing
-- If a wash sale couldn't be applied to the current history, record it to
-- apply to a future opening.
handle [] act@(preview wash -> Just x) = do
case x ^? details . traverse . _WashTo of
Just (name, mres) ->
Nothing
<$ tell
[ SaveWash
name
( ( case mres of
Nothing -> x
Just (_amount, _price) ->
let _symbol = x ^. symbol
_details = []
_computed = []
in Lot {..}
)
<$ act
),
Result (Wash x <$ act)
]
_ -> pure Nothing
-- Otherwise, if there is no history to examine then this buy or sale must
-- open a new position.
handle [] action = do
mact <- shortFoldM
action
(action ^.. buyOrSell . details . traverse . _WashApply)
$ \(name, _amount) -> \case
Just (act@(preview buyOrSell -> Just open)) -> do
sales <-
lift $
asks
( toListOf
( _3 . washSales
. ix name
. folded
. filtered
( \adj ->
(adj ^. time)
`distance` (act ^. time) <= 30
)
. to (\x -> x ^. item . amount * x ^. item . price)
)
)
let _price = sum sales / _amount
_symbol = open ^. symbol
_details = []
_computed = []
washNewPosition Lot {..} act
_ -> pure Nothing
case mact of
Just (act@(preview buyOrSell -> Just open)) ->
Nothing
<$ tell
[ AddEvent (Opened (has (item . _Buy) act) open <$ act),
Result (act & buyOrSell . details <>~ [Position Open])
]
_ ->
-- If none of the above apply, nothing is done for this action, pass
-- it through
pure mact
handle _ x = pure $ Just x
-- | The most common trading activities are either to open a new position, or
-- to close an existing one for a profit or loss. If there is a loss within 30
-- days of the opening, it implies a wash sale adjustment of any preceeding or
-- subsequent openings 30 days before or after.
closePosition ::
MonadError JournalError m =>
Int ->
Timed Event ->
Timed Action ->
WriterT [Change] m (Maybe (Timed Action))
closePosition n open close = do
let (s, d) = (open ^?! opened) `alignLots` (close ^?! buyOrSell)
forM_ ((,) <$> s ^? _SplitUsed <*> d ^? _SplitUsed) $ \(su, du) -> do
let pricing x =
x ^. price
+ sum
( x
^.. details
. traverse
. _Washed
)
lotFees = sum (du ^.. fees) + sum (su ^.. fees)
pl
| has (item . _Sell) close =
pricing du - pricing su - lotFees
| otherwise = pricing su - pricing du - lotFees
res =
du & details
<>~ [ Position Close,
if pl < 0
then Loss (- pl)
else Gain pl
]
tell [Result (close & buyOrSell .~ res)]
-- After closing at a loss, and if the loss occurs within 30 days
-- of its corresponding open, and there is another open within 30
-- days of the loss, close it and re-open so it's repriced by the
-- wash loss.
let mayWash = (close ^. time) `distance` (open ^. time) <= 30 && pl < 0
tell
[ case s ^? _SplitKept of
Just k ->
ReplaceEvent
n
( open & opened
.~ if mayWash
then k & details <>~ [Exempt]
else k
)
Nothing -> RemoveEvent n
]
when mayWash $
tell
[ Submit ((du & price .~ negate pl) <$ close)
]
pure $ (set buyOrSell ?? close) <$> (d ^? _SplitKept)
-- | If the action is a wash sale adjustment, determine if can be applied to
-- any existing open positions. If not, it is remembered, to be applied at the
-- next opening within 30 days of sale.
washExistingPosition ::
MonadError JournalError m =>
Int ->
Timed Event ->
Timed Action ->
WriterT [Change] m (Maybe (Timed Action))
washExistingPosition n open washing =
assert (washing ^?! wash . amount /= 0) $ do
let (s, d) = (open ^?! opened) `alignLots` (washing ^?! wash)
-- Wash failing closes by adding the amount to the cost basis of the
-- opening transaction.
tell $
[ case s ^? _SplitKept of
Just e ->
ReplaceEvent n (set opened (e & details <>~ [Exempt]) open)
Nothing -> RemoveEvent n
]
++ ( AddEvent
. (set opened ?? open)
. (details <>~ [Washed (d ^?! _SplitUsed . price)])
<$> s ^.. _SplitUsed
)
++ ( Result . (set wash ?? washing)
. (details .~ [Washed (s ^?! _SplitUsed . price)])
<$> d ^.. _SplitUsed
)
pure $ (set wash ?? washing) <$> (d ^? _SplitKept)
-- | If we are opening a position and there is a pending wash sale within the
-- last 30 days, apply the adjustment to the applicable part of this opening.
washNewPosition ::
MonadError JournalError m =>
Lot ->
Timed Action ->
WriterT [Change] m (Maybe (Timed Action))
washNewPosition washing open =
assert (washing ^. amount <= open ^?! buyOrSell . amount) $ do
let (_s, d) = washing `alignLots` (open ^?! buyOrSell)
-- We wash failing closes by adding the amount to the cost basis of
-- the opening transaction. Thus, we generate three instances of
-- WashLossApplied, but only one OpenPosition.
tell $
( AddEvent
. Timed (open ^. time)
. Opened (has (item . _Buy) open)
. (details <>~ [Washed (washing ^. price)])
<$> d ^.. _SplitUsed
)
++ ( Result . (set buyOrSell ?? open)
. ( details
<>~ [ Position Open,
Washed (washing ^. price)
]
)
<$> d ^.. _SplitUsed
)
pure $ (set buyOrSell ?? open) <$> d ^? _SplitKept