Calculation methodology
A complete description of the mathematics Finaim uses to optimise portfolios, measure risk, profile investors and score private companies.
Markowitz portfolio optimisation
CLA + EfficientFrontier (PyPortfolioOpt)
Mean-variance optimisation
The platform is built on classical Markowitz theory (1952). For a given set of assets it constructs the portfolio that maximises expected return at a fixed level of risk (volatility), or minimises risk at a given level of return. Mathematically the problem is solved by quadratic programming:
Objective function (maximising the Sharpe ratio):
max [ (wᵀ μ - r_f) / sqrt(wᵀ Σ w) ]
where: w — vector of asset weights (sums to 1)
μ — vector of expected annual returns (mean_historical_return)
Σ — covariance matrix (sample_cov from historical prices)
r_f — risk-free rate (2% p.a. by default)
Expected return and covariance
The expected return of an asset μ_i is the arithmetic mean of daily logarithmic returns,
annualised by multiplying by 252 (trading days per year). The covariance matrix Σ
is built by sample covariance over historical closing prices (adjusted for dividends and splits),
obtained through yfinance or the MOEX ISS API.
Critical Line Algorithm (CLA)
The efficient frontier is built with the Critical Line Algorithm (Wolfe, 1959) — an exact analytical
method that iterates over corner portfolios, which yields a precise frontier free of numerical
artefacts. CLA returns an array of (volatility, expected_return) pairs for the d3.js
visualisation.
Weight constraints
The system supports three kinds of constraint on asset weights:
- Fixed weight (
fixed): the asset is forced into the portfolio at the given share; the upper and lower bounds are both set to that value. - Minimum weight (
min_weight): a lower bound on the asset's share of the portfolio. - Maximum weight (
max_weight): an upper bound on the asset's share of the portfolio.
Random portfolios (Dirichlet sampling)
For the background cloud of “possible portfolios” on the chart, 300 random weight vectors
are drawn from a symmetric Dirichlet distribution: w ~ Dirichlet(1_N), which covers the
standard simplex uniformly. Return and volatility are computed for each random portfolio.
Risk and return metrics
Sharpe, Sortino, max drawdown, correlations
Sharpe ratio
where R_p — expected annual return of the portfolio
r_f — risk-free rate
σ_p = sqrt(wᵀ Σ w) — annual volatility of the portfolio
Computed from the optimal weights via PyPortfolioOpt.
Sortino ratio
A variant of Sharpe that counts downside risk only (downside deviation):
where σ_d = sqrt(252) * std( min(r_t - r_f/252, 0) )
r_t — daily return of the portfolio
Averaged over the days on which r_t - r_f/252 < 0
Maximum drawdown
running_max_t = max(cum_1, ..., cum_t)
drawdown_t = cum_t / running_max_t - 1
Max DD = min(drawdown_t)
Cumulative portfolio value is the product of (1 + daily return). A drawdown is the percentage decline from the running historical high. Max DD is the smallest (worst) drawdown over the whole period.
Correlation matrix
The matrix of pairwise Pearson correlation coefficients is computed from daily asset returns. It is drawn as a d3.js heatmap with a colour range from –1 (red) to +1 (blue).
Period returns (30d / YTD / 1y)
For each period the closing price at the start of the period and the current price are taken. The weighted portfolio return is:
return = P(T) / P(t_cutoff) - 1
Results are cached in memory for 30 minutes (a TTL cache keyed by an MD5 of the tickers and weights).
Investor profiling
5 questions → risk score 1–10 → portfolio type
The questionnaire
The investor answers 5 questions, each with 4 options (scored 1–4):
- Investment horizon — from “less than 1 year” (1) to “more than 7 years” (4)
- Tolerable drawdown — from “down to −5%” (1) to “more than −30%” (4)
- Investment goal — from “capital preservation” (1) to “maximum growth” (4)
- Experience in financial markets — from “no experience” (1) to “more than 5 years” (4)
- Appetite for private companies — from “listed markets only” (1) to “over 20%” (4)
Scoring formula
scaled = round((raw - 5) * 10 / 15) + 1 (normalised → 1..10)
The resulting score from 1 to 10 is read as the level of risk tolerance.
Portfolio type (tier)
| Score | Type | Expected return | Volatility |
|---|---|---|---|
| 1–4 | Conservative | 4–6% p.a. | 5–8% |
| 5–7 | Balanced | 7–10% p.a. | 10–14% |
| 8–10 | Aggressive | 12–18% p.a. | 18–25% |
The type determines a recommended basket of instruments: for a conservative profile — bonds (IEF, TLT), gold (GLD) and defensive equities; for an aggressive one — technology mega-caps (NVDA, META, GOOGL) with a possible pre-IPO allocation of up to 20%.
Snowflake analysis of public companies
5 axes, each scored 0–6 (source: yfinance)
Every US company is assessed on five axes in the style of Simply Wall St. On each axis the algorithm assigns a score from 0 to 6. The resulting “snowflake” is drawn as a d3.js radar chart.
Value
P/E, P/B, P/S — the lower the multiple, the higher the score. Implemented with a bucket function over thresholds and a reverse=True flag.
P/E < 10 → 6, < 15 → 5, ..., > 50 → 0
Future
earningsGrowth and revenueGrowth (YoY forecast). The faster the growth, the higher the score.
EPS growth > 40% → 6, > 25% → 5...
Past
ROE, ROA, profitMargins — the historical quality of profitability.
ROE > 30% → 6, > 20% → 5...
Health
Debt/Equity (reverse: lower is better), currentRatio and quickRatio — balance-sheet resilience.
D/E < 10 → 6, currentRatio > 3 → 6
Dividends
dividendYield, payoutRatio (optimum around 40%), fiveYearAvgDividendYield. The payout ratio is scored through a parabolic normalisation peaking at payout = 40%.
pr_norm = max(0, 1 - |payout - 0.4| * 2)
The score for each axis is the arithmetic mean of the components available (rounded). The overall Snowflake score is the sum of the five axes (maximum 30). No Snowflake is built for MOEX tickers, because fundamentals from public APIs are not reliable enough.
SHAP scoring of private companies
CatBoost + SHAP values (11 features)
The model
For private companies (start-ups, pre-IPO) we use a binary CatBoostClassifier trained on Crunchbase data. The target variable: 1 — the company is developing successfully / has gone public / has been acquired; 0 — it has been wound up or closed.
Features (11 in total)
Numeric (8):
- founded_year — year of incorporation
- age_years — age of the company
- funding_rounds — number of funding rounds
- funding_total_usd — total funding raised ($)
- milestones — number of key milestones
- relationships — number of business relationships
- investment_rounds — the company's participation in investments
- invested_companies — number of companies invested in
Categorical (3):
- country_code — country of registration
- category_code — industry
- state_code — state / region (for the US)
SHAP explanation
Once the prediction success_probability = P(class=1) has been obtained (CatBoost
predict_proba), a SHAP value (SHapley Additive exPlanations) is computed for every feature with
shap.TreeExplainer. A SHAP value shows how much that feature contributes to the deviation
of the prediction from the base value (base_value = E[f(x)]).
SHAP_i > 0 — the feature increases the probability of success
SHAP_i < 0 — the feature decreases the probability of success
The interface shows the top 8 features by |SHAP_i|, sorted by descending absolute contribution.
Semantic analysis of the company description
Sentence-transformers + Crunchbase centroids
The algorithm
The company's text description (at least 30 characters) is turned into a dense embedding vector using
the multilingual model paraphrase-multilingual-MiniLM-L12-v2 from the
sentence-transformers library. The vector has 384 dimensions.
Class centroids
During offline pre-training, two centroids are computed over the Crunchbase corpus:
c_failure = mean( embed(desc_j) ) for closed companies
Both centroids are L2-normalised and stored in semantic_centroids.npz
Scoring a new description
sim_success = dot(v, c_success) (cosine similarity)
sim_failure = dot(v, c_failure)
success_lift = sim_success - sim_failure
verdict = "success" if success_lift > 0 else "risk"
The result includes the numeric similarity to both centroids and the resulting verdict.
The function is cached with lru_cache at the level of loading the model and the centroids.
Monthly stock ranking (the model forecast)
LightGBM cross-sectional ranker · one model per market · after Zhang (ICIAAI 2025)
This is the number behind "Model forecast for the month" on an asset card. It is a cross-sectional model: it does not predict where the market goes, it ranks the stocks of one market against each other for the coming month. The US, Russian (MOEX) and eurozone markets each have their own model, trained and measured separately — a z-score only means something inside one currency and one trading calendar.
Features
Eleven monthly features are built from daily bars, each using data up to and including the last
trading day of month t: 1-month and 12-2 momentum, 6-month momentum change, realised
volatility within the month and over 252 days, the largest daily return of the month, distance
from the 52-week high, change in traded volume, log average daily dollar volume, the Amihud
illiquidity ratio, and a size proxy (log 252-day average dollar volume — not market
capitalisation, which would need a share count we do not have historically).
Normalisation
winsorised at the 1st / 99th percentile of that month, then clipped to ±5
Normalisation is within the month, never across time — the model compares stocks with each other, not today's market with 2016's.
Feature selection
Every candidate feature is scored by the t-statistic of its monthly rank IC (Spearman correlation between the feature and next month's return) computed on the training months only; mean |SHAP| breaks ties. The top five go into the final model. Ranking by SHAP alone rewards whatever the model happened to split on, which on a noisy monthly target is not the same as predictive power.
Fitting
LightGBM regression (MSE) on the next-month return. The most recent fifth of the training months is held out to choose the number of trees by early stopping, and the model is then refitted on the full window with that number (never fewer than 60): monthly returns are mostly noise, so the stopper can otherwise settle on one or two trees, and a model that predicts a handful of distinct values across hundreds of stocks is not ranking anything.
Walk-forward evaluation
train on rows with month < t only
rank the month-t cross-section, buy the top N equal-weight, hold one month
The absence of look-ahead is proven, not asserted: the pipeline re-computes the features with the
future truncated and requires them to be bit-identical, requires the target to disappear when
month t+1 is removed, and re-derives every target independently.
Is it skill or luck?
Returns alone cannot answer that, so each model also reports the mean monthly rank IC with its t-statistic, the spread between the top and bottom deciles, turnover and the net-of-cost series (10 bps per side), plus a randomisation test: the same book size picked at random, thousands of times, to see where the model's return falls in that distribution. The numbers for the model currently serving a market are in its metadata, shown under "How this is calculated" on the portfolio screener.
Limitations
The universe is today's index membership applied backwards through history — survivorship bias that flatters both the strategy and the benchmark. Headline metrics exclude transaction costs (the net series is reported next to them). Sharpe uses a 0% risk-free rate. The forecast is a point estimate with wide uncertainty over a one-month horizon, and it is not investment advice.