Metadata-Version: 2.5
Name: fincanva
Version: 1.0.0
Summary: Build, backtest and analyse Fincanva strategies from Python.
Project-URL: Homepage, https://fincanva.com
Project-URL: Documentation, https://fincanva.com/developers
Author-email: Coding Wand <help@fincanva.com>
License-Expression: MIT
License-File: LICENSE
Keywords: backtesting,finance,investing,portfolio,simulation
Classifier: Development Status :: 5 - Production/Stable
Classifier: Intended Audience :: Financial and Insurance Industry
Classifier: Intended Audience :: Science/Research
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3 :: Only
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Programming Language :: Python :: 3.14
Classifier: Topic :: Office/Business :: Financial :: Investment
Classifier: Typing :: Typed
Requires-Python: >=3.10
Requires-Dist: httpx<1,>=0.27
Requires-Dist: pydantic<3,>=2.8
Provides-Extra: all
Requires-Dist: matplotlib>=3.7; extra == 'all'
Requires-Dist: pandas>=2.0; extra == 'all'
Provides-Extra: pandas
Requires-Dist: pandas>=2.0; extra == 'pandas'
Provides-Extra: plot
Requires-Dist: matplotlib>=3.7; extra == 'plot'
Description-Content-Type: text/markdown

# fincanva

Build, backtest and analyse Fincanva strategies from Python, with your own Fincanva account.

```
pip install "fincanva[all]"     # with pandas and matplotlib; plain `fincanva` works without them
```

```python
import matplotlib.pyplot as plt
import fincanva as fc

fc.connect()            # reads FINCANVA_API_KEY; never writes the key anywhere
fc.connect("fk_...")    # or the key itself
fc.connect(key_file="~/fincanva-api-key.txt")   # or a text file that holds the key alone

sim = fc.simulate("66f1c0a2b3d4e5f60718293b", start=2008)   # a saved strategy of yours, or a public one
print(sim.headline.strategy.cagr, sim.headline.strategy.sharpe)

ax = sim.equity().plot(title=sim.strategy_name)   # growth since the start: strategy beside benchmark
sim.regimes().shade(ax)                           # the risk-off periods in grey
plt.show()

sim.monthly_returns().loc["2020"]                 # one row per month
sim.metrics().to_frame()                          # every metric, strategy beside benchmark
sim.stress(volatility_multiplier=2.0).to_frame()  # the risk under a harsher view
sim.cone(horizon_days=1260)                       # a Monte Carlo cone, five years ahead
sim.trust().intervals()                           # bootstrap intervals of return, Sharpe, drawdown
sim.positions()                                   # what it holds today

assets = ["SPY", "TLT", "GLD"]
w = fc.allocation.weights(fc.allocation.RiskParity(), assets)    # today's weights by a method of the catalogue
w.weights, w.as_of                                                # by permaTicker, and the date they are for
fc.allocation.efficient_frontier(assets, allow_short=False).plot()
fc.allocation.walk_forward(fc.allocation.InverseVolatility(), assets, start=2015, rebalance_months=3).metrics.sharpe
fc.estimators.estimate(assets, fc.estimators.correlation.SpearmanOnReturns).matrix
r = fc.risk.regime(fc.risk.presets.Trend200)                     # risk-on or risk-off now, and since when
r.state, r.since, r.history

fc.raw                  # every API operation as a typed function, one to one
fc.allocation           # the allocation methods, one class each
fc.risk.presets         # the risk condition presets
fc.filters              # the screening filters, grouped as in the app
fc.estimators           # the covariance and correlation estimators

try:
    fc.allocation.walk_forward("risk_parity", assets, start=1990)
except fc.LicenceRefused as refused:   # raised only when a request reaches the protected long-run history
    refused.first_allowed_dates         # by permaTicker: the first date to ask again from (None: leave it out)
```

`fc.connect()` calls Fincanva at `https://app.fincanva.com`. To call another address, pass it as
`fc.connect(base_url="https://...")` or set `FINCANVA_BASE_URL`: the origin alone, or the origin followed by
`/api/v1/research`. The address must use https (plain http is accepted for `localhost` only), so the key never
travels unencrypted.

