DhanHQ 3.0.0 → 3.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.rubocop_todo.yml +7 -0
- data/lib/DhanHQ/agent/order_preview.rb +50 -0
- data/lib/DhanHQ/agent/policy.rb +51 -0
- data/lib/DhanHQ/agent/tool_registry.rb +250 -0
- data/lib/DhanHQ/agent.rb +12 -0
- data/lib/DhanHQ/ai/context_builder.rb +145 -0
- data/lib/DhanHQ/ai/prompt_helpers.rb +114 -0
- data/lib/DhanHQ/ai.rb +27 -0
- data/lib/DhanHQ/auth.rb +0 -1
- data/lib/DhanHQ/client.rb +1 -3
- data/lib/DhanHQ/constants.rb +2 -0
- data/lib/DhanHQ/contracts/iceberg_order_contract.rb +83 -0
- data/lib/DhanHQ/contracts/twap_order_contract.rb +106 -0
- data/lib/DhanHQ/core/auth_api.rb +0 -1
- data/lib/DhanHQ/errors.rb +4 -0
- data/lib/DhanHQ/events/base.rb +203 -0
- data/lib/DhanHQ/events/bus.rb +158 -0
- data/lib/DhanHQ/events.rb +40 -0
- data/lib/DhanHQ/indicators.rb +283 -0
- data/lib/DhanHQ/market_data/market_snapshot.rb +97 -0
- data/lib/DhanHQ/market_data/ohlc_series.rb +169 -0
- data/lib/DhanHQ/market_data/option_snapshot.rb +223 -0
- data/lib/DhanHQ/market_data.rb +25 -0
- data/lib/DhanHQ/mcp/server.rb +72 -0
- data/lib/DhanHQ/mcp.rb +10 -0
- data/lib/DhanHQ/models/funds.rb +12 -0
- data/lib/DhanHQ/models/holding.rb +42 -0
- data/lib/DhanHQ/models/iceberg_order.rb +139 -0
- data/lib/DhanHQ/models/instrument.rb +36 -0
- data/lib/DhanHQ/models/order.rb +95 -0
- data/lib/DhanHQ/models/position.rb +66 -0
- data/lib/DhanHQ/models/search_result.rb +12 -0
- data/lib/DhanHQ/models/trade.rb +13 -0
- data/lib/DhanHQ/models/twap_order.rb +136 -0
- data/lib/DhanHQ/option_analytics/black_scholes.rb +194 -0
- data/lib/DhanHQ/option_analytics/max_pain.rb +119 -0
- data/lib/DhanHQ/option_analytics.rb +36 -0
- data/lib/DhanHQ/resources/iceberg_orders.rb +61 -0
- data/lib/DhanHQ/resources/twap_orders.rb +61 -0
- data/lib/DhanHQ/risk/checks/asm_gsm.rb +17 -0
- data/lib/DhanHQ/risk/checks/market_hours.rb +37 -0
- data/lib/DhanHQ/risk/checks/options.rb +46 -0
- data/lib/DhanHQ/risk/checks/order_type.rb +20 -0
- data/lib/DhanHQ/risk/checks/product_support.rb +34 -0
- data/lib/DhanHQ/risk/checks/quantity.rb +32 -0
- data/lib/DhanHQ/risk/checks/trading_permission.rb +16 -0
- data/lib/DhanHQ/risk/pipeline.rb +65 -0
- data/lib/DhanHQ/risk.rb +250 -0
- data/lib/DhanHQ/skills/base.rb +132 -0
- data/lib/DhanHQ/skills/builtin/buy_atm_call.rb +87 -0
- data/lib/DhanHQ/skills/builtin/iron_condor.rb +93 -0
- data/lib/DhanHQ/skills/builtin/square_off_all.rb +45 -0
- data/lib/DhanHQ/skills/builtin/square_off_position.rb +48 -0
- data/lib/DhanHQ/skills/builtin/strangle.rb +93 -0
- data/lib/DhanHQ/skills/registry.rb +101 -0
- data/lib/DhanHQ/skills/workflow.rb +66 -0
- data/lib/DhanHQ/skills.rb +29 -0
- data/lib/DhanHQ/strategy/base.rb +189 -0
- data/lib/DhanHQ/strategy.rb +40 -0
- data/lib/DhanHQ/version.rb +1 -1
- data/lib/DhanHQ/ws/decoder.rb +57 -19
- data/lib/DhanHQ.rb +3 -0
- data/lib/dhan_hq/agent.rb +3 -0
- data/lib/dhan_hq/mcp.rb +3 -0
- data/lib/dhan_hq.rb +27 -2
- data/lib/ta/technical_analysis.rb +3 -1
- data/skills/dhanhq-ruby/SKILL.md +74 -0
- data/skills/dhanhq-ruby/references/market_data.md +3 -0
- data/skills/dhanhq-ruby/references/orders.md +7 -0
- metadata +59 -20
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../concerns/order_audit"
|
|
4
|
+
|
|
5
|
+
module DhanHQ
|
|
6
|
+
module Resources
|
|
7
|
+
# Resource client for TWAP order management.
|
|
8
|
+
class TwapOrders < BaseAPI
|
|
9
|
+
include DhanHQ::Concerns::OrderAudit
|
|
10
|
+
|
|
11
|
+
API_TYPE = :order_api
|
|
12
|
+
HTTP_PATH = "/v2/orders/twap"
|
|
13
|
+
|
|
14
|
+
# Lists all TWAP orders for the account.
|
|
15
|
+
#
|
|
16
|
+
# @return [Array<Hash>]
|
|
17
|
+
def all
|
|
18
|
+
get("")
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# Creates a new TWAP order.
|
|
22
|
+
#
|
|
23
|
+
# @param params [Hash]
|
|
24
|
+
# @return [Hash]
|
|
25
|
+
def create(params)
|
|
26
|
+
ensure_live_trading!
|
|
27
|
+
log_order_context("DHAN_TWAP_ORDER_ATTEMPT", params)
|
|
28
|
+
post("", params: params)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Fetches a specific TWAP order by ID.
|
|
32
|
+
#
|
|
33
|
+
# @param order_id [String]
|
|
34
|
+
# @return [Hash]
|
|
35
|
+
def find(order_id)
|
|
36
|
+
get("/#{order_id}")
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Updates an existing TWAP order.
|
|
40
|
+
#
|
|
41
|
+
# @param order_id [String]
|
|
42
|
+
# @param params [Hash]
|
|
43
|
+
# @return [Hash]
|
|
44
|
+
def update(order_id, params)
|
|
45
|
+
ensure_live_trading!
|
|
46
|
+
log_order_context("DHAN_TWAP_ORDER_MODIFY_ATTEMPT", params.merge(order_id: order_id))
|
|
47
|
+
put("/#{order_id}", params: params)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Cancels a TWAP order.
|
|
51
|
+
#
|
|
52
|
+
# @param order_id [String]
|
|
53
|
+
# @return [Hash]
|
|
54
|
+
def cancel(order_id)
|
|
55
|
+
ensure_live_trading!
|
|
56
|
+
log_order_context("DHAN_TWAP_ORDER_CANCEL_ATTEMPT", { order_id: order_id })
|
|
57
|
+
delete("/#{order_id}")
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module DhanHQ
|
|
4
|
+
module Risk
|
|
5
|
+
module Checks
|
|
6
|
+
# Blocks trading on ASM/GSM restricted instruments.
|
|
7
|
+
class AsmGsm
|
|
8
|
+
def self.run!(instrument:, **_unused)
|
|
9
|
+
return unless instrument.asm_gsm_flag == "Y"
|
|
10
|
+
|
|
11
|
+
raise DhanHQ::RiskViolation,
|
|
12
|
+
"ASM/GSM restricted instrument (#{instrument.asm_gsm_category})"
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module DhanHQ
|
|
4
|
+
module Risk
|
|
5
|
+
module Checks
|
|
6
|
+
# Blocks orders outside Indian market hours (9:15 AM - 3:30 PM IST).
|
|
7
|
+
class MarketHours
|
|
8
|
+
TIMEZONE_OFFSET = "+05:30"
|
|
9
|
+
OPEN_HOUR = 9
|
|
10
|
+
OPEN_MINUTE = 15
|
|
11
|
+
CLOSE_HOUR = 15
|
|
12
|
+
CLOSE_MINUTE = 30
|
|
13
|
+
|
|
14
|
+
def self.run!(now: Time.now, **_unused)
|
|
15
|
+
market_now = now.getlocal(TIMEZONE_OFFSET)
|
|
16
|
+
return if market_open?(market_now)
|
|
17
|
+
|
|
18
|
+
raise DhanHQ::RiskViolation, "Market is closed"
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def self.market_open?(now)
|
|
22
|
+
now.between?(market_open(now), market_close(now))
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def self.market_open(now)
|
|
26
|
+
Time.new(now.year, now.month, now.day, OPEN_HOUR, OPEN_MINUTE, 0, TIMEZONE_OFFSET)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def self.market_close(now)
|
|
30
|
+
Time.new(now.year, now.month, now.day, CLOSE_HOUR, CLOSE_MINUTE, 0, TIMEZONE_OFFSET)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
private_class_method :market_open?, :market_open, :market_close
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module DhanHQ
|
|
4
|
+
module Risk
|
|
5
|
+
module Checks
|
|
6
|
+
# Enforces option-specific risk rules (index-only, stop loss, target, risk-reward).
|
|
7
|
+
class Options
|
|
8
|
+
def self.run!(args:, instrument:, **_unused)
|
|
9
|
+
enforce_index!(instrument)
|
|
10
|
+
enforce_stop_loss!(args)
|
|
11
|
+
enforce_target!(args)
|
|
12
|
+
enforce_risk_reward!(args)
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def self.enforce_index!(instrument)
|
|
16
|
+
return if instrument.instrument_type == "INDEX"
|
|
17
|
+
|
|
18
|
+
raise DhanHQ::RiskViolation, "Options only allowed on index"
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def self.enforce_stop_loss!(args)
|
|
22
|
+
return if args["stop_loss"]
|
|
23
|
+
|
|
24
|
+
raise DhanHQ::RiskViolation, "Stop loss required"
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def self.enforce_target!(args)
|
|
28
|
+
return if args["target"]
|
|
29
|
+
|
|
30
|
+
raise DhanHQ::RiskViolation, "Target required"
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def self.enforce_risk_reward!(args)
|
|
34
|
+
stop_loss = args["stop_loss"].to_f
|
|
35
|
+
target = args["target"].to_f
|
|
36
|
+
return if target > stop_loss
|
|
37
|
+
|
|
38
|
+
raise DhanHQ::RiskViolation, "Invalid risk-reward"
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
private_class_method :enforce_index!, :enforce_stop_loss!,
|
|
42
|
+
:enforce_target!, :enforce_risk_reward!
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module DhanHQ
|
|
4
|
+
module Risk
|
|
5
|
+
module Checks
|
|
6
|
+
# Restricts order types to MARKET and LIMIT only.
|
|
7
|
+
class OrderType
|
|
8
|
+
VALID_TYPES = %w[MARKET LIMIT].freeze
|
|
9
|
+
|
|
10
|
+
def self.run!(args:, **_unused)
|
|
11
|
+
order_type = args["order_type"]
|
|
12
|
+
return unless order_type
|
|
13
|
+
return if VALID_TYPES.include?(order_type)
|
|
14
|
+
|
|
15
|
+
raise DhanHQ::RiskViolation, "Invalid order type"
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module DhanHQ
|
|
4
|
+
module Risk
|
|
5
|
+
module Checks
|
|
6
|
+
# Validates that the instrument supports the requested product type (BO/CO).
|
|
7
|
+
class ProductSupport
|
|
8
|
+
def self.run!(args:, instrument:, **_unused)
|
|
9
|
+
product_type = args["product_type"]
|
|
10
|
+
return unless product_type
|
|
11
|
+
|
|
12
|
+
enforce_bracket_support!(product_type, instrument)
|
|
13
|
+
enforce_cover_support!(product_type, instrument)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def self.enforce_bracket_support!(product_type, instrument)
|
|
17
|
+
return unless product_type == DhanHQ::Constants::ProductType::BO
|
|
18
|
+
return if instrument.bracket_flag == "Y"
|
|
19
|
+
|
|
20
|
+
raise DhanHQ::RiskViolation, "Bracket orders not supported"
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def self.enforce_cover_support!(product_type, instrument)
|
|
24
|
+
return unless product_type == DhanHQ::Constants::ProductType::CO
|
|
25
|
+
return if instrument.cover_flag == "Y"
|
|
26
|
+
|
|
27
|
+
raise DhanHQ::RiskViolation, "Cover orders not supported"
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
private_class_method :enforce_bracket_support!, :enforce_cover_support!
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module DhanHQ
|
|
4
|
+
module Risk
|
|
5
|
+
module Checks
|
|
6
|
+
# Enforces quantity and notional limits for MCP tool safety.
|
|
7
|
+
class Quantity
|
|
8
|
+
MAX_QUANTITY = 10
|
|
9
|
+
MAX_NOTIONAL = 100_000
|
|
10
|
+
|
|
11
|
+
def self.run!(args:, **_unused)
|
|
12
|
+
quantity = args["quantity"].to_i
|
|
13
|
+
raise DhanHQ::RiskViolation, "Quantity must be > 0" unless quantity.positive?
|
|
14
|
+
raise DhanHQ::RiskViolation, "Quantity exceeds limit" if quantity > MAX_QUANTITY
|
|
15
|
+
|
|
16
|
+
enforce_notional_limit!(quantity, args["price"])
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def self.enforce_notional_limit!(quantity, price)
|
|
20
|
+
return unless price
|
|
21
|
+
|
|
22
|
+
notional = quantity * price.to_f
|
|
23
|
+
return if notional <= MAX_NOTIONAL
|
|
24
|
+
|
|
25
|
+
raise DhanHQ::RiskViolation, "Notional exceeds limit"
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
private_class_method :enforce_notional_limit!
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module DhanHQ
|
|
4
|
+
module Risk
|
|
5
|
+
module Checks
|
|
6
|
+
# Blocks trading on instruments where buy_sell_indicator is not "A".
|
|
7
|
+
class TradingPermission
|
|
8
|
+
def self.run!(instrument:, **_unused)
|
|
9
|
+
return if instrument.buy_sell_indicator == "A"
|
|
10
|
+
|
|
11
|
+
raise DhanHQ::RiskViolation, "Trading disabled for instrument"
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module DhanHQ
|
|
4
|
+
module Risk
|
|
5
|
+
# Pre-execution risk pipeline that validates orders before they reach the broker.
|
|
6
|
+
#
|
|
7
|
+
# Runs a sequence of checks against the instrument and order arguments.
|
|
8
|
+
# Raises DhanHQ::RiskViolation on the first failure.
|
|
9
|
+
#
|
|
10
|
+
# @example Run equity risk checks
|
|
11
|
+
# DhanHQ::Risk::Pipeline.run!(
|
|
12
|
+
# instrument: instrument,
|
|
13
|
+
# args: args,
|
|
14
|
+
# now: Time.now,
|
|
15
|
+
# type: :equity
|
|
16
|
+
# )
|
|
17
|
+
#
|
|
18
|
+
# @example Run options risk checks
|
|
19
|
+
# DhanHQ::Risk::Pipeline.run!(
|
|
20
|
+
# instrument: instrument,
|
|
21
|
+
# args: args,
|
|
22
|
+
# now: Time.now,
|
|
23
|
+
# type: :options
|
|
24
|
+
# )
|
|
25
|
+
#
|
|
26
|
+
class Pipeline
|
|
27
|
+
CHECKS = [
|
|
28
|
+
Checks::TradingPermission,
|
|
29
|
+
Checks::AsmGsm,
|
|
30
|
+
Checks::ProductSupport,
|
|
31
|
+
Checks::OrderType,
|
|
32
|
+
Checks::Quantity,
|
|
33
|
+
Checks::MarketHours
|
|
34
|
+
].freeze
|
|
35
|
+
|
|
36
|
+
OPTION_CHECKS = [
|
|
37
|
+
Checks::Options
|
|
38
|
+
].freeze
|
|
39
|
+
|
|
40
|
+
# Run all applicable risk checks.
|
|
41
|
+
#
|
|
42
|
+
# @param instrument [Object] instrument with trading metadata
|
|
43
|
+
# @param args [Hash] order arguments (string keys)
|
|
44
|
+
# @param now [Time] current time for market hours check (default: Time.now)
|
|
45
|
+
# @param type [Symbol] :equity or :options
|
|
46
|
+
# @return [true] if all checks pass
|
|
47
|
+
# @raise [DhanHQ::RiskViolation] on first failure
|
|
48
|
+
# rubocop:disable Naming/PredicateMethod
|
|
49
|
+
def self.run!(instrument:, args:, now: Time.now, type: :equity)
|
|
50
|
+
run_checks!(CHECKS, instrument, args, now)
|
|
51
|
+
run_checks!(OPTION_CHECKS, instrument, args, now) if type == :options
|
|
52
|
+
true
|
|
53
|
+
end
|
|
54
|
+
# rubocop:enable Naming/PredicateMethod
|
|
55
|
+
|
|
56
|
+
def self.run_checks!(checks, instrument, args, now)
|
|
57
|
+
checks.each do |check|
|
|
58
|
+
check.run!(instrument: instrument, args: args, now: now)
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
private_class_method :run_checks!
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
data/lib/DhanHQ/risk.rb
ADDED
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module DhanHQ
|
|
4
|
+
# Risk management utilities for position sizing and order risk calculation.
|
|
5
|
+
module Risk
|
|
6
|
+
# Calculate optimal position size based on risk parameters.
|
|
7
|
+
#
|
|
8
|
+
# Supports multiple sizing methods:
|
|
9
|
+
# - Fixed risk percentage of account
|
|
10
|
+
# - Kelly criterion
|
|
11
|
+
# - Volatility-based sizing
|
|
12
|
+
#
|
|
13
|
+
# @example Calculate position size with 2% risk
|
|
14
|
+
# size = DhanHQ::Risk::PositionSizer.calculate(
|
|
15
|
+
# account_balance: 100_000,
|
|
16
|
+
# risk_percent: 2.0,
|
|
17
|
+
# entry_price: 2500,
|
|
18
|
+
# stop_loss_price: 2450
|
|
19
|
+
# )
|
|
20
|
+
# #=> 40
|
|
21
|
+
#
|
|
22
|
+
class PositionSizer
|
|
23
|
+
# Calculate position size based on fixed risk percentage.
|
|
24
|
+
#
|
|
25
|
+
# @param account_balance [Float] Total account balance
|
|
26
|
+
# @param risk_percent [Float] Percentage of account to risk per trade (e.g., 2.0 for 2%)
|
|
27
|
+
# @param entry_price [Float] Planned entry price
|
|
28
|
+
# @param stop_loss_price [Float] Planned stop loss price
|
|
29
|
+
# @param lot_size [Integer] Lot size for the instrument (default: 1)
|
|
30
|
+
# @return [Integer] Number of shares/lots to trade
|
|
31
|
+
def self.calculate(account_balance:, risk_percent:, entry_price:, stop_loss_price:, lot_size: 1)
|
|
32
|
+
return 0 if account_balance <= 0 || entry_price <= 0 || stop_loss_price <= 0
|
|
33
|
+
return 0 if entry_price == stop_loss_price
|
|
34
|
+
|
|
35
|
+
risk_amount = account_balance * (risk_percent / 100.0)
|
|
36
|
+
risk_per_share = (entry_price - stop_loss_price).abs
|
|
37
|
+
|
|
38
|
+
return 0 if risk_per_share.zero?
|
|
39
|
+
|
|
40
|
+
raw_shares = (risk_amount / risk_per_share).floor
|
|
41
|
+
(raw_shares / lot_size).floor * lot_size
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Calculate position size using Kelly Criterion.
|
|
45
|
+
#
|
|
46
|
+
# @param win_rate [Float] Historical win rate (0.0 to 1.0)
|
|
47
|
+
# @param avg_win [Float] Average winning trade amount
|
|
48
|
+
# @param avg_loss [Float] Average losing trade amount (positive number)
|
|
49
|
+
# @param account_balance [Float] Total account balance
|
|
50
|
+
# @param entry_price [Float] Planned entry price
|
|
51
|
+
# @param fraction [Float] Kelly fraction to use (default: 0.5 for half-Kelly)
|
|
52
|
+
# @return [Integer] Number of shares to trade
|
|
53
|
+
def self.kelly(win_rate:, avg_win:, avg_loss:, account_balance:, entry_price:, fraction: 0.5)
|
|
54
|
+
return 0 if account_balance <= 0 || entry_price <= 0
|
|
55
|
+
return 0 if avg_loss.zero? || win_rate <= 0 || win_rate >= 1
|
|
56
|
+
|
|
57
|
+
# Kelly formula: f = (bp - q) / b
|
|
58
|
+
# where b = avg_win/avg_loss, p = win_rate, q = 1 - win_rate
|
|
59
|
+
b = avg_win / avg_loss
|
|
60
|
+
kelly_fraction = ((b * win_rate) - (1 - win_rate)) / b
|
|
61
|
+
|
|
62
|
+
return 0 if kelly_fraction <= 0
|
|
63
|
+
|
|
64
|
+
# Apply fractional Kelly
|
|
65
|
+
adjusted_fraction = kelly_fraction * fraction
|
|
66
|
+
risk_amount = account_balance * adjusted_fraction
|
|
67
|
+
shares = (risk_amount / entry_price).floor
|
|
68
|
+
|
|
69
|
+
[shares, 0].max
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Calculate position size based on volatility (ATR-based).
|
|
73
|
+
#
|
|
74
|
+
# @param account_balance [Float] Total account balance
|
|
75
|
+
# @param risk_percent [Float] Percentage of account to risk per trade
|
|
76
|
+
# @param entry_price [Float] Planned entry price
|
|
77
|
+
# @param atr [Float] Current ATR value
|
|
78
|
+
# @param atr_multiplier [Float] Multiplier for ATR-based stop (default: 2.0)
|
|
79
|
+
# @param lot_size [Integer] Lot size for the instrument (default: 1)
|
|
80
|
+
# @return [Integer] Number of shares/lots to trade
|
|
81
|
+
def self.volatility_based(account_balance:, risk_percent:, entry_price:, atr:, atr_multiplier: 2.0, lot_size: 1)
|
|
82
|
+
return 0 if account_balance <= 0 || entry_price <= 0 || atr <= 0
|
|
83
|
+
|
|
84
|
+
stop_distance = atr * atr_multiplier
|
|
85
|
+
stop_loss_price = entry_price - stop_distance
|
|
86
|
+
|
|
87
|
+
calculate(
|
|
88
|
+
account_balance: account_balance,
|
|
89
|
+
risk_percent: risk_percent,
|
|
90
|
+
entry_price: entry_price,
|
|
91
|
+
stop_loss_price: stop_loss_price,
|
|
92
|
+
lot_size: lot_size
|
|
93
|
+
)
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# Calculate stop loss and take profit levels.
|
|
98
|
+
#
|
|
99
|
+
# @example Calculate stop loss
|
|
100
|
+
# levels = DhanHQ::Risk::SLCalculator.stop_loss(
|
|
101
|
+
# entry_price: 2500,
|
|
102
|
+
# risk_percent: 2.0,
|
|
103
|
+
# atr: 50
|
|
104
|
+
# )
|
|
105
|
+
# #=> { fixed: 2450, atr_based: 2400 }
|
|
106
|
+
#
|
|
107
|
+
class SLCalculator
|
|
108
|
+
# Calculate fixed percentage stop loss.
|
|
109
|
+
#
|
|
110
|
+
# @param entry_price [Float] Entry price
|
|
111
|
+
# @param risk_percent [Float] Risk percentage (e.g., 2.0 for 2%)
|
|
112
|
+
# @return [Float] Stop loss price
|
|
113
|
+
def self.percentage(entry_price:, risk_percent:)
|
|
114
|
+
return 0.0 if entry_price <= 0 || risk_percent <= 0
|
|
115
|
+
|
|
116
|
+
entry_price * (1 - (risk_percent / 100.0))
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# Calculate ATR-based stop loss.
|
|
120
|
+
#
|
|
121
|
+
# @param entry_price [Float] Entry price
|
|
122
|
+
# @param atr [Float] Current ATR value
|
|
123
|
+
# @param multiplier [Float] ATR multiplier (default: 2.0)
|
|
124
|
+
# @return [Float] Stop loss price
|
|
125
|
+
def self.atr_based(entry_price:, atr:, multiplier: 2.0)
|
|
126
|
+
return 0.0 if entry_price <= 0 || atr <= 0
|
|
127
|
+
|
|
128
|
+
entry_price - (atr * multiplier)
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# Calculate support-based stop loss.
|
|
132
|
+
#
|
|
133
|
+
# @param entry_price [Float] Entry price
|
|
134
|
+
# @param support_levels [Array<Float>] Array of support levels
|
|
135
|
+
# @param buffer [Float] Buffer below support (default: 0.5%)
|
|
136
|
+
# @return [Float] Stop loss price (below nearest support)
|
|
137
|
+
def self.support_based(entry_price:, support_levels:, buffer: 0.005)
|
|
138
|
+
return 0.0 if entry_price <= 0 || support_levels.nil? || support_levels.empty?
|
|
139
|
+
|
|
140
|
+
# Find nearest support below entry price
|
|
141
|
+
valid_supports = support_levels.select { |s| s < entry_price }
|
|
142
|
+
return 0.0 if valid_supports.empty?
|
|
143
|
+
|
|
144
|
+
nearest_support = valid_supports.max
|
|
145
|
+
nearest_support * (1 - buffer)
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
# Calculate take profit based on risk-reward ratio.
|
|
149
|
+
#
|
|
150
|
+
# @param entry_price [Float] Entry price
|
|
151
|
+
# @param stop_loss_price [Float] Stop loss price
|
|
152
|
+
# @param risk_reward_ratio [Float] Desired risk:reward ratio (e.g., 2.0 for 1:2)
|
|
153
|
+
# @return [Float] Take profit price
|
|
154
|
+
def self.take_profit(entry_price:, stop_loss_price:, risk_reward_ratio: 2.0)
|
|
155
|
+
return 0.0 if entry_price <= 0 || stop_loss_price <= 0
|
|
156
|
+
|
|
157
|
+
risk = (entry_price - stop_loss_price).abs
|
|
158
|
+
entry_price + (risk * risk_reward_ratio)
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
# Calculate trailing stop level.
|
|
162
|
+
#
|
|
163
|
+
# @param highest_price [Float] Highest price since entry
|
|
164
|
+
# @param atr [Float] Current ATR value
|
|
165
|
+
# @param multiplier [Float] ATR multiplier (default: 2.0)
|
|
166
|
+
# @return [Float] Trailing stop price
|
|
167
|
+
def self.trailing_stop(highest_price:, atr:, multiplier: 2.0)
|
|
168
|
+
return 0.0 if highest_price <= 0 || atr <= 0
|
|
169
|
+
|
|
170
|
+
highest_price - (atr * multiplier)
|
|
171
|
+
end
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
# Manage trailing stop updates.
|
|
175
|
+
#
|
|
176
|
+
# @example Create trail manager
|
|
177
|
+
# trail = DhanHQ::Risk::TrailManager.new(
|
|
178
|
+
# entry_price: 2500,
|
|
179
|
+
# initial_stop: 2450,
|
|
180
|
+
# atr: 50,
|
|
181
|
+
# trail_multiplier: 2.0
|
|
182
|
+
# )
|
|
183
|
+
# trail.update(2600) #=> { stop: 2500, triggered: false }
|
|
184
|
+
#
|
|
185
|
+
class TrailManager
|
|
186
|
+
attr_reader :entry_price, :initial_stop, :current_stop, :highest_price, :atr, :trail_multiplier
|
|
187
|
+
|
|
188
|
+
def initialize(entry_price:, initial_stop:, atr:, trail_multiplier: 2.0)
|
|
189
|
+
@entry_price = entry_price
|
|
190
|
+
@initial_stop = initial_stop
|
|
191
|
+
@current_stop = initial_stop
|
|
192
|
+
@highest_price = entry_price
|
|
193
|
+
@atr = atr
|
|
194
|
+
@trail_multiplier = trail_multiplier
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
# Update trail with new price.
|
|
198
|
+
#
|
|
199
|
+
# @param current_price [Float] Current market price
|
|
200
|
+
# @return [Hash] Hash with :stop, :highest, :triggered
|
|
201
|
+
def update(current_price)
|
|
202
|
+
return { stop: @current_stop, highest: @highest_price, triggered: false } if current_price <= 0
|
|
203
|
+
|
|
204
|
+
# Update highest price
|
|
205
|
+
@highest_price = current_price if current_price > @highest_price
|
|
206
|
+
|
|
207
|
+
# Calculate new trailing stop
|
|
208
|
+
new_stop = @highest_price - (@atr * @trail_multiplier)
|
|
209
|
+
|
|
210
|
+
# Stop can only move up, never down
|
|
211
|
+
@current_stop = new_stop if new_stop > @current_stop
|
|
212
|
+
|
|
213
|
+
# Check if stop is triggered
|
|
214
|
+
triggered = current_price <= @current_stop
|
|
215
|
+
|
|
216
|
+
{
|
|
217
|
+
stop: @current_stop,
|
|
218
|
+
highest: @highest_price,
|
|
219
|
+
triggered: triggered
|
|
220
|
+
}
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
# Check if stop is triggered at current price.
|
|
224
|
+
#
|
|
225
|
+
# @param current_price [Float] Current market price
|
|
226
|
+
# @return [Boolean]
|
|
227
|
+
def triggered?(current_price)
|
|
228
|
+
current_price <= @current_stop
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
# Calculate profit from entry to current price.
|
|
232
|
+
#
|
|
233
|
+
# @param current_price [Float] Current market price
|
|
234
|
+
# @return [Float] Profit per share
|
|
235
|
+
def profit(current_price)
|
|
236
|
+
current_price - entry_price
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
# Calculate profit percentage from entry to current price.
|
|
240
|
+
#
|
|
241
|
+
# @param current_price [Float] Current market price
|
|
242
|
+
# @return [Float] Profit percentage
|
|
243
|
+
def profit_percent(current_price)
|
|
244
|
+
return 0.0 if entry_price.zero?
|
|
245
|
+
|
|
246
|
+
(profit(current_price) / entry_price * 100).round(2)
|
|
247
|
+
end
|
|
248
|
+
end
|
|
249
|
+
end
|
|
250
|
+
end
|