DhanHQ 3.0.0 → 3.1.0

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.
Files changed (119) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop.yml +2 -0
  3. data/.rubocop_todo.yml +7 -0
  4. data/CHANGELOG.md +51 -0
  5. data/README.md +152 -4
  6. data/docs/CONSTANTS_REFERENCE.md +3 -2
  7. data/exe/dhanhq-mcp +7 -0
  8. data/lib/DhanHQ/agent/order_preview.rb +50 -0
  9. data/lib/DhanHQ/agent/policy.rb +51 -0
  10. data/lib/DhanHQ/agent/tool_registry.rb +299 -0
  11. data/lib/DhanHQ/agent.rb +12 -0
  12. data/lib/DhanHQ/ai/context_builder.rb +145 -0
  13. data/lib/DhanHQ/ai/prompt_helpers.rb +114 -0
  14. data/lib/DhanHQ/ai.rb +27 -0
  15. data/lib/DhanHQ/auth.rb +0 -1
  16. data/lib/DhanHQ/client.rb +1 -3
  17. data/lib/DhanHQ/concerns/order_audit.rb +43 -1
  18. data/lib/DhanHQ/constants.rb +5 -2
  19. data/lib/DhanHQ/contracts/forever_order_contract.rb +1 -1
  20. data/lib/DhanHQ/contracts/iceberg_order_contract.rb +83 -0
  21. data/lib/DhanHQ/contracts/place_order_contract.rb +1 -1
  22. data/lib/DhanHQ/contracts/twap_order_contract.rb +106 -0
  23. data/lib/DhanHQ/core/auth_api.rb +0 -1
  24. data/lib/DhanHQ/errors.rb +4 -0
  25. data/lib/DhanHQ/events/base.rb +203 -0
  26. data/lib/DhanHQ/events/bus.rb +158 -0
  27. data/lib/DhanHQ/events.rb +40 -0
  28. data/lib/DhanHQ/indicators.rb +283 -0
  29. data/lib/DhanHQ/market_data/market_snapshot.rb +97 -0
  30. data/lib/DhanHQ/market_data/ohlc_series.rb +169 -0
  31. data/lib/DhanHQ/market_data/option_snapshot.rb +223 -0
  32. data/lib/DhanHQ/market_data.rb +25 -0
  33. data/lib/DhanHQ/mcp/server.rb +235 -0
  34. data/lib/DhanHQ/mcp.rb +10 -0
  35. data/lib/DhanHQ/models/funds.rb +12 -0
  36. data/lib/DhanHQ/models/holding.rb +42 -0
  37. data/lib/DhanHQ/models/iceberg_order.rb +139 -0
  38. data/lib/DhanHQ/models/instrument.rb +80 -14
  39. data/lib/DhanHQ/models/order.rb +95 -0
  40. data/lib/DhanHQ/models/position.rb +66 -0
  41. data/lib/DhanHQ/models/search_result.rb +12 -0
  42. data/lib/DhanHQ/models/trade.rb +13 -0
  43. data/lib/DhanHQ/models/twap_order.rb +136 -0
  44. data/lib/DhanHQ/option_analytics/black_scholes.rb +194 -0
  45. data/lib/DhanHQ/option_analytics/max_pain.rb +119 -0
  46. data/lib/DhanHQ/option_analytics.rb +36 -0
  47. data/lib/DhanHQ/rate_limiter.rb +5 -3
  48. data/lib/DhanHQ/resources/alert_orders.rb +1 -0
  49. data/lib/DhanHQ/resources/forever_orders.rb +1 -0
  50. data/lib/DhanHQ/resources/iceberg_orders.rb +62 -0
  51. data/lib/DhanHQ/resources/orders.rb +2 -0
  52. data/lib/DhanHQ/resources/pnl_exit.rb +1 -0
  53. data/lib/DhanHQ/resources/super_orders.rb +1 -0
  54. data/lib/DhanHQ/resources/twap_orders.rb +62 -0
  55. data/lib/DhanHQ/risk/checks/asm_gsm.rb +17 -0
  56. data/lib/DhanHQ/risk/checks/concentration.rb +37 -0
  57. data/lib/DhanHQ/risk/checks/market_hours.rb +37 -0
  58. data/lib/DhanHQ/risk/checks/max_loss.rb +24 -0
  59. data/lib/DhanHQ/risk/checks/options.rb +46 -0
  60. data/lib/DhanHQ/risk/checks/order_type.rb +20 -0
  61. data/lib/DhanHQ/risk/checks/position_limits.rb +24 -0
  62. data/lib/DhanHQ/risk/checks/product_support.rb +34 -0
  63. data/lib/DhanHQ/risk/checks/quantity.rb +32 -0
  64. data/lib/DhanHQ/risk/checks/trading_permission.rb +16 -0
  65. data/lib/DhanHQ/risk/pipeline.rb +72 -0
  66. data/lib/DhanHQ/risk.rb +250 -0
  67. data/lib/DhanHQ/skills/base.rb +183 -0
  68. data/lib/DhanHQ/skills/builtin/bear_call_spread.rb +87 -0
  69. data/lib/DhanHQ/skills/builtin/bull_put_spread.rb +87 -0
  70. data/lib/DhanHQ/skills/builtin/buy_atm_call.rb +84 -0
  71. data/lib/DhanHQ/skills/builtin/covered_call.rb +85 -0
  72. data/lib/DhanHQ/skills/builtin/iron_condor.rb +89 -0
  73. data/lib/DhanHQ/skills/builtin/market_data_summarizer.rb +195 -0
  74. data/lib/DhanHQ/skills/builtin/protective_put.rb +90 -0
  75. data/lib/DhanHQ/skills/builtin/square_off_all.rb +42 -0
  76. data/lib/DhanHQ/skills/builtin/square_off_position.rb +50 -0
  77. data/lib/DhanHQ/skills/builtin/straddle.rb +88 -0
  78. data/lib/DhanHQ/skills/builtin/strangle.rb +93 -0
  79. data/lib/DhanHQ/skills/registry.rb +101 -0
  80. data/lib/DhanHQ/skills/workflow.rb +66 -0
  81. data/lib/DhanHQ/skills.rb +29 -0
  82. data/lib/DhanHQ/strategy/base.rb +189 -0
  83. data/lib/DhanHQ/strategy.rb +40 -0
  84. data/lib/DhanHQ/version.rb +1 -1
  85. data/lib/DhanHQ/ws/decoder.rb +57 -19
  86. data/lib/DhanHQ.rb +3 -0
  87. data/lib/dhan_hq/agent.rb +3 -0
  88. data/lib/dhan_hq/mcp.rb +3 -0
  89. data/lib/dhan_hq.rb +74 -2
  90. data/lib/ta/technical_analysis.rb +3 -1
  91. data/skills/dhanhq-ruby/SKILL.md +207 -0
  92. data/skills/dhanhq-ruby/examples/fetch_option_chain.rb +54 -0
  93. data/skills/dhanhq-ruby/examples/gtt_forever_order.rb +65 -0
  94. data/skills/dhanhq-ruby/examples/historical_data_analysis.rb +89 -0
  95. data/skills/dhanhq-ruby/examples/iron_condor.rb +137 -0
  96. data/skills/dhanhq-ruby/examples/live_feed_setup.rb +43 -0
  97. data/skills/dhanhq-ruby/examples/margin_check.rb +42 -0
  98. data/skills/dhanhq-ruby/examples/order_management.rb +105 -0
  99. data/skills/dhanhq-ruby/examples/place_equity_order.rb +36 -0
  100. data/skills/dhanhq-ruby/examples/place_fno_order.rb +76 -0
  101. data/skills/dhanhq-ruby/examples/portfolio_summary.rb +74 -0
  102. data/skills/dhanhq-ruby/examples/super_order_with_sl.rb +57 -0
  103. data/skills/dhanhq-ruby/references/backtesting-with-dhan.md +65 -0
  104. data/skills/dhanhq-ruby/references/common-workflows.md +76 -0
  105. data/skills/dhanhq-ruby/references/error-codes.md +50 -0
  106. data/skills/dhanhq-ruby/references/funds.md +67 -0
  107. data/skills/dhanhq-ruby/references/instruments.md +85 -0
  108. data/skills/dhanhq-ruby/references/live-feed.md +83 -0
  109. data/skills/dhanhq-ruby/references/market-data.md +119 -0
  110. data/skills/dhanhq-ruby/references/option-chain.md +71 -0
  111. data/skills/dhanhq-ruby/references/options-analysis-patterns.md +76 -0
  112. data/skills/dhanhq-ruby/references/orders.md +201 -0
  113. data/skills/dhanhq-ruby/references/portfolio.md +93 -0
  114. data/skills/dhanhq-ruby/references/scanx-data.md +62 -0
  115. data/skills/dhanhq-ruby/scripts/dhan_helpers.rb +323 -0
  116. data/skills/dhanhq-ruby/scripts/resolve_security.rb +168 -0
  117. data/skills/dhanhq-ruby/scripts/trade_logger.rb +131 -0
  118. data/skills/dhanhq-ruby/scripts/validate_order.rb +169 -0
  119. metadata +95 -20
