diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 236a947..ce92952 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -19,9 +19,7 @@ jobs: - name: cargo-check run: nix develop --command cargo check - name: cargo-test - run: nix develop --command cargo test - # - name: cargo-test - # run: nix develop --command cargo test --release + run: nix develop --command cargo test --all-features - name: cargo-clippy run: nix develop --command cargo clippy - name: cargo-bench @@ -40,7 +38,3 @@ jobs: run: nix develop --command alejandra --check . - name: yamlfmt run: nix develop --command yamlfmt -lint . - -# TODO: enable hongdown -# - name: markdown-formatting -# run: nix develop --command hongdown --check . diff --git a/.github/workflows/review.yml b/.github/workflows/review.yml new file mode 100644 index 0000000..49a6b7f --- /dev/null +++ b/.github/workflows/review.yml @@ -0,0 +1,165 @@ +name: "review" +# AI PR review: runs pi with read-only tools against the self-hosted vLLM +# endpoint (desg0) and posts the result as a PR comment. +# Separate workflow so a dead inference node shows up as a failed "review" +# check instead of failing "ci". +on: + pull_request: + branches: [main, dev] + types: [opened, synchronize, reopened] +concurrency: + group: review-${{ github.ref }} + cancel-in-progress: true +permissions: + contents: read + pull-requests: write + # The comment is created through the issues API (see post-review), so a + # scoped automatic token needs issue write access too. + issues: write +jobs: + review: + # Target self-hosted runner by label + runs-on: [nixos] + # Must stay above the inner `timeout 900` around pi. + timeout-minutes: 25 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + LLM_BASE_URL: "http://desg0:8000/v1" + LLM_MODEL: "RadixArk/Qwen3.8-27B-NVFP4" + steps: + - name: checkout-code + uses: actions/checkout@v4 + with: + fetch-depth: 0 + # Fail fast (instead of burning the timeout) if the inference node is + # not reachable from the runner. + - name: check-inference-endpoint + run: nix develop .#review --accept-flake-config --command curl -fsS --max-time 10 "$LLM_BASE_URL/models" > /dev/null && echo "inference endpoint reachable" + - name: run-pi-review + # Isolated config dir: the native runner user's own ~/.pi/agent + # (settings, extensions, other providers) must not leak into this job. + # The provider block mirrors the nix-managed pi wrapper's models.json + # (http://desg0:8000/v1, sglang, RadixArk/Qwen3.8-27B-NVFP4), so CI + # uses the exact same model config as local dev -- including deriving + # the token limits from the endpoint, see below. + env: + # Via env, not inline interpolation: a ${{ }} expansion inside a run + # block is textual substitution into shell source, i.e. the shape of + # an injection even when the context itself is trusted. + BASE_REF: ${{ github.base_ref }} + run: | + export PI_CODING_AGENT_DIR="${RUNNER_TEMP:-/tmp}/pi-review" + mkdir -p "$PI_CODING_AGENT_DIR" + ctx=$(nix develop .#review --accept-flake-config --command curl -fsS --max-time 10 "$LLM_BASE_URL/models" \ + | nix develop .#review --command jq -r '.data[0].max_model_len // 65536') + case "$ctx" in + '' | *[!0-9]*) + echo "could not derive the model window from the endpoint, got: $ctx" >&2 + exit 1 + ;; + esac + max_tokens=$((ctx / 4)) + echo "model window: $ctx tokens, output budget: $max_tokens tokens" + echo '{"providers":{"vllm":{"baseUrl":"'"$LLM_BASE_URL"'","api":"openai-completions","apiKey":"vllm","compat":{"supportsDeveloperRole":false,"supportsReasoningEffort":false,"thinkingFormat":"qwen-chat-template"},"models":[{"id":"'"$LLM_MODEL"'","reasoning":true,"contextWindow":'"$ctx"',"maxTokens":'"$max_tokens"'}]}}}' > "$PI_CODING_AGENT_DIR/models.json" + if ! git rev-parse --verify --quiet "origin/$BASE_REF" > /dev/null; then + echo "base branch origin/$BASE_REF is gone, cannot diff" >&2 + exit 1 + fi + # flake.lock and archify output are excluded on purpose: lock rev + # churn and rendered diagram artifacts (architecture.html is ~650KB + # of generated markup) are unreviewable noise that the model happily + # spends its word budget describing. + git diff "origin/$BASE_REF...HEAD" -- . ':(exclude)flake.lock' ':(exclude)agent-symbiont/architecture*' > pr.diff + echo "diff size: $(wc -c < pr.diff) bytes" + if [ ! -s pr.diff ]; then + echo "no reviewable changes" + exit 0 + fi + # 150KB of this repo's diffs is ~70K tokens (measured 2.2 + # bytes/token): fits ctx - max_tokens with room for pi's system + # prompt. Denser content (tables, hashes) raises the token count, + # so the cap stays conservative. + # ponytail: byte cap, no tokenizer; chunked multi-pass review if + # big PRs routinely get clipped. + TRUNC_NOTE="" + if [ "$(wc -c < pr.diff)" -gt 150000 ]; then + head -c 150000 pr.diff > pr.trunc && mv pr.trunc pr.diff + echo "diff truncated to 150000 bytes (context window limit)" + TRUNC_NOTE="NOTE: the diff was truncated at 150000 bytes; review only what is present and mention the truncation in the summary." + fi + timeout 900 nix develop .#review --accept-flake-config \ + --command pi -p \ + --provider vllm \ + --model "$LLM_MODEL" \ + --tools read,grep,find,ls \ + --no-session \ + --no-extensions \ + --no-approve \ + "You are an expert Rust code reviewer reviewing a pull request. + The diff against the base branch is on stdin. You may use the read, grep, find and ls tools to check the surrounding code, at most 5 calls, then write the review. + $TRUNC_NOTE + + Report only defects present in this diff: correctness bugs, broken error handling, security issues, inconsistencies with existing APIs and patterns, Rust performance traps, and missing tests for new behavior. + + Hard rules: + - Report the single most important issue, plus at most 2 others. Blockers and majors only. No nitpicks, no style or formatting remarks. + - Only claim what you verified in the diff or with a tool call. No speculation about future changes, no hypothetical misconfigurations, no advice you cannot tie to a specific line. + - flake.lock is excluded from the diff, so draw no conclusions about lock file contents or about whether flake inputs are pinned. + - One issue per bullet, at most 2 sentences: path and line, severity, what breaks, then the fix. + - Write the review once. Do not revise, re-rank or repeat the list, do not append a final or corrected list, do not comment on these rules. + - No preamble, no closing remarks, no thinking out loud. + - 200 words maximum for the whole review. + + Output exactly this markdown: + ## Summary + One or two sentences: what the PR changes and the verdict. + ## Issues + The bullets, or the single word None if there is no blocker or major issue." < pr.diff > review.md + if [ ! -s review.md ]; then + echo "pi produced an empty review (output budget exhausted or request rejected)" >&2 + exit 1 + fi + # Safety net: the model does not reliably honor the word budget, and a + # runaway review is worse than a clipped one. + if [ "$(wc -c < review.md)" -gt 4000 ]; then + { head -c 4000 review.md; printf '\n\n_(review truncated at 4000 bytes)_\n'; } > review.clipped + mv review.clipped review.md + echo "review truncated to 4000 bytes" + fi + echo "review written: $(wc -c < review.md) bytes" + - name: post-review + # Pull requests are issues in the GitHub API too, so POST + # /issues/{number}/comments is a valid PR comment route (the native + # one is /pulls/{number}/comments, which is for line comments and + # needs a commit id and a position). + env: + PR_NUMBER: ${{ github.event.pull_request.number }} + run: | + # Not `github.pr.number`: that context field does not exist and + # expands to the empty string, which silently mangles the URL. + if [ -z "${PR_NUMBER:-}" ]; then + echo "no PR number in the event payload" >&2 + exit 1 + fi + nix develop .#review --accept-flake-config --command jq -n --rawfile body review.md '{body: $body}' > review.json + # GITHUB_API_URL (https://api.github.com), not + # "$GITHUB_SERVER_URL/api/v1": the latter is the Gitea/Forgejo API + # shape, and github.com serves those legacy /api paths as a plain + # nginx "410 Gone". + # No `-f`: it discards the response body, which is where the API + # puts the actual reason a post was rejected. No `-L` either: curl + # turns a redirected POST into a GET, which would look like success + # while posting nothing. + code=$(nix develop .#review --accept-flake-config --command curl -sS -o resp.json -w '%{http_code}' -X POST \ + -H "Authorization: Bearer $GITHUB_TOKEN" \ + -H "Accept: application/vnd.github+json" \ + -H "X-GitHub-Api-Version: 2022-11-28" \ + -H "Content-Type: application/json" \ + --data @review.json \ + "$GITHUB_API_URL/repos/$GITHUB_REPOSITORY/issues/$PR_NUMBER/comments") + if [ "$code" != "201" ]; then + echo "posting the review failed with HTTP $code:" >&2 + cat resp.json >&2 + exit 1 + fi + echo "review posted to PR #$PR_NUMBER" diff --git a/Cargo.lock b/Cargo.lock index a52b5bd..95cf60a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1136,7 +1136,7 @@ checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" [[package]] name = "lfest" -version = "0.138.2" +version = "0.138.3" dependencies = [ "assert2", "const-decimal", diff --git a/Cargo.toml b/Cargo.toml index 6a5bf20..4ee9f00 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "lfest" -version = "0.138.2" +version = "0.138.3" authors = ["MathisWellmann "] edition = "2024" license-file = "LICENSE" diff --git a/flake.lock b/flake.lock index c3ab2cd..743e356 100644 --- a/flake.lock +++ b/flake.lock @@ -1,5 +1,38 @@ { "nodes": { + "bun2nix": { + "inputs": { + "flake-parts": [ + "llm-agents", + "flake-parts" + ], + "nixpkgs": [ + "llm-agents", + "nixpkgs" + ], + "systems": [ + "llm-agents", + "systems" + ], + "treefmt-nix": [ + "llm-agents", + "treefmt-nix" + ] + }, + "locked": { + "lastModified": 1784665499, + "narHash": "sha256-9BMxlTxCCDAeoNLtb1a/st7udtTIJep+wpUzquA29VU=", + "owner": "nix-community", + "repo": "bun2nix", + "rev": "0f2a1f0b6f42cebe3b149bf62d38754c5e0e9729", + "type": "github" + }, + "original": { + "owner": "nix-community", + "repo": "bun2nix", + "type": "github" + } + }, "fenix": { "inputs": { "nixpkgs": [ @@ -22,6 +55,27 @@ "type": "github" } }, + "flake-parts": { + "inputs": { + "nixpkgs-lib": [ + "llm-agents", + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1785627969, + "narHash": "sha256-4dtXQk/NMePegK/nWp5NSeuZKLATItOq61lpEvmXqGw=", + "owner": "hercules-ci", + "repo": "flake-parts", + "rev": "427bf4bd9435fdf21321c8cc628c24efc14c0f7a", + "type": "github" + }, + "original": { + "owner": "hercules-ci", + "repo": "flake-parts", + "type": "github" + } + }, "flake-utils": { "inputs": { "systems": "systems" @@ -78,6 +132,28 @@ "type": "github" } }, + "llm-agents": { + "inputs": { + "bun2nix": "bun2nix", + "flake-parts": "flake-parts", + "nixpkgs": "nixpkgs_3", + "systems": "systems_3", + "treefmt-nix": "treefmt-nix" + }, + "locked": { + "lastModified": 1787462289, + "narHash": "sha256-JzRwvboh8r37Coj6UhtnDiL0LIdPyz3+0zknDqS1zi0=", + "owner": "numtide", + "repo": "llm-agents.nix", + "rev": "5fae6955fa522382e640b65876fb2f456059f4c2", + "type": "github" + }, + "original": { + "owner": "numtide", + "repo": "llm-agents.nix", + "type": "github" + } + }, "naersk": { "inputs": { "fenix": "fenix", @@ -132,6 +208,22 @@ } }, "nixpkgs_3": { + "locked": { + "lastModified": 1787209939, + "narHash": "sha256-WvvHR4kSQLAbtouMC/ruZ5UpLwlUcY3K4FAllMN+yGk=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "391b592eb44808b3bd0cb80bb71b63a5a118b8bb", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixpkgs-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "nixpkgs_4": { "locked": { "lastModified": 1784210874, "narHash": "sha256-keY82hYMWt1hWhFYWVNFYf/meYjxdMjs6AyePFzr+0I=", @@ -144,7 +236,7 @@ "type": "indirect" } }, - "nixpkgs_4": { + "nixpkgs_5": { "locked": { "lastModified": 1744536153, "narHash": "sha256-awS2zRgF4uTwrOKwwiJcByDzDOdo3Q1rPZbiHQg/N38=", @@ -180,8 +272,9 @@ "inputs": { "flake-utils": "flake-utils", "hongdown": "hongdown", + "llm-agents": "llm-agents", "naersk": "naersk", - "nixpkgs": "nixpkgs_3", + "nixpkgs": "nixpkgs_4", "nixpks": "nixpks", "rust-overlay": "rust-overlay_2" } @@ -223,7 +316,7 @@ }, "rust-overlay_2": { "inputs": { - "nixpkgs": "nixpkgs_4" + "nixpkgs": "nixpkgs_5" }, "locked": { "lastModified": 1784265529, @@ -268,6 +361,42 @@ "repo": "default", "type": "github" } + }, + "systems_3": { + "locked": { + "lastModified": 1681028828, + "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", + "owner": "nix-systems", + "repo": "default", + "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", + "type": "github" + }, + "original": { + "owner": "nix-systems", + "repo": "default", + "type": "github" + } + }, + "treefmt-nix": { + "inputs": { + "nixpkgs": [ + "llm-agents", + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1786901030, + "narHash": "sha256-WSFCsDSE5ffgD2MqzkM2CYjeFiKhRF/dJUN8uedb6YE=", + "owner": "numtide", + "repo": "treefmt-nix", + "rev": "27b3b12a8e6375f28ebe122f07d230ca5459bbfa", + "type": "github" + }, + "original": { + "owner": "numtide", + "repo": "treefmt-nix", + "type": "github" + } } }, "root": "root", diff --git a/flake.nix b/flake.nix index dc69fa6..442655f 100644 --- a/flake.nix +++ b/flake.nix @@ -10,6 +10,8 @@ url = "github:nix-community/naersk"; inputs.nixpkgs.follows = "nixpkgs"; }; + # Provides the `pi` coding agent package (same input as nixos-config). + llm-agents.url = "github:numtide/llm-agents.nix"; }; outputs = { @@ -18,6 +20,7 @@ flake-utils, hongdown, naersk, + llm-agents, ... }: flake-utils.lib.eachDefaultSystem ( @@ -81,6 +84,16 @@ buildInputs = buildInputs ++ rust_tools ++ nix_tools ++ tools; RUST_BACKTRACE = "1"; }; + # Minimal shell for the AI PR review workflow + # (.github/workflows/review.yml). Kept tiny on purpose: `nix develop` + # builds this closure on the runner, so no rust/pythonEnv here. + devShells.review = mkShell { + buildInputs = with pkgs; [ + llm-agents.packages.${pkgs.stdenv.hostPlatform.system}.pi + jq + curl + ]; + }; } ); } diff --git a/src/account/active_limit_orders.rs b/src/account/active_limit_orders.rs index 1c836ef..f505ee1 100644 --- a/src/account/active_limit_orders.rs +++ b/src/account/active_limit_orders.rs @@ -119,7 +119,7 @@ where &self, ) -> impl Iterator>> { - self.bids.orders().iter().chain(self.asks.orders().iter()) + self.bids.iter().chain(self.asks.iter()) } /// Alias for [`Self::iter`] for callers treating active orders as values. @@ -322,7 +322,7 @@ where >; fn into_iter(self) -> Self::IntoIter { - self.bids.orders().iter().chain(self.asks.orders().iter()) + self.bids.iter().chain(self.asks.iter()) } } diff --git a/src/account/position.rs b/src/account/position.rs index 9b04d8b..9a0c7fe 100644 --- a/src/account/position.rs +++ b/src/account/position.rs @@ -28,6 +28,19 @@ pub enum PositionSide { Long, } +impl PositionSide { + /// Invert the side of the position. Long becomes short and vice-versa + #[inline] + pub fn invert(self) -> Self { + use PositionSide::*; + match self { + Short => Long, + Neutral => Neutral, + Long => Short, + } + } +} + /// A futures position can be one of three variants. #[derive(Debug, Clone, Default, Eq, PartialEq, Getters, CopyGetters)] pub struct Position @@ -319,6 +332,14 @@ mod tests { assert_eq!(size_of::>>(), 16); } + #[test] + fn position_side_invert() { + use PositionSide::*; + assert_eq!(Short.invert(), Long); + assert_eq!(Long.invert(), Short); + assert_eq!(Neutral.invert(), Neutral); + } + proptest! { #[test] fn position_change_proptest_neutral(qty in 1..100_i64, fill_price in 1..100_i64, do_buy in 0..2_i32) { diff --git a/src/account/sorted_orders.rs b/src/account/sorted_orders.rs index 8da716b..a8532f1 100644 --- a/src/account/sorted_orders.rs +++ b/src/account/sorted_orders.rs @@ -88,21 +88,24 @@ where } } + /// The number of limit orders. #[inline(always)] #[must_use] - pub(crate) fn len(&self) -> usize { + pub fn len(&self) -> usize { self.orders.len() } + /// If `true`, there are no limit orders. #[inline(always)] #[must_use] - pub(crate) fn is_empty(&self) -> bool { + pub fn is_empty(&self) -> bool { self.orders.is_empty() } + /// The limit order closest to the mid price. #[inline(always)] #[must_use] - pub(crate) fn best( + pub fn best( &self, ) -> Option<&LimitOrder>> { self.orders.last() @@ -140,7 +143,7 @@ where /// Get a `LimitOrder` by the given `OrderId` if any. #[inline(always)] #[must_use] - pub(crate) fn get_by_id( + pub fn get_by_id( &self, order_id: OrderId, ) -> Option<&LimitOrder>> { @@ -165,9 +168,10 @@ where }) } + /// Remove a limit order based on its user order id. #[inline(always)] #[must_use] - pub(crate) fn remove_by_user_id( + pub fn remove_by_user_id( &mut self, uid: UserOrderIdT, ) -> Option>> { @@ -220,6 +224,44 @@ where } } +impl + SortedOrders +where + I: Mon, + BaseOrQuote: Currency, + BaseOrQuote::PairedCurrency: MarginCurrency, + UserOrderIdT: UserOrderId, +{ + /// A non-consuming iterator over the orders, from worst to best price. + #[inline(always)] + pub fn iter( + &self, + ) -> std::slice::Iter<'_, LimitOrder>> + { + self.orders.iter() + } +} + +impl<'a, I, const D: u8, BaseOrQuote, UserOrderIdT, SideT> IntoIterator + for &'a SortedOrders +where + I: Mon, + BaseOrQuote: Currency, + BaseOrQuote::PairedCurrency: MarginCurrency, + UserOrderIdT: UserOrderId, +{ + type Item = &'a LimitOrder>; + type IntoIter = std::slice::Iter< + 'a, + LimitOrder>, + >; + + #[inline(always)] + fn into_iter(self) -> Self::IntoIter { + self.iter() + } +} + #[cfg(test)] mod tests { use rand::{ @@ -512,6 +554,33 @@ mod tests { assert!(bids.is_empty()); } + #[test] + fn sorted_orders_iter() { + let mut bids = + SortedOrders::, NoUserOrderId, Bids>::with_capacity( + NonZeroU16::new(3).unwrap(), + ); + for (i, price) in [100, 99, 101].into_iter().enumerate() { + let order = LimitOrder::new( + Side::Buy, + QuoteCurrency::new(price, 0), + BaseCurrency::new(1, 0), + ) + .unwrap(); + let meta = ExchangeOrderMeta::new((i as u64).into(), (i as i64).into()); + bids.try_insert(order.into_pending(meta)).unwrap(); + } + assert_eq!(bids.notional_sum, QuoteCurrency::new(300, 0)); + + // The non-consuming iterator goes worst to best and leaves everything in place. + assert_eq!( + bids.iter().map(|order| order.id()).collect::>(), + vec![1.into(), 0.into(), 2.into()] + ); + assert_eq!((&bids).into_iter().count(), 3); + assert_eq!(bids.notional_sum, QuoteCurrency::new(300, 0)); + } + #[test] fn sorted_orders_random() { let cap = 1000; diff --git a/src/trade_aggregation.rs b/src/trade_aggregation.rs index ba81ba2..64eac31 100644 --- a/src/trade_aggregation.rs +++ b/src/trade_aggregation.rs @@ -58,7 +58,10 @@ mod test { > as TakerTrade>::size(&trade), 5.0 ); - assert_eq!(trade.price(), 100.0); + assert_eq!( + > as TakerTrade>::price(&trade), + 100.0 + ); assert_eq!( > as TakerTrade>::price(&trade), 100.0 diff --git a/src/types/smol_currency/base_currency.rs b/src/types/smol_currency/base_currency.rs index 65f4647..2660a74 100644 --- a/src/types/smol_currency/base_currency.rs +++ b/src/types/smol_currency/base_currency.rs @@ -59,11 +59,32 @@ where I: Mon, { /// Create a new instance from an `integer` and a `scale`. + /// + /// # Panics: + /// If the arguments don't fit into the range and can't construct the internal `Decimal` type + /// For a panic free version, use `BaseCurrency::try_from_scaled` + #[inline(always)] pub fn new(integer: I, scale: u8) -> Self { - Self( - Decimal::try_from_scaled(integer, scale) - .expect("Can construct `Decimal` from `integer` and `scale`"), - ) + Self::try_from_scaled(integer, scale) + .expect("Can construct `Decimal` from `integer` and `scale`") + } + + /// Create a new instance from an `integer` and a `scale`. + #[inline(always)] + pub fn try_from_scaled(integer: I, scale: u8) -> Option { + Decimal::try_from_scaled(integer, scale).map(Self) + } + + /// Convert to `f64` + #[inline(always)] + pub fn to_f64(&self) -> f64 { + self.0.to_f64() + } + + /// Convert to `f32` + #[inline(always)] + pub fn to_f32(&self) -> f32 { + self.0.to_f32() } /// Round a number to a multiple of a given `quantum` toward zero. @@ -224,6 +245,18 @@ where } } +impl std::ops::Div for BaseCurrency { + type Output = Self; + + #[inline] + fn div(self, rhs: i64) -> Self::Output { + Self( + self.0 + / Decimal::try_from_scaled(rhs, 0).expect("i64 divisor exceeds decimal precision"), + ) + } +} + impl std::ops::Rem for BaseCurrency where I: Mon, @@ -310,10 +343,21 @@ mod test { assert_eq!(v % BaseCurrency::new(5, 0), BaseCurrency::new(3, 0)); assert_eq!(v.div(BaseCurrency::new(2, 0)), BaseCurrency::new(4, 0)); assert_eq!(v / BaseCurrency::new(2, 0), BaseCurrency::new(4, 0)); + assert_eq!(v / 2, BaseCurrency::new(4, 0)); let mut result = BaseCurrency::from_str_radix("27", 10).unwrap(); assert_eq!(result, BaseCurrency::::new(27, 0)); result.set_one(); assert_eq!(result, BaseCurrency::one()); } + + #[test] + fn base_currency_to_f64_to_f32() { + let v = BaseCurrency::::new(12345, 3); + assert_eq!(v.to_f64(), 12.345_f64); + assert_eq!(v.to_f32(), 12.345_f32); + let v = BaseCurrency::::new(-12345, 3); + assert_eq!(v.to_f64(), -12.345_f64); + assert_eq!(v.to_f32(), -12.345_f32); + } } diff --git a/src/types/smol_currency/quote_currency.rs b/src/types/smol_currency/quote_currency.rs index f7c6db9..28c25c5 100644 --- a/src/types/smol_currency/quote_currency.rs +++ b/src/types/smol_currency/quote_currency.rs @@ -58,11 +58,32 @@ impl QuoteCurrency where I: Mon, { - // TODO: return `Result` /// Create a new instance from an `integer` and a `scale`. + /// + /// # Panics: + /// If the arguments don't fit into the range and can't construct the internal `Decimal` type + /// For a panic free version, use `QuoteCurrency::try_from_scaled` + #[inline(always)] pub fn new(integer: I, scale: u8) -> Self { - assert2::debug_assert!(scale <= D); - Self(Decimal::try_from_scaled(integer, scale).expect("Make sure the inputs are correct.")) + Self::try_from_scaled(integer, scale).expect("Can construct from arguments") + } + + /// Create a new instance from an `integer` and a `scale`. + #[inline(always)] + pub fn try_from_scaled(integer: I, scale: u8) -> Option { + Decimal::try_from_scaled(integer, scale).map(Self) + } + + /// Convert to `f64` + #[inline(always)] + pub fn to_f64(&self) -> f64 { + self.0.to_f64() + } + + /// Convert to `f32` + #[inline(always)] + pub fn to_f32(&self) -> f32 { + self.0.to_f32() } #[inline] @@ -77,7 +98,8 @@ where Self(self.0 * (Decimal::one() + maint_margin_req)) } - pub(crate) fn new_weighted_price( + /// Create a weighted average price from two prices and their associated weights. + pub fn new_weighted_price( price_0: Self, weight_0: Decimal, price_1: Self, @@ -263,6 +285,15 @@ where } } +impl std::ops::Div for QuoteCurrency { + type Output = Self; + + #[inline] + fn div(self, rhs: i64) -> Self::Output { + self / Decimal::try_from_scaled(rhs, 0).expect("i64 divisor exceeds decimal precision") + } +} + impl std::ops::Rem for QuoteCurrency where I: Mon, @@ -349,6 +380,7 @@ mod test { assert_eq!(v % QuoteCurrency::new(5, 0), QuoteCurrency::new(3, 0)); assert_eq!(v.div(QuoteCurrency::new(2, 0)), QuoteCurrency::new(4, 0)); assert_eq!(v / QuoteCurrency::new(2, 0), QuoteCurrency::new(4, 0)); + assert_eq!(v / 2, QuoteCurrency::new(4, 0)); let mut result = QuoteCurrency::from_str_radix("27", 10).unwrap(); assert_eq!(result, QuoteCurrency::::new(27, 0)); @@ -356,4 +388,31 @@ mod test { assert_eq!(result, QuoteCurrency::one()); assert_eq!(QuoteCurrency::zero(), QuoteCurrency::::new(0, 0)); } + + #[test] + fn quote_currency_to_f64_to_f32() { + let v = QuoteCurrency::::new(12345, 3); + assert_eq!(v.to_f64(), 12.345_f64); + assert_eq!(v.to_f32(), 12.345_f32); + let v = QuoteCurrency::::new(-12345, 3); + assert_eq!(v.to_f64(), -12.345_f64); + assert_eq!(v.to_f32(), -12.345_f32); + } + + #[test] + fn new_weighted_price() { + let p0 = QuoteCurrency::::new(10, 0); + let p1 = QuoteCurrency::::new(20, 0); + let w1 = Decimal::try_from_scaled(1, 0).unwrap(); + assert_eq!( + QuoteCurrency::new_weighted_price(p0, w1, p1, w1), + QuoteCurrency::new(15, 0) + ); + // (10 * 1 + 20 * 3) / (1 + 3) = 17.5 + let w3 = Decimal::try_from_scaled(3, 0).unwrap(); + assert_eq!( + QuoteCurrency::new_weighted_price(p0, w1, p1, w3), + QuoteCurrency::new(175, 1) + ); + } }