Portfolio is Fundcloud's shared post-simulation object. Every entry path — Simulator.run_strategy, run_weights, run_signals, run_orders, and the skfolio round-trip via from_skfolio / to_skfolio — produces one. Metrics (sharpe, max_drawdown, turnover, attribution), the full summary() bundle, and the tear-sheet renderers all read from the same object, which keeps notebook exploration and production reporting numerically identical. Population holds a set of portfolios for cross-strategy comparison; Position is the per-asset record.
One :class:Portfolio class handles both live simulation state (via
:meth:Portfolio.apply / :meth:Portfolio.mark_to_market) and post-run
analytics (Sharpe, drawdowns, CVaR, attribution, …). :class:Population
compares several portfolios side-by-side.
def__init__(self,*,returns:pd.DataFrame|pd.Series|None=None,weights:pd.DataFrame|pd.Series|None=None,benchmark:pd.Series|None=None,cash:float=0.0,positions:dict[str,float]|None=None,name:str|None=None,)->None:self._name=nameself._benchmark=benchmarkself._returns:pd.Series|None=Noneself._weights_frame:pd.DataFrame|None=NoneifreturnsisnotNone:self._returns=_coerce_returns(returns)ifweightsisnotNone:self._weights_frame=_coerce_weights(weights)# Live state — only populated for live-mode portfolios.self._live=_LiveState(cash=float(cash))ifpositions:forasset,qtyinpositions.items():self._live.positions[asset]=Position(qty=float(qty))
defapply(self,trade:Any)->None:"""Apply a ``Trade`` (from :mod:`fundcloud.sim`) to live state. The method only depends on the trade's duck-typed attributes (``asset``, ``qty``, ``price``, ``fee``) so this module doesn't have to import the simulator package. """asset=str(trade.asset)qty=float(trade.qty)price=float(trade.price)fee=float(getattr(trade,"fee",0.0))pos=self.position(asset)notional=qty*priceself._live.cash-=notional+fee# Weighted-average cost update for adds; leave avg_cost alone on closes.ifpos.qty==0or(pos.qty>0)==(qty>0):total=pos.qty+qtyiftotal!=0:pos.avg_cost=(pos.qty*pos.avg_cost+qty*price)/totalpos.qty+=qtyself._live.trade_log.append(trade)
defattribution(self)->pd.DataFrame:"""Asset-level return contribution = weights × returns (shifted). Requires a weights frame. Uses the current-bar weight × current-bar asset return, which is the standard backward-looking decomposition. """w=self._weights_frameifwisNone:returnpd.DataFrame()ifself._returnsisNoneorself._returns.empty:returnpd.DataFrame(columns=w.columns)# If returns is a total-portfolio series (no per-asset info), attribution# is undefined beyond `weights * total_return`.contrib=w.reindex(self._returns.index).mul(self._returns,axis=0)returncontrib
defcontribution(self)->pd.Series:"""Average per-asset contribution to total return."""attr=self.attribution()ifattr.empty:returnpd.Series(dtype=float)returnattr.mean()
defdrawdown_details(self)->pd.DataFrame:"""One row per drawdown episode: start / valley / recovery + durations. See :func:`fundcloud.metrics.drawdown_details` for the column definitions. """return_metrics.drawdown_details(self.returns)
@classmethoddeffrom_skfolio(cls,portfolio:Any,*,benchmark:pd.Series|None=None)->Portfolio:"""Lift a skfolio ``Portfolio`` into a Fundcloud ``Portfolio``. Copies the returns series and the (per-period) weight vector if one is exposed. Compatible with skfolio >= 0.6. """returns=_safe_skfolio_returns(portfolio)weights=_safe_skfolio_weights(portfolio)name=getattr(portfolio,"name",None)ortype(portfolio).__name__returncls(returns=returns,weights=weights,benchmark=benchmark,name=name,)
prices is a Series of asset → price at that timestamp. When a
price is missing (NaN) — typical for a mixed-frequency panel
on a weekend / market holiday — the portfolio falls back to the
last known price for that asset so a held position keeps its
value across the closed bar. Cash-only positions (qty == 0)
are skipped.
Source code in python/fundcloud/portfolio/portfolio.py
defmark_to_market(self,prices:pd.Series,ts:pd.Timestamp,)->float:"""Compute and record equity at timestamp ``ts``. ``prices`` is a Series of asset → price at that timestamp. When a price is missing (``NaN``) — typical for a mixed-frequency panel on a weekend / market holiday — the portfolio falls back to the last known price for that asset so a held position keeps its value across the closed bar. Cash-only positions (``qty == 0``) are skipped. """# Refresh the last-known price cache from this bar's quotes.forasset,rawinprices.items():px=float(raw)ifnp.isfinite(px)andpx>0:self._live.last_prices[str(asset)]=pxequity=self._live.cashper_asset_value:dict[str,float]={}forasset,posinself._live.positions.items():ifpos.qty==0:continue# Prefer the current bar's price; fall back to the last known# price for that asset; final fallback is the position's average# cost (covers the unusual case where we buy and immediately# need to mark-to-market on a NaN bar).raw_px=prices.get(asset,np.nan)px=float(raw_px)ifraw_pxisnotNoneelsefloat("nan")ifnotnp.isfinite(px)orpx<=0:px=self._live.last_prices.get(asset,pos.avg_costifpos.avg_cost>0elsefloat("nan"))ifnotnp.isfinite(px)orpx<=0:continuevalue=pos.qty*pxequity+=valueper_asset_value[asset]=valueself._live.equity_history.append((ts,equity))ifequity!=0:weights={a:v/equityfora,vinper_asset_value.items()}else:weights={a:0.0forainper_asset_value}self._live.weights_history.append((ts,weights))returnequity
Delegates to :func:fundcloud.metrics.metrics. When this Portfolio
was constructed with benchmark=, that benchmark is used by
default; pass an explicit benchmark= to override.
Source code in python/fundcloud/portfolio/portfolio.py
defmetrics(self,*,benchmark:pd.Series|None=None,risk_free:float|None=None,periods_per_year:int|None=None,cvar_alpha:float=0.95,)->pd.Series:"""Full portfolio-metrics bundle. Delegates to :func:`fundcloud.metrics.metrics`. When this Portfolio was constructed with ``benchmark=``, that benchmark is used by default; pass an explicit ``benchmark=`` to override. """r=self.returnsbench=benchmarkifbenchmarkisnotNoneelseself.benchmarkreturn_metrics.metrics(r,benchmark=bench,risk_free=risk_free,periods_per_year=periods_per_year,cvar_alpha=cvar_alpha,).rename(self.name)
defperiod_returns(self,*,benchmark:pd.Series|None=None,periods_per_year:int|None=None,)->pd.Series|pd.DataFrame:"""MTD / 3M / 6M / YTD / 1Y / 3Y / 5Y / 10Y / All-time bundle. When a benchmark is not passed and :attr:`benchmark` was set on construction, it's used as the default. See :func:`fundcloud.metrics.period_returns`. """bench=benchmarkifbenchmarkisnotNoneelseself.benchmarkreturn_metrics.period_returns(self.returns,benchmark=bench,periods_per_year=periods_per_year,)
defrunup_details(self)->pd.DataFrame:"""One row per runup (rally) episode between drawdowns. See :func:`fundcloud.metrics.runup_details` for the column definitions. """return_metrics.runup_details(self.returns)
Builds returns from the equity curve (if there is one) and
weights from the recorded weights history, then detaches live
state so the returned instance behaves immutably for analytics.
Source code in python/fundcloud/portfolio/portfolio.py
defsnapshot(self)->Portfolio:"""Freeze live state into an analytics-mode copy. Builds ``returns`` from the equity curve (if there is one) and ``weights`` from the recorded weights history, then detaches live state so the returned instance behaves immutably for analytics. """equity=pd.Series({ts:valforts,valinself._live.equity_history},dtype=float,).sort_index()returns=equity.pct_change().dropna()iflen(equity)>1elsepd.Series([],dtype=float)weights_frame:pd.DataFrame|Noneifself._live.weights_history:raw=pd.DataFrame.from_dict({ts:wforts,winself._live.weights_history},orient="index").sort_index()weights_frame=raw.fillna(0.0)else:weights_frame=Nonesnap=Portfolio(returns=returns,weights=weights_frame,benchmark=self._benchmark,name=self._name,)returnsnap
defsummary(self,*,risk_free:float|None=None,periods_per_year:int|None=None,cvar_alpha:float=0.95,)->pd.Series:"""Single-column summary of key metrics (rows = metric names). Compact 11-metric view. For the full ~55-metric bundle use :meth:`metrics`. """r=self.returnsstats=_metrics.returns_stats(r,risk_free=risk_free,periods_per_year=periods_per_year,cvar_alpha=cvar_alpha,)returnstats.iloc[:,0].rename(self.name)
defto_skfolio(self)->Any:"""Build a skfolio ``Portfolio`` mirror of this object. Requires the ``[pf]`` extra. The resulting object is an instance of :class:`skfolio.Portfolio`. """try:fromskfolioimportPortfolioasSkPortfolio# type: ignore[import-not-found]exceptImportErrorase:msg="to_skfolio() requires skfolio; install with: uv add 'fundcloud[pf]'"raiseImportError(msg)frome# skfolio's Portfolio constructor expects an X/returns and a weights# vector. We pass a per-period weights frame when available.returnSkPortfolio(X=self.returns.to_frame()ifisinstance(self.returns,pd.Series)elseself.returns,weights=self._weights_frame.iloc[-1].to_numpy()ifself._weights_frameisnotNoneandlen(self._weights_frame)>0elseNone,name=self.name,)
defturnover(self)->float:"""Average one-way turnover across rebalance boundaries. Returns ``0.0`` when weights are constant or unknown. """w=self._weights_frameifwisNoneorlen(w)<2:return0.0returnfloat(w.diff().abs().sum(axis=1).iloc[1:].mean()/2.0)
Returns a :class:pd.Series when no benchmark is available, or a
two-column :class:pd.DataFrame (benchmark, strategy)
when one is supplied (or set on construction).
Source code in python/fundcloud/portfolio/portfolio.py
defyearly_returns(self,*,benchmark:pd.Series|None=None)->pd.Series|pd.DataFrame:"""End-of-year returns. Returns a :class:`pd.Series` when no benchmark is available, or a two-column :class:`pd.DataFrame` (``benchmark``, ``strategy``) when one is supplied (or set on construction). """bench=benchmarkifbenchmarkisnotNoneelseself.benchmarkstrategy=_metrics.yearly_returns(self.returns).rename(self.name)ifbenchisNone:returnstrategybench_yearly=_metrics.yearly_returns(bench).rename(str(bench.name)ifbench.nameisnotNoneelse"benchmark")returnpd.concat([bench_yearly,strategy],axis=1)
def__init__(self,portfolios:Sequence[Portfolio])->None:self._portfolios=list(portfolios)# Disambiguate name collisions so `summary` produces unique columns.counts:dict[str,int]={}forpinself._portfolios:counts[p.name]=counts.get(p.name,0)+1seen:dict[str,int]={}forpinself._portfolios:base=p.nameifcounts[base]>1:seen[base]=seen.get(base,0)+1p.rename(f"{base}_{seen[base]}")
defcomposition(self)->pd.DataFrame:"""Latest weights per portfolio, as rows-per-portfolio × asset columns."""rows:dict[str,pd.Series]={}forpinself._portfolios:w=p.weightsifwisNoneorlen(w)==0:continuerows[p.name]=w.iloc[-1]ifnotrows:returnpd.DataFrame()returnpd.DataFrame(rows).T.fillna(0.0)
defcumulative_returns(self)->pd.DataFrame:"""Wide frame of cumulative (compounded) returns per portfolio."""ifnotself._portfolios:returnpd.DataFrame()series={}forpinself._portfolios:try:r=p.returnsexceptValueError:continueseries[p.name]=(1.0+r).cumprod()returnpd.DataFrame(series)