Python 3.10 or later. Without pandas, the typed results (`sim.headline`, `sim.metrics()`, `sim.trust()`,
`sim.stress()`, `sim.regimes()`) work as they are, and a method that returns a DataFrame raises
`fc.MissingDependency`, naming the extra to install, before it spends anything. The research results
(`w.weights`, `r.history`, ...) answer a dict or a list instead of a pandas object.

## The designed surface

Built on `fc.raw` alone: every method calls one API operation through it, never a transport of its own. Every number
is the API's own; a DataFrame reshapes an answer and never computes a figure.

| Call | Returns | API operation |
|---|---|---|
| `fc.simulate(strategy_id, *, start, benchmark, base_currency, starting_capital, variant)` | `Simulation` | `simulate_strategy` |
| `fc.Simulation(simulation_id, *, variant)`, `fc.Simulation.from_answer(answer)` | `Simulation` | none |
| `fc.Variant(costs=False, reinvest=True, taxes=False)`; `sim.with_variant(costs=True)` | the money-management variant | none |
| `sim.headline`, `sim.strategy_name`, `sim.start_year`, `sim.benchmark_name`, ... | typed, from the run | none |
| `sim.metrics()` | `results.Metrics`: typed; `.to_frame()`, `.by_year()` | `get_simulation_metrics` |
| `sim.equity()`, `sim.drawdown()`, `sim.equity_series(part, *, view, inflation_adjusted)` | DataFrame / Series by date | `get_simulation_equity` |
| `sim.monthly_returns()`, `sim.yearly_returns()` | DataFrame by month / year | `get_simulation_monthly_returns` |
| `sim.allocations(measure, *, sub_strategies)` | DataFrame by date, one column per sub-strategy | `get_simulation_allocations` |
| `sim.positions(on, *, capital)`, `sim.target_positions(...)`, `sim.orders(...)` | DataFrame | `get_simulation_positions` |
| `sim.regimes()` | `results.Regimes`: typed; `.periods()`, `.to_frame()`, `.shade(ax)` | `get_regime_timeline` |
| `sim.stress(*, annual_return_view, volatility_multiplier)` | `results.StressTest`: typed; `.to_frame()` | `run_stress_test` |
| `sim.cone(*, horizon_days, method, scenarios)` | DataFrame by day: `p5` to `p95` | `get_projection_cone` |
| `sim.trust(*, method, resamples)` | `results.Trust`: typed; `.intervals()`, `.walk_forward()` | `get_backtest_trust` |
| `sim.correlations(*, year)`, `sim.rolling_correlation(a, b)` | DataFrame of pairs / Series by date | `get_simulation_correlations`, `get_rolling_correlation` |

Every read is made on the simulation's own variant, so one object's results agree, and is kept on the object:
asking again spends nothing. Positions are always the base variant's, as the API reports them.

The allocation methods and the risk-on/off rules, called from the catalogue each choice is made from:

| Call | Returns | API operation |
|---|---|---|
| `fc.allocation.weights(method, assets, *, as_of, lookback_months, covariance_estimator, correlation_estimator)` | `results.Weights`: `.weights` by permaTicker, `.as_of`, `.instruments`, `.to_frame()` | `compute_weights` |
| `fc.allocation.combined_weights(method, strategy_id, *, as_of, lookback_months, covariance_estimator, timeout)` | `results.CombinedWeights`: `.weights` by sub-strategy id, `.rotation_date`, `.profile` | `compute_combined_weights` |
| `fc.allocation.efficient_frontier(assets, *, as_of, lookback_months, covariance_estimator, allow_short, min_weight, max_weight, limit_weights, points, place)` | `results.Frontier`: `.points`, `.minimum_volatility`, `.maximum_sharpe`, `.maximum_return`, `.placed`, `.plot(ax)` | `efficient_frontier` |
| `fc.allocation.walk_forward(method, assets, *, start, as_of, rebalance_months, lookback_months, covariance_estimator, correlation_estimator, variant, timeout)` | `results.WalkForward`: `.metrics`, `.years`, `.walk_forward_years`, `.rotations` | `walk_forward` |
| `fc.estimators.estimate(assets, estimator, *, matrix, as_of, lookback_months)` | `results.Estimate`: `.matrix` by permaTicker, `.kind`, `.estimator` | `estimate_covariance` |
| `fc.risk.regime(rule, *, start, as_of, strategy_id, timeout)` | `results.RuleRegime`: `.state`, `.since`, `.history`, `.periods()`, `.shade(ax)` | `compute_regime` |

