diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..ded65b9 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,128 @@ +on: + push: + branches: + - main + paths: + - 'Cargo.toml' + - 'Cargo.lock' + - 'Dockerfile' + - 'src/**' + pull_request: + paths: + - 'Cargo.toml' + - 'Cargo.lock' + - 'Dockerfile' + - 'src/**' + +name: Continuous integration + +jobs: + cancel-previous: + name: Cancel Previous Runs + runs-on: ubuntu-latest + steps: + - name: Cancel actions + uses: styfle/cancel-workflow-action@0.8.0 + with: + access_token: ${{ github.token }} + + fmt: + name: Rustfmt + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v2 + - name: Cache + uses: actions/cache@v2 + id: cache + with: + path: | + ~/.cargo/registry + ~/.cargo/git + target + key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }} + - name: Setup git credentials + uses: fusion-engineering/setup-git-credentials@v2 + with: + credentials: ${{secrets.GIT_USER_CREDENTIALS}} + - name: Setup toolchain + uses: actions-rs/toolchain@v1 + with: + profile: minimal + toolchain: stable + override: true + - name: Install rustfmt + run: rustup component add rustfmt + - name: Run check + uses: actions-rs/cargo@v1 + with: + command: fmt + args: --all -- --check + + clippy: + name: Clippy + runs-on: ubuntu-latest + needs: [fmt] + steps: + - name: Checkout repository + uses: actions/checkout@v2 + - name: Cache + uses: actions/cache@v2 + id: cache + with: + path: | + ~/.cargo/registry + ~/.cargo/git + target + key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }} + - name: Setup git credentials + uses: fusion-engineering/setup-git-credentials@v2 + with: + credentials: ${{secrets.GIT_USER_CREDENTIALS}} + - name: Setup toolchain + uses: actions-rs/toolchain@v1 + with: + profile: minimal + toolchain: stable + override: true + - name: Install clippy + run: rustup component add clippy + - name: Run clippy + uses: actions-rs/cargo@v1 + with: + command: clippy + args: -- -D warnings + + coverage: + name: Test & Coverage + runs-on: ubuntu-latest + needs: [fmt, clippy] + steps: + - name: Checkout repository + uses: actions/checkout@v2 + - name: Cache + uses: actions/cache@v2 + id: cache + with: + path: | + ~/.cargo/registry + ~/.cargo/git + target + key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }} + - name: Setup git credentials + uses: fusion-engineering/setup-git-credentials@v2 + with: + credentials: ${{secrets.GIT_USER_CREDENTIALS}} + - name: Install toolchain + uses: actions-rs/toolchain@v1 + with: + toolchain: stable + - name: Install tarpaulin + run: cargo install cargo-tarpaulin + - name: Generate coverage + run: cargo tarpaulin --out Xml + - name: Upload to codecov + uses: codecov/codecov-action@v1 + with: + token: ${{secrets.CODECOV_TOKEN}} + fail_ci_if_error: true diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..25c8933 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,27 @@ +name: Release +on: + push: + branches: + - main + +jobs: + deploy: + name: Tag if new release + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v2 + - name: Read version number + id: read_toml + uses: SebRollen/toml-action@v1.0.0 + with: + file: Cargo.toml + field: package.version + - name: Set tag env variable + run: echo IMAGE_TAG=v${{steps.read_toml.outputs.value}} >> $GITHUB_ENV + - uses: ncipollo/release-action@v1 + continue-on-error: true + with: + allowUpdates: false + tag: ${{ env.IMAGE_TAG }} + token: ${{ secrets.GITHUB_TOKEN }} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..96ef6c0 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +/target +Cargo.lock diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..8027197 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "trading-base" +version = "0.1.0" +edition = "2018" + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[dependencies] +chrono = { version = "0.4", features = ["serde"] } +rust_decimal = "1.14" +serde = { version = "1.0", features = ["derive"] } +thiserror = "1.0" +uuid = { version = "0.8", features = ["v4", "serde"] } + +[dev-dependencies] +serde_json = "1.0.64" diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..44cd3f1 --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,21 @@ +use chrono::{DateTime, Utc}; +use thiserror::Error; + +mod position_intents; +pub use position_intents::{ + AmountSpec, PositionIntent, PositionIntentBuilder, TickerSpec, UpdatePolicy, +}; +mod trade_intents; +pub use trade_intents::{OrderType, TimeInForce, TradeIntent}; + +#[derive(Error, Clone, Debug)] +pub enum Error { + #[error( + "Non-`Zero` `AmountSpec`s of different type cannot be merged.\nLeft: {0:?}, Right: {1:?}" + )] + IncompatibleAmountError(AmountSpec, AmountSpec), + #[error("Cannot create PositionIntent with `before` < `after`. \nBefore: {0}, After: {1}")] + InvalidBeforeAfter(DateTime, DateTime), + #[error("TickerSpec `All` can only be used with the `Dollars` and `Shares` `AmountSpec`s")] + InvalidCombination, +} diff --git a/src/position_intents.rs b/src/position_intents.rs new file mode 100644 index 0000000..cb748ba --- /dev/null +++ b/src/position_intents.rs @@ -0,0 +1,217 @@ +use crate::Error; +use chrono::{DateTime, Utc}; +use rust_decimal::Decimal; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)] +#[serde(rename_all = "snake_case")] +pub enum UpdatePolicy { + Retain, + RetainLong, + RetainShort, + Update, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)] +#[serde(rename_all = "snake_case")] +pub enum AmountSpec { + Dollars(Decimal), + Shares(Decimal), + Percent(Decimal), + Zero, +} +impl AmountSpec { + pub fn merge(self, other: Self) -> Result { + match (self, other) { + (AmountSpec::Dollars(x), AmountSpec::Dollars(y)) => Ok(AmountSpec::Dollars(x + y)), + (AmountSpec::Shares(x), AmountSpec::Shares(y)) => Ok(AmountSpec::Shares(x + y)), + (AmountSpec::Percent(x), AmountSpec::Percent(y)) => Ok(AmountSpec::Percent(x + y)), + (AmountSpec::Zero, AmountSpec::Zero) => Ok(AmountSpec::Zero), + (AmountSpec::Zero, y) => Ok(y), + (x, AmountSpec::Zero) => Ok(x), + (x, y) => Err(Error::IncompatibleAmountError(x, y)), + } + } +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)] +#[serde(rename_all = "lowercase")] +pub enum TickerSpec { + Ticker(String), + All, +} + +impl From for TickerSpec { + fn from(s: T) -> Self { + Self::Ticker(s.to_string()) + } +} + +#[derive(Debug, Clone)] +pub struct PositionIntentBuilder { + strategy: String, + sub_strategy: Option, + ticker: TickerSpec, + amount: AmountSpec, + update_policy: UpdatePolicy, + decision_price: Option, + limit_price: Option, + stop_price: Option, + before: Option>, + after: Option>, +} + +impl PositionIntentBuilder { + pub fn sub_strategy(mut self, sub_strategy: impl Into) -> Self { + self.sub_strategy = Some(sub_strategy.into()); + self + } + + pub fn decision_price(mut self, decision_price: Decimal) -> Self { + self.decision_price = Some(decision_price); + self + } + + pub fn limit_price(mut self, limit_price: Decimal) -> Self { + self.limit_price = Some(limit_price); + self + } + + pub fn stop_price(mut self, stop_price: Decimal) -> Self { + self.stop_price = Some(stop_price); + self + } + + pub fn before(mut self, before: DateTime) -> Self { + self.before = Some(before); + self + } + + pub fn after(mut self, after: DateTime) -> Self { + self.after = Some(after); + self + } + + pub fn update_policy(mut self, policy: UpdatePolicy) -> Self { + self.update_policy = policy; + self + } + + pub fn build(self) -> Result { + if let Some((before, after)) = self.before.zip(self.after) { + if before < after { + return Err(Error::InvalidBeforeAfter(before, after)); + } + } + match (self.ticker.clone(), self.amount.clone()) { + (TickerSpec::All, AmountSpec::Dollars(_)) => return Err(Error::InvalidCombination), + (TickerSpec::All, AmountSpec::Shares(_)) => return Err(Error::InvalidCombination), + _ => (), + } + Ok(PositionIntent { + id: Uuid::new_v4(), + strategy: self.strategy, + sub_strategy: self.sub_strategy, + timestamp: Utc::now(), + ticker: self.ticker, + amount: self.amount, + update_policy: self.update_policy, + decision_price: self.decision_price, + limit_price: self.limit_price, + stop_price: self.stop_price, + before: self.before, + after: self.after, + }) + } +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)] +pub struct PositionIntent { + pub id: Uuid, + /// The strategy that is requesting a position. Dollar limits are shared between all positions + /// of the same strategy. + pub strategy: String, + /// Identifier for a specific leg of a position for a strategy. Sub-strategies must still + /// adhere to the dollar limits of the strategy, but the order-manager will keep track of the + /// holdings at the sub-strategy level. + #[serde(skip_serializing_if = "Option::is_none")] + pub sub_strategy: Option, + pub timestamp: DateTime, + pub ticker: TickerSpec, + pub amount: AmountSpec, + pub update_policy: UpdatePolicy, + /// The price at which the decision was made to send a position request. This can be used by + /// other parts of the app for execution analysis. This field might also be used for + /// translating between dollars and shares by the order-manager. + #[serde(skip_serializing_if = "Option::is_none")] + pub decision_price: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub limit_price: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub stop_price: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub before: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub after: Option>, +} + +impl PositionIntent { + pub fn builder( + strategy: impl Into, + ticker: impl Into, + amount: AmountSpec, + ) -> PositionIntentBuilder { + PositionIntentBuilder { + strategy: strategy.into(), + sub_strategy: None, + ticker: ticker.into(), + amount, + update_policy: UpdatePolicy::Update, + decision_price: None, + limit_price: None, + stop_price: None, + before: None, + after: None, + } + } +} + +#[cfg(test)] +mod test { + use super::*; + use chrono::Duration; + + #[test] + fn can_construct_position_intent() { + let builder = PositionIntent::builder("A", "AAPL", AmountSpec::Dollars(Decimal::new(1, 0))); + let _intent = builder + .sub_strategy("B") + .decision_price(Decimal::new(2, 0)) + .limit_price(Decimal::new(3, 0)) + .stop_price(Decimal::new(3, 0)) + .update_policy(UpdatePolicy::Retain) + .before(Utc::now() + Duration::hours(1)) + .after(Utc::now()) + .build() + .unwrap(); + } + + #[test] + fn can_serialize_and_deserialize() { + let builder = PositionIntent::builder("A", "AAPL", AmountSpec::Shares(Decimal::new(1, 0))); + let intent = builder + .sub_strategy("B") + .decision_price(Decimal::new(2, 0)) + .limit_price(Decimal::new(3, 0)) + .stop_price(Decimal::new(3, 0)) + .update_policy(UpdatePolicy::Retain) + .before(Utc::now() + Duration::hours(1)) + .after(Utc::now()) + .build() + .unwrap(); + let serialized = serde_json::to_string(&intent).unwrap(); + let deserialized = serde_json::from_str(&serialized).unwrap(); + assert_eq!(intent, deserialized); + } +} diff --git a/src/trade_intents.rs b/src/trade_intents.rs new file mode 100644 index 0000000..8b47e3a --- /dev/null +++ b/src/trade_intents.rs @@ -0,0 +1,100 @@ +use rust_decimal::Decimal; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +#[derive(Serialize, Deserialize, Debug, PartialEq, Clone)] +#[serde(tag = "order_type")] +pub enum OrderType { + Market, + Limit { + limit_price: Decimal, + }, + Stop { + stop_price: Decimal, + }, + StopLimit { + stop_price: Decimal, + limit_price: Decimal, + }, +} + +#[derive(Serialize, Deserialize, Debug, PartialEq, Clone)] +pub enum TimeInForce { + #[serde(rename = "gtc")] + GoodTilCanceled, + #[serde(rename = "day")] + Day, + #[serde(rename = "ioc")] + ImmediateOrCancel, + #[serde(rename = "fok")] + FillOrKill, + #[serde(rename = "opg")] + Open, + #[serde(rename = "cls")] + Close, +} + +#[derive(Serialize, Deserialize, Debug, PartialEq, Clone)] +pub struct TradeIntent { + pub id: Uuid, + #[serde(skip_serializing_if = "Option::is_none")] + pub position_intent_id: Option, + pub symbol: String, + pub qty: isize, + #[serde(flatten)] + pub order_type: OrderType, + pub time_in_force: TimeInForce, +} + +impl TradeIntent { + pub fn new(symbol: impl Into, qty: isize) -> Self { + Self { + id: Uuid::new_v4(), + position_intent_id: None, + symbol: symbol.into(), + qty, + order_type: OrderType::Market, + time_in_force: TimeInForce::Day, + } + } + + pub fn id(mut self, id: Uuid) -> Self { + self.id = id; + self + } + + pub fn position_intent_id(mut self, id: Uuid) -> Self { + self.position_intent_id = Some(id); + self + } + + pub fn order_type(mut self, order_type: OrderType) -> Self { + self.order_type = order_type; + self + } + + pub fn time_in_force(mut self, time_in_force: TimeInForce) -> Self { + self.time_in_force = time_in_force; + self + } +} + +#[cfg(test)] +mod test { + use super::*; + + #[test] + fn can_serialize_and_deserialize() { + let intent = TradeIntent::new("AAPL", 10) + .id(Uuid::new_v4()) + .position_intent_id(Uuid::new_v4()) + .order_type(OrderType::StopLimit { + stop_price: Decimal::new(100, 0), + limit_price: Decimal::new(101, 0), + }) + .time_in_force(TimeInForce::ImmediateOrCancel); + let serialized = serde_json::to_string(&intent).unwrap(); + let deserialized = serde_json::from_str(&serialized).unwrap(); + assert_eq!(intent, deserialized); + } +}