@@ -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,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DhanHQ
4
+ module Risk
5
+ module Checks
6
+ # Enforces maximum number of concurrent open positions.
7
+ class PositionLimits
8
+ MAX_OPEN_POSITIONS = 20
9
+
10
+ def self.run!(**_unused)
11
+ positions = DhanHQ::Models::Position.all
12
+ open_count = positions.count do |p|
13
+ p.net_qty.to_i != 0
14
+ end
15
+
16
+ return if open_count < MAX_OPEN_POSITIONS
17
+
18
+ raise DhanHQ::RiskViolation,
19
+ "Maximum #{MAX_OPEN_POSITIONS} open positions exceeded (#{open_count} open)"
20
+ end
21
+ end
22
+ end
23
+ end
24
+ 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,72 @@
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
+ Checks::PositionLimits,
35
+ Checks::Concentration
36
+ ].freeze
37
+
38
+ OPTION_CHECKS = [
39
+ Checks::Options
40
+ ].freeze
41
+
42
+ DAILY_CHECKS = [
43
+ Checks::MaxLoss
44
+ ].freeze
45
+
46
+ # Run all applicable risk checks.
47
+ #
48
+ # @param instrument [Object] instrument with trading metadata
49
+ # @param args [Hash] order arguments (string keys)
50
+ # @param now [Time] current time for market hours check (default: Time.now)
51
+ # @param type [Symbol] :equity or :options
52
+ # @return [true] if all checks pass
53
+ # @raise [DhanHQ::RiskViolation] on first failure
54
+ # rubocop:disable Naming/PredicateMethod
55
+ def self.run!(instrument:, args:, now: Time.now, type: :equity)
56
+ run_checks!(CHECKS, instrument, args, now)
57
+ run_checks!(OPTION_CHECKS, instrument, args, now) if type == :options
58
+ run_checks!(DAILY_CHECKS, instrument, args, now)
59
+ true
60
+ end
61
+ # rubocop:enable Naming/PredicateMethod
62
+
63
+ def self.run_checks!(checks, instrument, args, now)
64
+ checks.each do |check|
65
+ check.run!(instrument: instrument, args: args, now: now)
66
+ end
67
+ end
68
+
69
+ private_class_method :run_checks!
70
+ end
71
+ end
72
+ end
@@ -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
@@ -0,0 +1,183 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DhanHQ
4
+ module Skills
5
+ # Base class for all trading skills.
6
+ #
7
+ # Provides a DSL for defining parameters, steps, and execution logic.
8
+ # Skills are stateless — context is passed through and returned.
9
+ #
10
+ # @example
11
+ # class BuyAtmCall < DhanHQ::Skills::Base
12
+ # param :symbol, type: :string, required: true
13
+ # param :expiry, type: :string, required: true
14
+ # param :quantity, type: :integer, default: 50
15
+ #
16
+ # step :find_instrument
17
+ # step :get_spot_price
18
+ # step :prepare_intent
19
+ #
20
+ # def find_instrument(ctx)
21
+ # ctx[:instrument] = DhanHQ::Models::Instrument.find("IDX_I", ctx[:symbol])
22
+ # ctx
23
+ # end
24
+ #
25
+ # def get_spot_price(ctx)
26
+ # ctx[:spot_price] = ctx[:instrument].ltp
27
+ # ctx
28
+ # end
29
+ #
30
+ # def prepare_intent(ctx)
31
+ # ctx[:intent] = { symbol: ctx[:symbol], spot: ctx[:spot_price] }
32
+ # ctx
33
+ # end
34
+ # end
35
+ #
36
+ class Base
37
+ class << self
38
+ # Define a parameter for this skill.
39
+ #
40
+ # @param name [Symbol] parameter name
41
+ # @param type [Symbol] :string, :integer, :number, :boolean
42
+ # @param required [Boolean] whether the parameter is required
43
+ # @param default [Object] default value if not provided
44
+ def param(name, type: :string, required: false, default: nil, description: nil)
45
+ @params ||= {}
46
+ @params[name] = { type: type, required: required, default: default, description: description }
47
+ end
48
+
49
+ # Define a step in the skill execution sequence.
50
+ #
51
+ # @param name [Symbol] method name to call
52
+ # @param priority [Integer] execution order (lower = earlier)
53
+ def step(name, priority: 10)
54
+ @steps ||= []
55
+ @steps << { name: name, priority: priority }
56
+ @steps.sort_by! { |s| s[:priority] }
57
+ end
58
+
59
+ # MCP risk level for this skill (defaults to the most conservative tier
60
+ # so a skill that forgets to declare one fails safe/write-gated).
61
+ #
62
+ # @param level [String, nil] one of read_only, trade_adjacent_read, live_write, destructive_write
63
+ def risk(level = nil)
64
+ level ? (@risk = level) : (@risk || "destructive_write")
65
+ end
66
+
67
+ # MCP policy scope required to invoke this skill.
68
+ #
69
+ # @param value [String, nil] e.g. "orders:read", "orders:write"
70
+ def scope(value = nil)
71
+ value ? (@scope = value) : (@scope || "orders:write")
72
+ end
73
+
74
+ # Human-readable description shown to MCP/LLM clients in tools/list.
75
+ #
76
+ # @param text [String, nil] one-line summary of what the skill does
77
+ def description(text = nil)
78
+ text ? (@description = text) : @description
79
+ end
80
+
81
+ # Accessor for defined parameters.
82
+ def params
83
+ @params || {}
84
+ end
85
+
86
+ # Accessor for defined steps.
87
+ def steps
88
+ @steps || []
89
+ end
90
+
91
+ # Validate that all required parameters are present.
92
+ #
93
+ # @param args [Hash] provided parameters
94
+ # @raise [ArgumentError] if required parameters are missing
95
+ def validate_params!(args)
96
+ params.each do |name, config|
97
+ next unless config[:required]
98
+
99
+ value = args[name] || args[name.to_s]
100
+ next unless value.nil?
101
+
102
+ raise ArgumentError, "Missing required parameter: #{name}"
103
+ end
104
+ end
105
+ end
106
+
107
+ # Execute the skill with the given arguments.
108
+ #
109
+ # @param args [Hash] skill parameters (symbol or string keys)
110
+ # @return [Hash] context with all accumulated state
111
+ # @raise [ArgumentError] if required parameters are missing
112
+ def call(args = {})
113
+ ctx = build_context(args)
114
+ self.class.validate_params!(ctx)
115
+
116
+ self.class.steps.each do |step|
117
+ result = send(step[:name], ctx)
118
+ ctx = result if result.is_a?(Hash)
119
+ end
120
+
121
+ ctx
122
+ end
123
+
124
+ # Skill name (defaults to class name).
125
+ def name
126
+ self.class.name || self.class.to_s
127
+ end
128
+
129
+ # Skill description (declare via the class-level `description` macro; falls back to class name).
130
+ def description
131
+ self.class.description || self.class.to_s
132
+ end
133
+
134
+ # List of parameter definitions for this skill.
135
+ def param_definitions
136
+ self.class.params
137
+ end
138
+
139
+ private
140
+
141
+ # Real DhanHQ::Models::OptionChain#fetch shape: { last_price:, strikes: [{ strike:, call: {...}, put: {...} }] }.
142
+ # Nearest strike to a target price — always returns an entry (never nil) unless the chain is empty.
143
+ def nearest_strike(chain, target_price)
144
+ strikes = chain[:strikes]
145
+ return nil if strikes.nil? || strikes.empty?
146
+
147
+ strikes.min_by { |s| (s[:strike].to_f - target_price.to_f).abs }
148
+ end
149
+
150
+ # Exact strike match within tolerance — nil if no strike sits on that price.
151
+ def find_strike(chain, target_price, tolerance: 0.001)
152
+ strikes = chain[:strikes]
153
+ return nil if strikes.nil? || strikes.empty?
154
+
155
+ strikes.find { |s| (s[:strike].to_f - target_price.to_f).abs < tolerance }
156
+ end
157
+
158
+ def leg_side(strike_entry, option_type)
159
+ option_type == "CE" ? strike_entry[:call] : strike_entry[:put]
160
+ end
161
+
162
+ def leg_security_id(strike_entry, option_type)
163
+ leg_side(strike_entry, option_type)[:security_id]
164
+ end
165
+
166
+ def leg_premium(strike_entry, option_type)
167
+ leg_side(strike_entry, option_type)[:last_price]
168
+ end
169
+
170
+ def build_context(args)
171
+ ctx = {}
172
+
173
+ self.class.params.each do |name, config|
174
+ value = args[name] || args[name.to_s]
175
+ value = config[:default] if value.nil? && config[:default]
176
+ ctx[name] = value
177
+ end
178
+
179
+ ctx
180
+ end
181
+ end
182
+ end
183
+ end