`method` is an entry of `fc.allocation` with its settings (`fc.allocation.MeanVariance(objective="minimum_volatility")`),
the entry alone, or its id; its settings are checked against the level the call uses (inside a strategy, or a
Combined's) before anything is sent. An estimator of `fc.estimators.covariance` or `fc.estimators.correlation` carries
its own settings (`ExponentiallyWeighted(decay_factor=0.97)`) and chooses the matrix. `rule` is a preset of
`fc.risk.presets`, a preset's id, or a rule of a type as `create_strategy` takes a risk condition. Each result keeps the
operation's typed result as `.result`, beside `.summary` and `.assumed`.

Your library and your real holdings (K), batches, pipelines and long runs (P), strategy discovery (S), explanations (X),
your own series (U) and Fin Research (L), each its own module:

| Call | Returns | API operation |
|---|---|---|
| `fc.library.strategies(*, limit, offset)`, `.live_strategies(...)`, `.screeners(...)` | `library.Listing`: the typed entries; `.total`, `.next_offset`, `.allowance`, `.to_frame()` | `list_my_strategies`, `list_my_live_strategies`, `list_my_screeners` |
| `fc.library.strategy(strategy_id)`, `fc.library.screener(screener_id)` | the saved item in full, `.definition` as create_strategy / create_screener take it | `get_my_strategy`, `get_my_screener` |
| `fc.review.positions_to_weights(positions, *, cash, as_of, base_currency)` | `review.PositionWeights`: `.weights` by permaTicker, `.total_value`, `.stale`, `.to_frame()` | `review_strategy` |
| `fc.review.rebalance_plan(current, target, *, current_cash, target_cash, capital, base_currency, minimum_trade_weight, slippage, transaction_cost)` | `review.RebalancePlan`: `.trades`, `.dropped_trades`, `.turnover`, `.estimated_costs`, `.post_trade_weights` | `review_strategy` |
| `fc.review.live_trades(*, on, since, strategy_ids)` | `review.LiveTrades`: `.strategies`, `.refused`, `.to_frame()` | `review_strategy` |
| `fc.review.health_check(simulation, *, as_of, window_months, factors, regime_series, episodes, wait, timeout)` | `review.HealthCheck`: `.priorities`, `.not_computed`, `.episodes()`, every section typed | `review_strategy` |
| `fc.Job(job_id)`: `.status()`, `.wait(timeout)`, `.cancel()`, `.answer()`; `fc.jobs.cancel(job_id)` | `jobs.JobStatus`; the run's designed result; `jobs.Cancellation` | `get_result`, `cancel_job` |
| `fc.batch(calls, *, wait, timeout)` with `fc.Call(operation, **arguments)` | `batches.Batch`: one `BatchItem` per call (`.result`, `.answer()`, `.error`, `.job`, `.get()`), `.errors`, `.counts` | `run_batch` |
| `fc.pipeline({step_id: fc.Call(...)}, *, wait, timeout)` with `fc.from_step(step_id, pointer)` | `batches.Pipeline`: each step by id, as `BatchItem` | `run_pipeline` |
| `fc.research_guide(*, query, family, tool, method)` | `batches.Guide`: `.scenarios`, `.tool` | `get_research_guide` |
| `fc.discovery.discover_strategy(strategy_id, space, *, validation, folds, fold_years, objective, constraints, max_trials, ...)` with `Choices`, `Range`, `Alternatives` | `discovery.Discovery`: `.shortlist`, `.best`, `.pbo`, `.multiple_testing`, `.to_frame()`, `.trials()` | `discover` |
| `fc.discovery.discover_risk_rule(...)`, `.robust_parameter_region(...)`, `.improve_strategy(...)` | `discovery.Discovery`: also `.baseline`, `.region`, `.robust_centre`, `.proposals` | `discover` |
| `fc.discovery.trials(discovery_id, *, limit, offset)` | `discovery.DiscoveryTrials`: `.trials`, `.to_frame()`, `.next_page()` | `discover` |
| `fc.explain.performance(simulation, *, year, by_sub_strategy, by_asset, by_regime, by_factor, factors, drag, variant)` | `explain.PerformanceExplanation`: `.sub_strategies()`, `.assets()`, `.regimes()`, `.factors()`, `.drag` | `explain` |
| `fc.explain.trade(simulation, asset, on, *, variant)` | `explain.TradeExplanation`: `.position`, `.entry`, `.regime`, `.selection` | `explain` |
| `fc.explain.what_if(strategy_id, changed_strategy_id, *, start, variant, wait, timeout)` | `explain.WhatIf`: `.to_frame()`, `.years()`, `.relative_growth` | `explain` |
| `fc.my_series.upload(name, points, *, kind)`, `.list()`, `.delete(series)` | `my_series.UserSeries`: `.reference` (`user:u_...`, where a method names a series); `SeriesList`; the id deleted | `my_series` |
| `fc.fin_research.start(question, *, strategy_ids, screener_ids, language)`; `ResearchSession(session_id)`: `.get()`, `.wait()`, `.answer()`, `.approve()`, `.edit_plan()`, `.redirect()`, `.follow_up()`, `.cancel()`, `.report()`, `.refresh()`, `.schedule()`, `.delete()`; `fc.fin_research.sessions()`, `.schedules()`, `.delete_schedule()` | `fin_research.ResearchSession`, `Report` (`.text()` shows each cited figure as its display), `Schedule` | `research` |

Market research, one module per family: valuation, fundamentals, the universe of instruments, screener runs and options.
Each function calls one API operation, a method or a mode of it, and each result keeps the operation's typed result as
`.result` (every field the API answered) with named views, as the research results above do; a family's result classes
are named on its module (`fc.valuation.ReverseDcf`). An argument left out is not sent: the API applies its default.

| Call | Returns | API operation |
|---|---|---|
| `fc.valuation.reverse_dcf(asset, *, as_of, valuation_method, horizon_years, record_years, history_window_years, change_months, peer_group, discount_basis, discount_rate, terminal_growth)` | `ReverseDcf`: `.implied_growth`, `.implied_return`, `.excess_return_over_capm`, `.peer_percentile`, `.peer_gap` (each a `Figure(value, reason)`), `.figures`, `.to_frame()` | `value_company`, `reverse_dcf` |
| `fc.valuation.dcf_valuation(asset, *, stage1_growth, ...)` | `DcfValuation`: `.scenarios` (bear, base, bull, yours), `.scenario(name)`, `.to_frame()` | `value_company`, `dcf_valuation` |
| `fc.valuation.dcf_sensitivity(asset, *, discount_rates, terminal_growths, stage1_growth, ...)` | `DcfSensitivity`: `.grid` (rates by growths), `.value_at(rate, growth)` | `value_company`, `dcf_sensitivity` |
| `fc.valuation.implied_growth_history(asset, *, history_years, ...)` | `ImpliedGrowthHistory`: `.implied`, `.realized` by month-end, `.to_frame()` | `value_company`, `implied_growth_history` |
| `fc.fundamentals.report(asset, *, as_of)` | `Fundamentals`: `.filed`, `.income`, `.balance_sheet`, `.cash_flow`, `.ratios`, ..., `.to_frame()` | `analyze_fundamentals`, `get_fundamentals` |
| `fc.fundamentals.trends(asset, *, start, as_of)` | `FinancialTrends`: `.years`, `.growth`, `.streaks` | `analyze_fundamentals`, `financial_trends` |
| `fc.fundamentals.metric_history(asset, field, *, filter, start, as_of)` | `MetricHistory`: `.series`, `.latest` (percentile, z-score) | `analyze_fundamentals`, `get_metric_history` |
| `fc.fundamentals.valuation_vs_peers(asset, filter, *, start, as_of)` | `PeerValuation`: `.to_frame()` (the stock, its sector, its market, its index), `.latest` | `analyze_fundamentals`, `valuation_vs_peers` |
| `fc.fundamentals.market_valuation(*, start, as_of)` | `MarketValuation`: `.to_frame()` (Shiller P/E, Buffett indicator, its deviation), `.latest` | `analyze_fundamentals`, `market_valuation` |
| `fc.universe.find(queries, *, asset_class, region, currency, ucits, accumulating, candidates)` | `Found`: `.resolved`, `.unresolved` (never guessed), `.handle`, `.to_frame()` | `find_assets` |
| `fc.universe.coverage(assets, *, datasets, fields)` | `Coverage`: `.span(asset, dataset)`, `.unknown`, `.to_frame()` | `get_data_coverage` |
| `fc.universe.describe(asset)` | `AssetDescription`: `.name`, `.type`, `.sector`, `.size_tier`, `.statistics`, `.availability` | `explore_universe`, `describe_asset` |
| `fc.universe.universes(*, as_of)` | `Universes`: `.universes`, `.indices`, `.sectors` | `explore_universe`, `list_universes` |
| `fc.universe.index_members(index)` | `IndexMembers`: `.date`, `.perma_tickers`, `.to_frame()` (today's membership only) | `explore_universe`, `get_index_members` |
| `fc.universe.search_by_exposure(exposure, *, region, accumulating, as_of)` | `Exposures`: `.default_funds`, `.to_frame()` | `explore_universe`, `search_by_exposure` |
| `fc.universe.datasets()` | `Datasets`: `.version`, `.fields(dataset)`, `.to_frame()` | `explore_universe`, `list_datasets` |
| `fc.screens.screen(screener, *, limit, timeout)` | `Screen`: `.picks`, `.rows`, `.emptied_by`, `.handle`, `.to_frame()` | `run_screen`, `screen_assets` |
| `fc.screens.backtest(screener, *, timeout, wait)` | `ScreenBacktest`: `.horizons`, `.path`, `.row_impacts()` | `run_screen`, `backtest_screen` |
| `fc.screens.improve(screener, *, timeout, wait)`, `fc.screens.suggest(universe, *, name, timeout, wait)` | `ScreenSuggestion`: `.robust`, `.outperformance_score`, `.trials_evaluated`, `.screener_id`, `.problems` | `run_screen`, `improve_screen` / `suggest_screen` |
| `fc.screens.membership_changes(screener, since, *, as_of, timeout)` | `MembershipChanges`: `.entered`, `.left`, exact counts | `run_screen`, `screen_membership_changes` |
| `fc.screens.collect(run_id, *, timeout)`; `wait=False` answers a `fc.Job` | the run's result | `get_result` |
| `fc.options.price(right, *, underlying_price, strike, days_to_expiry, volatility, model, rate, dividend_yield, dividends)` (`model` and `rate` required by every calculator) | `OptionPrice`: `.price`, `.greeks`, `.units` | `price_options`, `price_option` |
| `fc.options.implied_volatility(right, *, ..., option_price, model, rate)` | `ImpliedVolatility`: `.implied_volatility`, `.reason`, `.greeks` | `price_options`, `implied_volatility` |
| `fc.options.payoff(legs, *, underlying_price, model, rate, ...)` with `fc.options.Leg(...)` | `StrategyPayoff`: `.profit`, `.breakevens`, `.max_profit`, `.max_loss`, `.probability_of_profit`, `.plot(ax)` | `price_options`, `strategy_payoff` |
| `fc.options.expiries(underlying, *, as_of)`, `fc.options.chain(underlying, expiry, *, greeks, min_abs_delta, root, as_of)` | `Expiries`; `OptionChain`: `.to_frame()`, `.calls()`, `.puts()` | `analyze_options`, `get_option_expiries` / `get_option_chain` |
| `fc.options.term_structure(underlying, ...)`, `fc.options.skew(underlying, ...)` | `TermStructure`, `Skew`: `.horizons`, `.expiries`, `.history` | `analyze_options`, `volatility_term_structure` / `volatility_skew` |
| `fc.options.implied_vs_realized(underlying, ...)` | `ImpliedVsRealized`: `.iv30`, `.iv_rank30`, `.iv_rv_spread`, `.realised`, `.vrp`, `.history` | `analyze_options`, `implied_vs_realized` |
| `fc.options.expected_move(underlying, to, *, measure, as_of)` | `ExpectedMove`: `.expected_move_pct`, `.target_date`, `.reason` | `analyze_options`, `expected_move` |
| `fc.options.positioning(underlying, *, profile, expiry, band_width, history_from)` | `Positioning`: `.pc_volume`, `.max_pain_strike`, `.gex_total`, `.walls`, `.bands`, `.history` | `analyze_options`, `options_positioning` |
| `fc.options.earnings_history(underlying, *, last_events, pair_type)` | `EarningsHistory`: `.events`, `.aggregates` | `analyze_options`, `earnings_options_history` |

`backtest`, `improve` and `suggest` are long runs, waited for like a simulation (a screener run is never cancelled); `improve` and `suggest` save what they
find as a new screener and are refused to an app set to read-only (`ToolsetDisabled`). A figure that does not apply is
None with its reason, never 0: a valuation that does not apply (`.reason`), reverse_dcf's peer figures while the peers
are not read (`peer_group_too_small`), an implied volatility no price gives (`no_volatility_reprices_it`).

The analysis families, one module each over one API operation that takes the method as `method` (each function sends
one method; the API operation and method are named in its documentation):

| Call | Returns | API operation, method |
|---|---|---|
| `fc.instruments.analyze(assets, *, start, benchmark, variant, as_of, years, sortino, factor_betas)` | `Backtests`: `.metrics`, `.years()`, `.factor_betas()` | `analyze_instruments`, `analyze_assets` |
| `fc.instruments.compare(assets, rank_on, *, start, order, ...)` | `Ranking`: `.rows`, `.best` | `compare_assets` |
| `fc.instruments.performance_history(assets, *, start, growth, real, drawdown, ...)` | `PerformanceHistory`: `.growth()`, `.drawdown()` by date | `get_performance_history` |
| `fc.instruments.rolling(assets, statistic, *, start, factor, rolling_months, window_years, ...)` | `RollingStatistics`: `.lines()`, `.window_outcomes()`, `.aggregates()` | `rolling_statistics` |
| `fc.instruments.calendar_returns(assets, *, start, ...)` | `CalendarReturns`: `.monthly()` by month, `.years()` | `calendar_returns` |
| `fc.instruments.drawdowns(assets, *, start, min_depth, top, ...)` | `Drawdowns`: `.episodes()`, `.lines()`, `.max_drawdown` | `drawdown_analysis` |
| `fc.instruments.return_distribution(assets, *, start, monthly, bins, ...)` | `ReturnDistribution`: `.summary()`, `.histogram()` | `return_distribution` |
| `fc.instruments.seasonality(assets, *, start, day_of_week, turn_of_month, ...)` | `Seasonality`: `.month_of_year()`, `.day_of_week()`, `.turn_of_month()` | `seasonality` |
| `fc.instruments.event_study(assets, *, benchmark, start, events, events_series, earnings, ...)` | `EventStudy`: `.car()`, `.path()`, `.events()`, `.skipped()` | `event_study` |
| `fc.relationships.betas(assets, against, *, lookback_months, as_of)` | `Betas`: `.betas`, `.to_frame()` | `analyze_relationships`, `beta_analysis` |
| `fc.relationships.expected_returns(assets, *, lookback_months, black_litterman, momentum_months, view_confidence, covariance_estimator)` | `ExpectedReturns`: `.annualized` | `estimate_expected_returns` |
| `fc.relationships.factor_exposure(assets, *, start, year, benchmark, variant)` | `FactorExposure`: `.factors()` | `factor_exposure` |
| `fc.relationships.factor_regression(assets, *, simulation, start, factors, monthly, rolling_months, ...)` | `FactorRegression`: `.betas()`, `.fit()`, `.rolling()` | `factor_regression` |
| `fc.relationships.pairs(first, second, *, lookback_months, johansen, z_score_window_days)` | `Pair`: `.cointegrated`, `.hedge_ratio`, `.half_life_days`, `.z_score` | `pairs_analysis` |
| `fc.relationships.risk_contributions(weights, *, lookback_months, covariance_estimator)` | `RiskContributions`: `.volatility`, `.risk_shares` | `risk_contributions` |
| `fc.relationships.correlation_matrix(assets, *, lookback_months, estimator, clustered)` | `CorrelationMatrix`: `.matrix` in its clustered order | `correlation_matrix` |
| `fc.relationships.regime_behaviour(rule, assets, *, simulation, start, ...)` | `RegimeBehaviour`: `.states()`, `.state`, `.time_in_regime` | `asset_regime_behaviour` |
| `fc.macro.series(kind, *, asset, start, as_of)` | `MacroSeries`: `.points` by date, `.latest` | `analyze_macro`, `get_macro_series` |
| `fc.macro.snapshot(*, as_of, start)` | `MacroSnapshot`: `.latest()` by kind | `macro_snapshot` |
| `fc.macro.sensitivity(assets, *, start, series, columns, monthly, benchmark, variant)` | `MacroSensitivity`: `.betas()`, `.fit()` or `.columns()` | `macro_sensitivity` |
| `fc.scenarios.crisis_replay(assets, *, start, episodes, ...)` | `CrisisReplay`: `.replay()`, `.episodes` | `run_scenario`, `crisis_replay` |
| `fc.scenarios.regime_projection(assets, *, start, horizon_days, scenarios, method, state_count, regime_series, ...)` | `RegimeProjection`: `.cone()`, `.regime` | `regime_conditional_projection` |
| `fc.scenarios.macro_scenario(assets, shocks, *, start, horizon_months, monthly, ...)` | `MacroScenario`: `.impacts()`, `.betas()` | `macro_scenario` |
| `fc.scenarios.historical_analogues(assets, *, start, features, horizon_months, count, minimum_gap_months, ...)` | `HistoricalAnalogues`: `.analogues()`, `.following_returns()`, `.today` | `historical_analogues` |
| `fc.robustness.cone(returns, *, horizon_days, scenarios, method)` | `SeriesCone`: `.bands` by day | `analyse_return_series`, `projection_cone_on_series` |
| `fc.robustness.trust(returns, *, risk_free_rate, resamples, method)` | `SeriesTrust`: `.intervals()` | `backtest_trust_on_series` |
| `fc.robustness.deflated_sharpe(sharpe, trial_sharpes, *, observations, skewness, kurtosis)` | `DeflatedSharpe`: `.probability` | `deflated_sharpe` |
| `fc.robustness.sweep(strategy_id, axis, *, rank_by, start, variant)` | `Sweep`: `.cells()`, `.control`, `.best` | `stress_strategy`, `sweep` |
| `fc.robustness.check_overfitting(strategy_id, axis, *, start, variant)` | `OverfittingCheck`: `.trials()`, `.best` | `check_overfitting` |
| `fc.robustness.stability_report(strategy_id, axis, *, rank_by, slicing, slice_years, step_years, minimum_era_days, ...)` | `StabilityReport`: `.stability()`, `.ranks()` | `get_stability_report` |

Every figure is an instrument's one-instrument backtest, a window of prices, a macro series or the numbers you give,
as the engine answers it. A method that computes backtests (`analyze`, `drawdowns`, `return_distribution`,
`seasonality`, `event_study`, `factor_exposure`, `factor_regression`, `regime_behaviour`, the regression form of
`macro.sensitivity`, every scenario, every sweep) is a long run: it waits up to `timeout` seconds, then raises
`RunStillRunning`, whose `run_id` the family's `collect(run_id)` or `fc.Job(run_id)` turns into the same typed result
later. `compare`, `performance_history`, `rolling` and `calendar_returns` read backtests
`analyze` computed, with the same instruments, start, benchmark and variant, and are refused `NotFoundError` before.
`variant` is a `fc.Variant`. Each result keeps the method's typed result as `.result`; its views answer pandas objects
when pandas is installed and plain Python otherwise, and `to_frame()` always needs pandas.

Every long run is one `fc.Job`. A call that may be a long run (a simulation, `health_check`, a search, a what-if, a
batch or a pipeline the request cannot finish, a screener's backtest or search, a method computed from backtests)
waits for it by default, up to `timeout` seconds; where it takes `wait=False`, it returns a `fc.Job` at once instead
(`job.wait()`, `job.cancel()`), already done when the API answered at once. A wait that runs out raises
`RunStillRunning`: the run goes on, and `fc.Job(error.run_id).wait()` picks it up and finishes as the same designed
result, whichever family started it. Following and cancelling a run are free; `job.cancel()` stops a batch, a
discovery, a walk-forward, a sweep or a method computed from backtests, and refuses the rest. A batch item that is
itself a long run holds its own `Job`. A Fin Research session is not a run: when `session.wait()` runs out, the
`RunStillRunning` it raises has no `run_id`, and `fc.fin_research.ResearchSession(session_id)` reads the session again.

The errors the API answers each have a class under `fc.FincanvaError`: `AuthenticationError`, `ValidationError`,
`PlanLimitError` (the plan, an allowance or the per-minute cap), `ToolsetDisabled` (the user's settings for a connected
app: `.family`, `.read_only`), `LicenceRefused` (data that may not leave Fincanva: for the protected long-run history,
`.first_allowed_dates` by permaTicker; otherwise `.dataset`, `.licence_class`, `.surface`), `NotFoundError`,
`SavedStrategyChanged`, `RunFailed`, `RunStillRunning`, `ServiceUnavailable`.

### Design notes

- **Install extras.** `fincanva[pandas]` (the DataFrames), `fincanva[plot]` (matplotlib, for `Regimes.shade()`
  without an axes), `fincanva[all]`. The core needs httpx and pydantic only. `import fincanva` never imports pandas,
  matplotlib or the generated models, and neither does importing a catalogue.
- **Typed results are the generated models.** `fincanva.results` subclasses the generated result model of each
  simulation answer and adds views, so the fields, names and documentation are `fc.raw`'s, and a field the API adds
  appears in both at once. A research result keeps that model as `.result` instead, because its named views
  (`.weights`, `.matrix`, `.history`) answer a pandas object or plain Python where the model has a list.
- **No instrument as a benchmark yet.** `benchmark=` takes `"strategy"`, `"none"` or a strategy id, as the API does;
  `fc.simulate(..., benchmark="ACWI")` is refused before anything is sent.
- **No "excluded" list.** `compute_weights` answers no entry for an instrument with no price on the date and states
  no list of what it left out; `Weights` shows what the API answered and never infers the rest.
- **Every other operation: through `fc.raw`.** The designed surface above covers every family of the API but three
  groups, which are in `fc.raw` only, one typed function each, with the operation's own arguments and typed result:
  building and saving strategies (`create_strategy`, `edit_strategy`, `replace_strategy`, `validate_strategy`,
  `explain_strategy`, `describe_strategy_model`; a designed `fc.Strategy` is not in this version), building and saving
  screeners (`create_screener`, `edit_screener`, `replace_screener`, `validate_screener`, `explain_screener`,
  `describe_screen_filter`), and more reads on simulations (`compare_simulations`, `get_contribution_analysis`,
  `get_simulation_trade_history`, `get_start_date_sensitivity`, `get_strategies_analytics`, `get_strategies_returns`,
  `get_rotation_dates`, `get_todays_rotations`). The four listings (`list_allocation_methods`,
  `list_covariance_estimators`, `list_risk_rule_presets`, `list_screen_filters`) are what the catalogues
  `fc.allocation`, `fc.estimators`, `fc.risk.presets` and `fc.filters` were recorded from; `fc.raw` asks them live.
